bundles/dieschittigs/iconic/src/Controller/IconicApiController.php line 116

Open in your IDE?
  1. <?php
  2. namespace DieSchittigs\IconicWorld\Controller;
  3. use Symfony\Component\Routing\Annotation\Route;
  4. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  5. use Symfony\Component\HttpFoundation\Response;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Contao\CoreBundle\Framework\ContaoFramework;
  8. use Contao\DirectoryTagModel;
  9. use Contao\DirectoryEntryModel;
  10. use Symfony\Component\Filesystem\Filesystem;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use DateTime;
  13. use Contao\StringUtil;
  14. use Contao\Database;
  15. use Contao\DirectoryCategoryModel;
  16. use Contao\DirectoryAwardModel;
  17. use Fuse\Fuse;
  18. use Contao\File;
  19. use Contao\Image;
  20. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  21. use Contao\System;
  22. use Contao\DirectoryDistinctionModel;
  23. /**
  24. * @Route("/api", defaults={"_scope" = "frontend", "_token_check" = false})
  25. */
  26. class IconicApiController extends AbstractController
  27. {
  28. /**
  29. * @var string
  30. */
  31. protected $cacheDirectory;
  32. /**
  33. * @var Filesystem
  34. */
  35. protected $filesystem;
  36. public function setController(Filesystem $filesystem, $cacheDirectory)
  37. {
  38. //$this->container->get('contao.framework')->initialize();
  39. $this->filesystem = $filesystem;
  40. $this->cacheDirectory = $cacheDirectory;
  41. }
  42. protected function getLastModifiedDate()
  43. {
  44. $result = Database::getInstance()->execute('SELECT tstamp FROM tl_directory_entry ORDER BY tstamp DESC LIMIT 1')->row();
  45. return $result['tstamp'] ?? PHP_INT_MAX;
  46. }
  47. /**
  48. * Either returns an existing cached version of the identified request, or store
  49. * a new version of the data in cache based on the provided callable.
  50. *
  51. * @param array|string $options A set of options used to identify this request
  52. * @param callable $callable A callable used to retrieve new data for the request
  53. * @param bool $invalidate Force the cache to be invalidated
  54. *
  55. * @return array
  56. */
  57. public function cached($options, callable $callable, bool $invalidate = false)
  58. {
  59. if (!is_array($options)) $options = ['name' => $options];
  60. $options['_new'] = true;
  61. // 1. Generate unique ID from options
  62. $key = implode(',', array_keys($options)) . implode(',', array_values($options));
  63. $key = md5($key);
  64. // 2. Make sure the cache directory exists
  65. $iconicCache = $this->cacheDirectory . '/iconic/';
  66. $file = $iconicCache . $key;
  67. if ($this->filesystem->exists($this->cacheDirectory) && !$this->filesystem->exists($iconicCache)) {
  68. $this->filesystem->mkdir($iconicCache);
  69. }
  70. // $timestamp = $this->getLastModifiedDate();
  71. // 3. Invalidate cache if the file doesn't exist
  72. if ($invalidate === true || !$this->filesystem->exists($file)) {
  73. $invalidate = true;
  74. } else {
  75. // $invalidate = filemtime($file) < $timestamp;
  76. }
  77. // 4. Generate new data if cache was invalidated
  78. $data = null;
  79. if ($invalidate) {
  80. $data = $callable();
  81. $this->filesystem->dumpFile($file, json_encode($data));
  82. } else {
  83. // 5. Otherwise retreive from filesystem
  84. $data = file_get_contents($file);
  85. $data = json_decode($data, true);
  86. }
  87. return $data;
  88. }
  89. public function filters()
  90. {
  91. $cached = $this->cached('filter-options', function () {
  92. return [
  93. 'categories' => DirectoryCategoryModel::listForFilter(),
  94. 'distinctions' => DirectoryDistinctionModel::listForFilter(),
  95. 'tags' => $this->getTags(),
  96. 'years' => $this->getYears(),
  97. ];
  98. });
  99. // Awards shouldn't be cached since they can be reordered
  100. $cached['awards'] = DirectoryAwardModel::listForFilter();
  101. return $cached;
  102. }
  103. public function paginated(Request $request, $data)
  104. {
  105. $take = (int) $request->get('take', 40);
  106. $offset = (int) $request->get('offset', 0);
  107. $total = count($data);
  108. $data = array_slice($data, $offset, $take);
  109. $pagination = compact('take', 'offset', 'total', 'data');
  110. return $pagination;
  111. }
  112. protected function getRequestLanguage(Request $request)
  113. {
  114. // Determine language based on ?locale=.. parameter or request headers
  115. $language = $request->get('locale', $request->getPreferredLanguage());
  116. if (!in_array($language, ['de', 'en'])) $language = 'de';
  117. return $language;
  118. }
  119. public function getDirectoryEntries(Request $request)
  120. {
  121. $language = $this->getRequestLanguage($request);
  122. $previewKey = $request->get('key');
  123. $data = $this->cached(
  124. ['name' => 'entries', 'lang' => $language, 'previewKey' => $previewKey],
  125. function () use ($language, $previewKey) {
  126. $entries = DirectoryEntryModel::findPublished();
  127. $previewEntry = $previewKey ? DirectoryEntryModel::findOneBy('secret', $previewKey) : null;
  128. if (!$entries) return [];
  129. $entries = $entries->getModels();
  130. if ($previewEntry) array_unshift($entries, $previewEntry);
  131. $response = array_map(function ($m) use ($language) {
  132. return $m->forAPI($language);
  133. }, $entries);
  134. return $response;
  135. },
  136. !!$previewKey
  137. );
  138. // Set up seed for random sorting
  139. $seed = floor(time() / 86400); // Refreshes once per day
  140. mt_srand($seed);
  141. $order = array_map(function () {
  142. return mt_rand();
  143. }, count($data) > 0 ? range(1, count($data)) : []);
  144. array_multisort($order, $data);
  145. usort($data, function ($a, $b) {
  146. return $b['year'] <=> $a['year'];
  147. });
  148. return $data;
  149. }
  150. /**
  151. * @return Response
  152. *
  153. * @Route("/directory", name="iconic_api_base")
  154. */
  155. public function directory(Request $request)
  156. {
  157. $paginated = $this->paginated($request, $this->getDirectoryEntries($request));
  158. return $this->json($paginated);
  159. }
  160. public function filtered(Request $request)
  161. {
  162. $language = $this->getRequestLanguage($request);
  163. $filters = $request->query->all();
  164. $hash = array_intersect_key($filters, array_flip(['award', 'year', 'category', 'distinction', 'notdistinction', 'tag', 'locale']));
  165. $data = $this->cached($hash, function () use ($request, $filters) {
  166. $content = $this->getDirectoryEntries($request);
  167. $content = array_filter($content, function ($entry) use ($filters) {
  168. return DirectoryEntryModel::filtered($entry, $filters);
  169. });
  170. return array_values($content);
  171. });
  172. if ($text = $request->get('text')) {
  173. $words = array_filter(explode(' ', $text));
  174. $values = [];
  175. $fields = array_fill(0, count($words), "CONCAT_WS(e.name, ' ', t.$language, ' ', a.company, ' ', a.person, ' ', e.tags_$language) COLLATE utf8mb4_unicode_ci LIKE ?");
  176. $fields = implode(' AND ', $fields);
  177. $result = Database::getInstance()->prepare("
  178. SELECT e.productid
  179. FROM tl_directory_entry e
  180. LEFT JOIN tl_directory_text t ON t.pid = e.id
  181. LEFT JOIN tl_directory_associated a ON a.pid = e.id
  182. WHERE $fields
  183. ")->execute(array_map(function ($word) {
  184. return "%$word%";
  185. }, $words));
  186. $scores = [];
  187. while ($row = $result->next()) {
  188. if (!isset($scores[$row->productid])) $scores[$row->productid] = 0;
  189. }
  190. $keys = array_column($data, 'productid');
  191. $filtered = [];
  192. $scoring = function ($haystack, $needle, $multiplier) {
  193. $pos = stripos($haystack, $needle);
  194. if ($pos === false) return 0;
  195. if (!strlen($haystack)) return 0;
  196. $part = strlen($needle) / strlen($haystack);
  197. $normalized = 1 - ($pos / strlen($haystack));
  198. if ($pos === 0 || in_array($haystack[$pos - 1], [' ', '-'])) {
  199. $normalized = 1;
  200. }
  201. return floor(($normalized + $part) * $multiplier);
  202. };
  203. foreach ($scores as $id => &$score) {
  204. if (!in_array($id, $keys)) {
  205. unset($scores[$id]);
  206. continue;
  207. }
  208. $entry = $data[array_search($id, $keys)];
  209. foreach ($words as $word) {
  210. $score += $scoring($entry['name'], $word, 200);
  211. if ($entry['texts']['title']) $score += $scoring($entry['texts']['title'], $word, 120);
  212. if ($entry['texts']['jury']) $score += $scoring($entry['texts']['jury'], $word, 40);
  213. if ($entry['texts']['description']) $score += $scoring($entry['texts']['description'], $word, 60);
  214. $searched = [];
  215. foreach ($entry['associated'] as $assoc) {
  216. if (!in_array($assoc['company'], $searched)) {
  217. $score += $scoring($assoc['company'], $word, $assoc['type'] == 'agency' ? 50 : 30);
  218. $searched[] = $assoc['company'];
  219. }
  220. if (!in_array($assoc['person'], $searched)) {
  221. $score += $scoring($assoc['person'], $word, $assoc['type'] == 'agency' ? 50 : 30);
  222. $searched[] = $assoc['person'];
  223. }
  224. }
  225. }
  226. $score += $scoring(implode(',', $entry['tags']), implode(' ', $words), 50);
  227. $entry['match'] = $score;
  228. $filtered[$entry['productid']] = $entry;
  229. }
  230. array_multisort($scores, SORT_DESC, $filtered);
  231. $data = array_values($filtered);
  232. }
  233. return $this->paginated($request, $data);
  234. }
  235. /**
  236. * @return Response
  237. *
  238. * @Route("/directory/filtered", name="iconic_api_filtered")
  239. */
  240. public function filteredDirectory(Request $request)
  241. {
  242. $filtered = $this->filtered($request);
  243. return $this->json($filtered);
  244. }
  245. /**
  246. * @return Response
  247. *
  248. * @Route("/directory/{slug}", name="iconic_api_winner")
  249. */
  250. public function directoryEntry($slug, Request $request)
  251. {
  252. // We don't want to cache a list of all items PLUS each item individually,
  253. // so we will just reuse the existing list of all items and return the one we need.
  254. $data = $this->getDirectoryEntries($request);
  255. $key = array_search($slug, array_column($data, 'slug'));
  256. if ($key === false) {
  257. // As a fallback, try to search for an ID
  258. $key = array_search($slug, array_column($data, 'productid'));
  259. }
  260. if ($key === false) {
  261. return $this->json(['error' => 'No entry with this slug or ID'], 404);
  262. }
  263. return $this->json($data[$key]);
  264. }
  265. /**
  266. * @return Response
  267. *
  268. * @Route("/filters", name="iconic_api_filter_options")
  269. */
  270. public function availableFilters(Request $request)
  271. {
  272. return $this->json(self::filters());
  273. }
  274. protected function getTags()
  275. {
  276. $de = '';
  277. $en = '';
  278. $entries = DirectoryEntryModel::findAll()->getModels();
  279. foreach ($entries as $entry) {
  280. $de .= ',' . $entry->tags_de;
  281. $en .= ',' . $entry->tags_en;
  282. }
  283. $de = array_map('trim', array_filter(explode(',', $de)));
  284. $en = array_map('trim', array_filter(explode(',', $en)));
  285. $de = array_intersect_key($de, array_unique(array_map('strtolower', $de)));
  286. $en = array_intersect_key($en, array_unique(array_map('strtolower', $en)));
  287. setlocale(LC_COLLATE, 'de_DE.utf8');
  288. uasort($de, 'strcoll');
  289. uasort($en, 'strcoll');
  290. return ['de' => array_values($de), 'en' => array_values($en)];
  291. }
  292. protected function getYears()
  293. {
  294. $entries = DirectoryEntryModel::findAll()->getModels();
  295. $entries = array_map(function ($entry) {
  296. return $entry->year;
  297. }, $entries);
  298. $entries = array_unique($entries);
  299. $entries = array_filter($entries);
  300. rsort($entries);
  301. return array_values($entries);
  302. }
  303. }