<?php
namespace DieSchittigs\IconicWorld\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\DirectoryTagModel;
use Contao\DirectoryEntryModel;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
use DateTime;
use Contao\StringUtil;
use Contao\Database;
use Contao\DirectoryCategoryModel;
use Contao\DirectoryAwardModel;
use Fuse\Fuse;
use Contao\File;
use Contao\Image;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Contao\System;
use Contao\DirectoryDistinctionModel;
/**
* @Route("/api", defaults={"_scope" = "frontend", "_token_check" = false})
*/
class IconicApiController extends AbstractController
{
/**
* @var string
*/
protected $cacheDirectory;
/**
* @var Filesystem
*/
protected $filesystem;
public function setController(Filesystem $filesystem, $cacheDirectory)
{
//$this->container->get('contao.framework')->initialize();
$this->filesystem = $filesystem;
$this->cacheDirectory = $cacheDirectory;
}
protected function getLastModifiedDate()
{
$result = Database::getInstance()->execute('SELECT tstamp FROM tl_directory_entry ORDER BY tstamp DESC LIMIT 1')->row();
return $result['tstamp'] ?? PHP_INT_MAX;
}
/**
* Either returns an existing cached version of the identified request, or store
* a new version of the data in cache based on the provided callable.
*
* @param array|string $options A set of options used to identify this request
* @param callable $callable A callable used to retrieve new data for the request
* @param bool $invalidate Force the cache to be invalidated
*
* @return array
*/
public function cached($options, callable $callable, bool $invalidate = false)
{
if (!is_array($options)) $options = ['name' => $options];
$options['_new'] = true;
// 1. Generate unique ID from options
$key = implode(',', array_keys($options)) . implode(',', array_values($options));
$key = md5($key);
// 2. Make sure the cache directory exists
$iconicCache = $this->cacheDirectory . '/iconic/';
$file = $iconicCache . $key;
if ($this->filesystem->exists($this->cacheDirectory) && !$this->filesystem->exists($iconicCache)) {
$this->filesystem->mkdir($iconicCache);
}
// $timestamp = $this->getLastModifiedDate();
// 3. Invalidate cache if the file doesn't exist
if ($invalidate === true || !$this->filesystem->exists($file)) {
$invalidate = true;
} else {
// $invalidate = filemtime($file) < $timestamp;
}
// 4. Generate new data if cache was invalidated
$data = null;
if ($invalidate) {
$data = $callable();
$this->filesystem->dumpFile($file, json_encode($data));
} else {
// 5. Otherwise retreive from filesystem
$data = file_get_contents($file);
$data = json_decode($data, true);
}
return $data;
}
public function filters()
{
$cached = $this->cached('filter-options', function () {
return [
'categories' => DirectoryCategoryModel::listForFilter(),
'distinctions' => DirectoryDistinctionModel::listForFilter(),
'tags' => $this->getTags(),
'years' => $this->getYears(),
];
});
// Awards shouldn't be cached since they can be reordered
$cached['awards'] = DirectoryAwardModel::listForFilter();
return $cached;
}
public function paginated(Request $request, $data)
{
$take = (int) $request->get('take', 40);
$offset = (int) $request->get('offset', 0);
$total = count($data);
$data = array_slice($data, $offset, $take);
$pagination = compact('take', 'offset', 'total', 'data');
return $pagination;
}
protected function getRequestLanguage(Request $request)
{
// Determine language based on ?locale=.. parameter or request headers
$language = $request->get('locale', $request->getPreferredLanguage());
if (!in_array($language, ['de', 'en'])) $language = 'de';
return $language;
}
public function getDirectoryEntries(Request $request)
{
$language = $this->getRequestLanguage($request);
$previewKey = $request->get('key');
$data = $this->cached(
['name' => 'entries', 'lang' => $language, 'previewKey' => $previewKey],
function () use ($language, $previewKey) {
$entries = DirectoryEntryModel::findPublished();
$previewEntry = $previewKey ? DirectoryEntryModel::findOneBy('secret', $previewKey) : null;
if (!$entries) return [];
$entries = $entries->getModels();
if ($previewEntry) array_unshift($entries, $previewEntry);
$response = array_map(function ($m) use ($language) {
return $m->forAPI($language);
}, $entries);
return $response;
},
!!$previewKey
);
// Set up seed for random sorting
$seed = floor(time() / 86400); // Refreshes once per day
mt_srand($seed);
$order = array_map(function () {
return mt_rand();
}, count($data) > 0 ? range(1, count($data)) : []);
array_multisort($order, $data);
usort($data, function ($a, $b) {
return $b['year'] <=> $a['year'];
});
return $data;
}
/**
* @return Response
*
* @Route("/directory", name="iconic_api_base")
*/
public function directory(Request $request)
{
$paginated = $this->paginated($request, $this->getDirectoryEntries($request));
return $this->json($paginated);
}
public function filtered(Request $request)
{
$language = $this->getRequestLanguage($request);
$filters = $request->query->all();
$hash = array_intersect_key($filters, array_flip(['award', 'year', 'category', 'distinction', 'notdistinction', 'tag', 'locale']));
$data = $this->cached($hash, function () use ($request, $filters) {
$content = $this->getDirectoryEntries($request);
$content = array_filter($content, function ($entry) use ($filters) {
return DirectoryEntryModel::filtered($entry, $filters);
});
return array_values($content);
});
if ($text = $request->get('text')) {
$words = array_filter(explode(' ', $text));
$values = [];
$fields = array_fill(0, count($words), "CONCAT_WS(e.name, ' ', t.$language, ' ', a.company, ' ', a.person, ' ', e.tags_$language) COLLATE utf8mb4_unicode_ci LIKE ?");
$fields = implode(' AND ', $fields);
$result = Database::getInstance()->prepare("
SELECT e.productid
FROM tl_directory_entry e
LEFT JOIN tl_directory_text t ON t.pid = e.id
LEFT JOIN tl_directory_associated a ON a.pid = e.id
WHERE $fields
")->execute(array_map(function ($word) {
return "%$word%";
}, $words));
$scores = [];
while ($row = $result->next()) {
if (!isset($scores[$row->productid])) $scores[$row->productid] = 0;
}
$keys = array_column($data, 'productid');
$filtered = [];
$scoring = function ($haystack, $needle, $multiplier) {
$pos = stripos($haystack, $needle);
if ($pos === false) return 0;
if (!strlen($haystack)) return 0;
$part = strlen($needle) / strlen($haystack);
$normalized = 1 - ($pos / strlen($haystack));
if ($pos === 0 || in_array($haystack[$pos - 1], [' ', '-'])) {
$normalized = 1;
}
return floor(($normalized + $part) * $multiplier);
};
foreach ($scores as $id => &$score) {
if (!in_array($id, $keys)) {
unset($scores[$id]);
continue;
}
$entry = $data[array_search($id, $keys)];
foreach ($words as $word) {
$score += $scoring($entry['name'], $word, 200);
if ($entry['texts']['title']) $score += $scoring($entry['texts']['title'], $word, 120);
if ($entry['texts']['jury']) $score += $scoring($entry['texts']['jury'], $word, 40);
if ($entry['texts']['description']) $score += $scoring($entry['texts']['description'], $word, 60);
$searched = [];
foreach ($entry['associated'] as $assoc) {
if (!in_array($assoc['company'], $searched)) {
$score += $scoring($assoc['company'], $word, $assoc['type'] == 'agency' ? 50 : 30);
$searched[] = $assoc['company'];
}
if (!in_array($assoc['person'], $searched)) {
$score += $scoring($assoc['person'], $word, $assoc['type'] == 'agency' ? 50 : 30);
$searched[] = $assoc['person'];
}
}
}
$score += $scoring(implode(',', $entry['tags']), implode(' ', $words), 50);
$entry['match'] = $score;
$filtered[$entry['productid']] = $entry;
}
array_multisort($scores, SORT_DESC, $filtered);
$data = array_values($filtered);
}
return $this->paginated($request, $data);
}
/**
* @return Response
*
* @Route("/directory/filtered", name="iconic_api_filtered")
*/
public function filteredDirectory(Request $request)
{
$filtered = $this->filtered($request);
return $this->json($filtered);
}
/**
* @return Response
*
* @Route("/directory/{slug}", name="iconic_api_winner")
*/
public function directoryEntry($slug, Request $request)
{
// We don't want to cache a list of all items PLUS each item individually,
// so we will just reuse the existing list of all items and return the one we need.
$data = $this->getDirectoryEntries($request);
$key = array_search($slug, array_column($data, 'slug'));
if ($key === false) {
// As a fallback, try to search for an ID
$key = array_search($slug, array_column($data, 'productid'));
}
if ($key === false) {
return $this->json(['error' => 'No entry with this slug or ID'], 404);
}
return $this->json($data[$key]);
}
/**
* @return Response
*
* @Route("/filters", name="iconic_api_filter_options")
*/
public function availableFilters(Request $request)
{
return $this->json(self::filters());
}
protected function getTags()
{
$de = '';
$en = '';
$entries = DirectoryEntryModel::findAll()->getModels();
foreach ($entries as $entry) {
$de .= ',' . $entry->tags_de;
$en .= ',' . $entry->tags_en;
}
$de = array_map('trim', array_filter(explode(',', $de)));
$en = array_map('trim', array_filter(explode(',', $en)));
$de = array_intersect_key($de, array_unique(array_map('strtolower', $de)));
$en = array_intersect_key($en, array_unique(array_map('strtolower', $en)));
setlocale(LC_COLLATE, 'de_DE.utf8');
uasort($de, 'strcoll');
uasort($en, 'strcoll');
return ['de' => array_values($de), 'en' => array_values($en)];
}
protected function getYears()
{
$entries = DirectoryEntryModel::findAll()->getModels();
$entries = array_map(function ($entry) {
return $entry->year;
}, $entries);
$entries = array_unique($entries);
$entries = array_filter($entries);
rsort($entries);
return array_values($entries);
}
}