feat: serve the chatbot content manifest at /api/content/manifest
This commit is contained in:
1 parent
b4a9156df6
commit
81724c818b
8 files changed
+970
-1
No files matched your search
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\ContentFeed;
|
||||
|
||||
use EP\EpProducts\Http\ApiKeyAuthenticator;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Core\Cache\CacheManager;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* The chatbot content feed's backchannel endpoints under /api/content/.
|
||||
*
|
||||
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md.
|
||||
*/
|
||||
class ContentFeedApi
|
||||
{
|
||||
public const PATH_PREFIX = '/api/content/';
|
||||
|
||||
private const MANIFEST_CACHE_IDENTIFIER = 'content_feed_manifest';
|
||||
// The consumer polls every 15 minutes, so this caps the build at once per poll however
|
||||
// often it retries. Edits reach the chatbot within about 25 minutes.
|
||||
private const MANIFEST_CACHE_LIFETIME = 600;
|
||||
|
||||
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
|
||||
/**
|
||||
* @var ApiKeyAuthenticator
|
||||
*/
|
||||
private $authenticator;
|
||||
|
||||
/**
|
||||
* @var ContentFeedConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
/**
|
||||
* @var ManifestBuilder
|
||||
*/
|
||||
private $manifestBuilder;
|
||||
|
||||
/**
|
||||
* @var FrontendInterface
|
||||
*/
|
||||
private $cache;
|
||||
|
||||
public function __construct(
|
||||
ApiKeyAuthenticator $authenticator,
|
||||
ContentFeedConfiguration $configuration,
|
||||
ManifestBuilder $manifestBuilder,
|
||||
CacheManager $cacheManager
|
||||
) {
|
||||
$this->authenticator = $authenticator;
|
||||
$this->configuration = $configuration;
|
||||
$this->manifestBuilder = $manifestBuilder;
|
||||
$this->cache = $cacheManager->getCache('ep_products_cache');
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
if (!$this->authenticator->isAuthorized($request)) {
|
||||
return $this->error('Invalid or missing api key', 403);
|
||||
}
|
||||
|
||||
if ([] === $this->configuration->getSites()) {
|
||||
// An empty feed would read as "everything was deleted". Refuse instead.
|
||||
return $this->error('Content feed has no sites configured', 503);
|
||||
}
|
||||
|
||||
$path = substr($request->getUri()->getPath(), strlen(self::PATH_PREFIX));
|
||||
|
||||
if ('manifest' === $path) {
|
||||
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
|
||||
}
|
||||
|
||||
return $this->error('Not found', 404);
|
||||
}
|
||||
|
||||
private function getManifest(): array
|
||||
{
|
||||
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
|
||||
|
||||
if (!is_array($manifest)) {
|
||||
$manifest = $this->manifestBuilder->build();
|
||||
$this->cache->set(self::MANIFEST_CACHE_IDENTIFIER, $manifest, [], self::MANIFEST_CACHE_LIFETIME);
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
private function error(string $message, int $status): ResponseInterface
|
||||
{
|
||||
return new JsonResponse(['message' => $message], $status, [], self::JSON_FLAGS);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,16 @@ class ContentFeedConfiguration
|
||||
return GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedSites'] ?? ''), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[] language ids; the default language when unset
|
||||
*/
|
||||
public function getLanguages(): array
|
||||
{
|
||||
$languages = GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedLanguages'] ?? '0'), true);
|
||||
|
||||
return [] === $languages ? [0] : $languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[] page uids whose whole subtree stays out of the feed
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\ContentFeed;
|
||||
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
|
||||
/**
|
||||
* Builds GET /api/content/manifest: everything the content feed publishes, with a modification
|
||||
* marker per item and no bodies. The list is complete - an item missing from it is gone - so the
|
||||
* consumer never has to infer deletions from timestamps.
|
||||
*
|
||||
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §4.
|
||||
*/
|
||||
class ManifestBuilder
|
||||
{
|
||||
public const CONTENT_PAGE_TYPE = 1730;
|
||||
|
||||
private const DOKTYPE_DEFAULT = 1;
|
||||
// Recycler and backend user section: nothing below them is public.
|
||||
private const DOKTYPES_NOT_TRAVERSED = [6, 255];
|
||||
// l18n_cfg bit 1: "hide default translation of page".
|
||||
private const L18N_HIDE_DEFAULT_LANGUAGE = 1;
|
||||
|
||||
private const RECORD_FIELDS = [
|
||||
'product' => ['uid', 'tstamp', 'detail_page'],
|
||||
'hotel' => ['uid', 'tstamp', 'detail_page'],
|
||||
'region' => ['uid', 'tstamp', 'detail_page'],
|
||||
'country' => ['uid', 'tstamp', 'detail_page'],
|
||||
'city' => ['uid', 'tstamp', 'detail_page'],
|
||||
'concept' => ['uid', 'tstamp', 'detail_page'],
|
||||
'news' => ['uid', 'tstamp', 'pid'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var PageTree
|
||||
*/
|
||||
private $pageTree;
|
||||
|
||||
/**
|
||||
* @var UrlResolver
|
||||
*/
|
||||
private $urlResolver;
|
||||
|
||||
/**
|
||||
* @var RecordFinder
|
||||
*/
|
||||
private $recordFinder;
|
||||
|
||||
/**
|
||||
* @var ContentFeedConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
/**
|
||||
* @var ConnectionPool
|
||||
*/
|
||||
private $connectionPool;
|
||||
|
||||
public function __construct(
|
||||
PageTree $pageTree,
|
||||
UrlResolver $urlResolver,
|
||||
RecordFinder $recordFinder,
|
||||
ContentFeedConfiguration $configuration,
|
||||
ConnectionPool $connectionPool
|
||||
) {
|
||||
$this->pageTree = $pageTree;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->recordFinder = $recordFinder;
|
||||
$this->configuration = $configuration;
|
||||
$this->connectionPool = $connectionPool;
|
||||
}
|
||||
|
||||
public function build(): array
|
||||
{
|
||||
$sites = $this->urlResolver->getIncludedSites();
|
||||
$records = $this->buildRecordItems();
|
||||
|
||||
return [
|
||||
'generatedAt' => date('c'),
|
||||
'sites' => array_map([$this, 'describeSite'], $sites),
|
||||
'items' => array_merge($this->buildPageItems($sites, $this->collectDetailPages()), $records),
|
||||
];
|
||||
}
|
||||
|
||||
private function describeSite(Site $site): array
|
||||
{
|
||||
$languages = [];
|
||||
|
||||
foreach ($site->getLanguages() as $language) {
|
||||
$languages[] = $language->getTwoLetterIsoCode();
|
||||
}
|
||||
|
||||
$title = trim((string) ($site->getConfiguration()['websiteTitle'] ?? ''));
|
||||
|
||||
if ('' === $title) {
|
||||
$title = trim($site->getDefaultLanguage()->getWebsiteTitle());
|
||||
}
|
||||
|
||||
return [
|
||||
'identifier' => $site->getIdentifier(),
|
||||
'title' => '' !== $title ? $title : $site->getIdentifier(),
|
||||
'base' => (string) $site->getBase(),
|
||||
'languages' => $languages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Site[] $sites
|
||||
* @param array<int, array> $detailPages detailOf entries by page uid
|
||||
*/
|
||||
private function buildPageItems(array $sites, array $detailPages): array
|
||||
{
|
||||
$contentTimestamps = $this->findContentTimestamps();
|
||||
$excluded = array_flip($this->configuration->getExcludedPages());
|
||||
$feedLanguages = $this->configuration->getLanguages();
|
||||
$items = [];
|
||||
|
||||
foreach ($sites as $site) {
|
||||
$languageIds = [];
|
||||
|
||||
foreach ($site->getLanguages() as $language) {
|
||||
if (in_array($language->getLanguageId(), $feedLanguages, true)) {
|
||||
$languageIds[$language->getLanguageId()] = $language->getTwoLetterIsoCode();
|
||||
}
|
||||
}
|
||||
|
||||
$context = [
|
||||
'site' => $site,
|
||||
'languages' => $languageIds,
|
||||
'excluded' => $excluded,
|
||||
'contentTimestamps' => $contentTimestamps,
|
||||
'detailPages' => $detailPages,
|
||||
];
|
||||
$breadcrumbs = array_fill_keys(array_keys($languageIds), []);
|
||||
|
||||
$this->walk($site->getRootPageId(), $context, $breadcrumbs, true, $items);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string[]> $breadcrumbs titles above this page, by language id
|
||||
*/
|
||||
private function walk(int $uid, array $context, array $breadcrumbs, bool $isRoot, array &$items): void
|
||||
{
|
||||
$page = $this->pageTree->getPage($uid);
|
||||
|
||||
if (null === $page || isset($context['excluded'][$uid])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array((int) $page['doktype'], self::DOKTYPES_NOT_TRAVERSED, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$visible = $this->pageTree->isVisible($page);
|
||||
|
||||
if (!$visible && (int) $page['extendToSubpages'] === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$childBreadcrumbs = $breadcrumbs;
|
||||
|
||||
foreach ($context['languages'] as $languageId => $isoCode) {
|
||||
$row = $this->getLanguageRow($page, $languageId);
|
||||
|
||||
if ($visible && null !== $row && $this->isPublishable($page, $row, $languageId)) {
|
||||
$item = $this->buildPageItem($page, $row, $languageId, $isoCode, $breadcrumbs[$languageId], $context);
|
||||
|
||||
if (null !== $item) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isRoot) {
|
||||
$source = $row ?? $page;
|
||||
$childBreadcrumbs[$languageId][] = '' !== trim((string) $source['nav_title'])
|
||||
? trim((string) $source['nav_title'])
|
||||
: trim((string) $source['title']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->pageTree->getChildren($uid) as $childUid) {
|
||||
$this->walk($childUid, $context, $childBreadcrumbs, false, $items);
|
||||
}
|
||||
}
|
||||
|
||||
private function getLanguageRow(array $page, int $languageId): ?array
|
||||
{
|
||||
if (0 === $languageId) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$overlay = $this->pageTree->getOverlay((int) $page['uid'], $languageId);
|
||||
|
||||
return null !== $overlay && $this->pageTree->isVisible($overlay) ? $overlay : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the XML sitemap: standard pages only, nothing marked no_index. If Google is not
|
||||
* meant to see a page, the chatbot is not either.
|
||||
*/
|
||||
private function isPublishable(array $page, array $row, int $languageId): bool
|
||||
{
|
||||
if ((int) $page['doktype'] !== self::DOKTYPE_DEFAULT || (int) $row['no_index'] === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 0 !== $languageId || 0 === ((int) $page['l18n_cfg'] & self::L18N_HIDE_DEFAULT_LANGUAGE);
|
||||
}
|
||||
|
||||
private function buildPageItem(array $page, array $row, int $languageId, string $isoCode, array $breadcrumb, array $context): ?array
|
||||
{
|
||||
$uid = (int) $page['uid'];
|
||||
$url = $this->urlResolver->getPageUrl($uid, $languageId);
|
||||
|
||||
if (null === $url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified = max(
|
||||
(int) $row['tstamp'],
|
||||
$context['contentTimestamps'][$uid][$languageId] ?? 0,
|
||||
$context['contentTimestamps'][$uid][-1] ?? 0
|
||||
);
|
||||
|
||||
return [
|
||||
'type' => 'page',
|
||||
'uid' => $uid,
|
||||
'language' => $isoCode,
|
||||
'site' => $context['site']->getIdentifier(),
|
||||
'url' => $url,
|
||||
'contentUrl' => $this->urlResolver->getPageUrl($uid, $languageId, ['type' => self::CONTENT_PAGE_TYPE]),
|
||||
'title' => trim((string) $row['title']),
|
||||
'description' => $this->nullIfEmpty((string) $row['description']),
|
||||
'breadcrumb' => $breadcrumb,
|
||||
'detailOf' => $context['detailPages'][$uid] ?? [],
|
||||
'modified' => date('c', $modified),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest change to any content element, by page and language. Deleted and hidden elements
|
||||
* count - removing or hiding one changes the page - which is also why SYS_LASTCHANGED is no
|
||||
* use here: it only moves when the page is rendered.
|
||||
*
|
||||
* @return array<int, array<int, int>>
|
||||
*/
|
||||
private function findContentTimestamps(): array
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
|
||||
$queryBuilder->getRestrictions()->removeAll();
|
||||
|
||||
$rows = $queryBuilder
|
||||
->selectLiteral('pid', 'sys_language_uid', 'MAX(tstamp) AS tstamp')
|
||||
->from('tt_content')
|
||||
->groupBy('pid', 'sys_language_uid')
|
||||
->execute()
|
||||
->fetchAllAssociative()
|
||||
;
|
||||
|
||||
$timestamps = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$timestamps[(int) $row['pid']][(int) $row['sys_language_uid']] = (int) $row['tstamp'];
|
||||
}
|
||||
|
||||
return $timestamps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which records name a page as their detail page. Keyed by the page the record points at,
|
||||
* not by where a shortcut leads: a shortcut is not in the manifest, and the page it leads to
|
||||
* usually describes something broader (product detail shortcuts point at region pages).
|
||||
*
|
||||
* @return array<int, array>
|
||||
*/
|
||||
private function collectDetailPages(): array
|
||||
{
|
||||
$detailPages = [];
|
||||
|
||||
foreach (self::RECORD_FIELDS as $type => $fields) {
|
||||
if (!in_array('detail_page', $fields, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->recordFinder->find($type, ['uid', 'detail_page']) as $uid => $row) {
|
||||
$pageUid = $this->urlResolver->parsePageReference((string) $row['detail_page']);
|
||||
|
||||
if (null !== $pageUid) {
|
||||
$detailPages[$pageUid][] = ['type' => $type, 'uid' => $uid];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $detailPages;
|
||||
}
|
||||
|
||||
private function buildRecordItems(): array
|
||||
{
|
||||
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
|
||||
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
|
||||
$items = [];
|
||||
|
||||
foreach (self::RECORD_FIELDS as $type => $fields) {
|
||||
foreach ($this->recordFinder->find($type, $fields) as $uid => $row) {
|
||||
if ('news' === $type) {
|
||||
$link = $this->urlResolver->getNewsLink((int) $row['uid'], (int) $row['pid']);
|
||||
|
||||
// News has no detail_page of its own: an article stored outside the feed's
|
||||
// sites (the retired ep-events) is not part of the feed at all.
|
||||
if (null === $link) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
|
||||
}
|
||||
|
||||
$modified = (int) $row['tstamp'];
|
||||
|
||||
if ('product' === $type) {
|
||||
$modified = max($modified, $faqTimestamps[$uid] ?? 0);
|
||||
}
|
||||
|
||||
if ('hotel' === $type) {
|
||||
$modified = max($modified, $matchcodeTimestamps[$uid] ?? 0);
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'type' => $type,
|
||||
'uid' => $uid,
|
||||
'language' => 'de',
|
||||
'site' => $link['site'],
|
||||
'url' => $link['url'],
|
||||
'modified' => date('c', $modified),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function nullIfEmpty(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\ContentFeed;
|
||||
|
||||
use TYPO3\CMS\Core\Context\Context;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
|
||||
|
||||
/**
|
||||
* The whole page table in memory, for the content feed's tree walk and shortcut resolution.
|
||||
*
|
||||
* About 2 900 rows: one query and a PHP walk is far cheaper than a query per tree level.
|
||||
* Visibility is evaluated here, not in SQL, because a hidden page still has to be traversed
|
||||
* (its subpages stay reachable unless extendToSubpages is set).
|
||||
*/
|
||||
class PageTree
|
||||
{
|
||||
private const FIELDS = [
|
||||
'uid', 'pid', 'sorting', 'doktype', 'hidden', 'starttime', 'endtime', 'fe_group',
|
||||
'extendToSubpages', 'no_index', 'title', 'nav_title', 'description', 'shortcut',
|
||||
'shortcut_mode', 'tstamp', 'sys_language_uid', 'l10n_parent', 'l18n_cfg',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var ConnectionPool
|
||||
*/
|
||||
private $connectionPool;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $now;
|
||||
|
||||
/**
|
||||
* @var array<int, array>|null default-language rows by uid
|
||||
*/
|
||||
private $pages;
|
||||
|
||||
/**
|
||||
* @var array<int, int[]> child uids by parent uid, in sorting order
|
||||
*/
|
||||
private $children = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array<int, array>> overlay rows by default-language uid and language id
|
||||
*/
|
||||
private $overlays = [];
|
||||
|
||||
public function __construct(ConnectionPool $connectionPool, Context $context)
|
||||
{
|
||||
$this->connectionPool = $connectionPool;
|
||||
$this->now = (int) $context->getPropertyFromAspect('date', 'timestamp');
|
||||
}
|
||||
|
||||
public function getPage(int $uid): ?array
|
||||
{
|
||||
$this->load();
|
||||
|
||||
return $this->pages[$uid] ?? null;
|
||||
}
|
||||
|
||||
public function getOverlay(int $uid, int $languageId): ?array
|
||||
{
|
||||
$this->load();
|
||||
|
||||
return $this->overlays[$uid][$languageId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function getChildren(int $uid): array
|
||||
{
|
||||
$this->load();
|
||||
|
||||
return $this->children[$uid] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible to an anonymous frontend visitor: not hidden, inside its start/end window, and not
|
||||
* restricted to a frontend user group.
|
||||
*/
|
||||
public function isVisible(array $row): bool
|
||||
{
|
||||
if ((int) $row['hidden'] === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $row['starttime'] > $this->now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $row['endtime'] !== 0 && (int) $row['endtime'] <= $this->now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array((string) $row['fe_group'], ['', '0'], true);
|
||||
}
|
||||
|
||||
private function load(): void
|
||||
{
|
||||
if (null !== $this->pages) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
$queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction());
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select(...self::FIELDS)
|
||||
->from('pages')
|
||||
->orderBy('sorting')
|
||||
->execute()
|
||||
->fetchAllAssociative()
|
||||
;
|
||||
|
||||
$this->pages = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$uid = (int) $row['uid'];
|
||||
|
||||
if ((int) $row['sys_language_uid'] > 0) {
|
||||
$this->overlays[(int) $row['l10n_parent']][(int) $row['sys_language_uid']] = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->pages[$uid] = $row;
|
||||
$this->children[(int) $row['pid']][] = $uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\ContentFeed;
|
||||
|
||||
use TYPO3\CMS\Core\Database\Connection;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Which records the content feed publishes. The manifest and the records endpoint both go
|
||||
* through here, so a uid the manifest lists is exactly a uid the records endpoint returns.
|
||||
*
|
||||
* The default restrictions apply: deleted, hidden, starttime and endtime.
|
||||
*/
|
||||
class RecordFinder
|
||||
{
|
||||
public const TYPES = [
|
||||
'product' => 'tx_epproducts_domain_model_product',
|
||||
'hotel' => 'tx_epproducts_domain_model_hotel',
|
||||
'region' => 'tx_epproducts_domain_model_region',
|
||||
'country' => 'tx_epproducts_domain_model_country',
|
||||
'city' => 'tx_epproducts_domain_model_city',
|
||||
'concept' => 'tx_epproducts_domain_model_concept',
|
||||
'news' => 'tx_news_domain_model_news',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var ConnectionPool
|
||||
*/
|
||||
private $connectionPool;
|
||||
|
||||
public function __construct(ConnectionPool $connectionPool)
|
||||
{
|
||||
$this->connectionPool = $connectionPool;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $fields
|
||||
* @param int[]|null $uids null for all eligible records
|
||||
* @return array<int, array> rows by uid, in uid order
|
||||
*/
|
||||
public function find(string $type, array $fields, ?array $uids = null): array
|
||||
{
|
||||
$table = self::TYPES[$type];
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
|
||||
$queryBuilder
|
||||
->select(...$fields)
|
||||
->from($table)
|
||||
->where($queryBuilder->expr()->in('sys_language_uid', [0, -1]))
|
||||
->orderBy('uid')
|
||||
;
|
||||
|
||||
if (null !== $uids) {
|
||||
if ([] === $uids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queryBuilder->andWhere($queryBuilder->expr()->in(
|
||||
'uid',
|
||||
$queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)
|
||||
));
|
||||
}
|
||||
|
||||
$this->addTypeConstraints($type, $queryBuilder);
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($queryBuilder->execute()->fetchAllAssociative() as $row) {
|
||||
$rows[(int) $row['uid']] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest change to a product's FAQs, by product uid. Deleted and hidden FAQs count: removing
|
||||
* one is a change to the product's text.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function findFaqTimestamps(): array
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
|
||||
$queryBuilder->getRestrictions()->removeAll();
|
||||
|
||||
$rows = $queryBuilder
|
||||
->selectLiteral('mm.uid_local AS product', 'MAX(faq.tstamp) AS tstamp')
|
||||
->from('tx_epproducts_product_faq_mm', 'mm')
|
||||
->join('mm', 'tx_epproducts_domain_model_faq', 'faq', 'faq.uid = mm.uid_foreign')
|
||||
->groupBy('mm.uid_local')
|
||||
->execute()
|
||||
->fetchAllAssociative()
|
||||
;
|
||||
|
||||
return $this->indexTimestamps($rows, 'product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest change to a hotel's matchcode aliases, by hotel uid. They are part of the hotel's
|
||||
* `codes`, which the consumer joins its trip data on.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function findMatchcodeTimestamps(): array
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
|
||||
$queryBuilder->getRestrictions()->removeAll();
|
||||
|
||||
$rows = $queryBuilder
|
||||
->selectLiteral('entity AS hotel', 'MAX(tstamp) AS tstamp')
|
||||
->from('tx_epproducts_domain_model_matchcode')
|
||||
->where($queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')))
|
||||
->groupBy('entity')
|
||||
->execute()
|
||||
->fetchAllAssociative()
|
||||
;
|
||||
|
||||
return $this->indexTimestamps($rows, 'hotel');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function indexTimestamps(array $rows, string $key): array
|
||||
{
|
||||
$timestamps = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$timestamps[(int) $row[$key]] = (int) $row['tstamp'];
|
||||
}
|
||||
|
||||
return $timestamps;
|
||||
}
|
||||
|
||||
private function addTypeConstraints(string $type, QueryBuilder $queryBuilder): void
|
||||
{
|
||||
$expr = $queryBuilder->expr();
|
||||
|
||||
if ('product' === $type) {
|
||||
// A product that is neither searchable nor bookable is an internal record.
|
||||
$queryBuilder->andWhere($expr->orX($expr->eq('searchable', 1), $expr->eq('bookable', 1)));
|
||||
}
|
||||
|
||||
if ('news' === $type) {
|
||||
// Type 0 is a regular article; 1 and 2 are just links to a page or an external URL.
|
||||
$queryBuilder->andWhere(
|
||||
$expr->eq('type', $queryBuilder->createNamedParameter('0')),
|
||||
$expr->in('fe_group', $queryBuilder->createNamedParameter(['', '0'], Connection::PARAM_STR_ARRAY))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace EP\EpProducts\ContentFeed;
|
||||
|
||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||
use TYPO3\CMS\Core\Site\Entity\Site;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Core\Utility\MathUtility;
|
||||
|
||||
/**
|
||||
* Absolute public URLs for the content feed, generated through each site's router so that
|
||||
* baseVariants apply (staging and DDEV get their own hosts) and route enhancers shape the path.
|
||||
*/
|
||||
class UrlResolver
|
||||
{
|
||||
private const DOKTYPE_DEFAULT = 1;
|
||||
private const DOKTYPE_SHORTCUT = 4;
|
||||
private const SHORTCUT_MODE_NONE = 0;
|
||||
private const SHORTCUT_MODE_FIRST_SUBPAGE = 1;
|
||||
private const SHORTCUT_MODE_PARENT_PAGE = 3;
|
||||
private const MAX_SHORTCUT_DEPTH = 5;
|
||||
|
||||
/**
|
||||
* @var SiteFinder
|
||||
*/
|
||||
private $siteFinder;
|
||||
|
||||
/**
|
||||
* @var PageTree
|
||||
*/
|
||||
private $pageTree;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private $includedSites;
|
||||
|
||||
/**
|
||||
* @var array<string, int> news detail page uid by site identifier
|
||||
*/
|
||||
private $newsDetailPages;
|
||||
|
||||
/**
|
||||
* @var array<int, Site|null>
|
||||
*/
|
||||
private $sitesByPage = [];
|
||||
|
||||
public function __construct(SiteFinder $siteFinder, PageTree $pageTree, ContentFeedConfiguration $configuration)
|
||||
{
|
||||
$this->siteFinder = $siteFinder;
|
||||
$this->pageTree = $pageTree;
|
||||
$this->includedSites = $configuration->getSites();
|
||||
$this->newsDetailPages = $configuration->getNewsDetailPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Site[] included sites, in configuration order
|
||||
*/
|
||||
public function getIncludedSites(): array
|
||||
{
|
||||
$sites = [];
|
||||
|
||||
foreach ($this->includedSites as $identifier) {
|
||||
try {
|
||||
$sites[] = $this->siteFinder->getSiteByIdentifier($identifier);
|
||||
} catch (SiteNotFoundException $e) {
|
||||
// A misspelt identifier must not take the whole feed down; it just contributes nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return $sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's site, or null when it belongs to no site or to one outside the feed.
|
||||
*/
|
||||
public function getIncludedSite(int $pageUid): ?Site
|
||||
{
|
||||
if (!array_key_exists($pageUid, $this->sitesByPage)) {
|
||||
try {
|
||||
$site = $this->siteFinder->getSiteByPageId($pageUid);
|
||||
$this->sitesByPage[$pageUid] = in_array($site->getIdentifier(), $this->includedSites, true) ? $site : null;
|
||||
} catch (SiteNotFoundException $e) {
|
||||
$this->sitesByPage[$pageUid] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->sitesByPage[$pageUid];
|
||||
}
|
||||
|
||||
public function getPageUrl(int $pageUid, int $languageId = 0, array $parameters = []): ?string
|
||||
{
|
||||
$site = $this->getIncludedSite($pageUid);
|
||||
|
||||
if (null === $site) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$language = $site->getLanguageById($languageId);
|
||||
|
||||
return (string) $site->getRouter()->generateUri($pageUid, ['_language' => $language] + $parameters);
|
||||
} catch (\Throwable $e) {
|
||||
// Unroutable pages (missing slug, disabled language) are left out rather than failing the feed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a typolink page reference as stored in `detail_page`: a plain uid or `t3://page?uid=N`.
|
||||
*/
|
||||
public function parsePageReference(string $reference): ?int
|
||||
{
|
||||
$reference = trim($reference);
|
||||
|
||||
if (MathUtility::canBeInterpretedAsInteger($reference)) {
|
||||
return (int) $reference > 0 ? (int) $reference : null;
|
||||
}
|
||||
|
||||
if (1 === preg_match('/^t3:\/\/page\?(?:.*&)?uid=(\d+)/', html_entity_decode($reference), $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows shortcut pages the way a typolink does, and returns the uid of the visible standard
|
||||
* page a link to $pageUid ends up on - or null when the chain breaks or loops.
|
||||
*/
|
||||
public function resolveLinkTarget(int $pageUid): ?int
|
||||
{
|
||||
for ($depth = 0; $depth < self::MAX_SHORTCUT_DEPTH; $depth++) {
|
||||
$page = $this->pageTree->getPage($pageUid);
|
||||
|
||||
if (null === $page || !$this->pageTree->isVisible($page)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $page['doktype'] !== self::DOKTYPE_SHORTCUT) {
|
||||
return (int) $page['doktype'] === self::DOKTYPE_DEFAULT ? $pageUid : null;
|
||||
}
|
||||
|
||||
$next = $this->getShortcutTarget($page);
|
||||
|
||||
if (null === $next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pageUid = $next;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{url: ?string, site: ?string}
|
||||
*/
|
||||
public function getRecordLink(string $detailPage): array
|
||||
{
|
||||
$pageUid = $this->parsePageReference($detailPage);
|
||||
$target = null === $pageUid ? null : $this->resolveLinkTarget($pageUid);
|
||||
$site = null === $target ? null : $this->getIncludedSite($target);
|
||||
$url = null === $site ? null : $this->getPageUrl($target);
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'site' => null === $url ? null : $site->getIdentifier(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* News has no detail_page of its own: the storage folder decides the site, and the site's
|
||||
* configured detail page carries the article through the NewsDetailPlugin route enhancer.
|
||||
*
|
||||
* @return array{url: ?string, site: string}|null null when the article is stored outside the feed's sites
|
||||
*/
|
||||
public function getNewsLink(int $newsUid, int $storagePid): ?array
|
||||
{
|
||||
$site = $this->getIncludedSite($storagePid);
|
||||
|
||||
if (null === $site) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$detailPage = $this->newsDetailPages[$site->getIdentifier()] ?? null;
|
||||
$url = null === $detailPage ? null : $this->getPageUrl($detailPage, 0, [
|
||||
'tx_news_pi1' => ['action' => 'detail', 'controller' => 'News', 'news' => $newsUid],
|
||||
]);
|
||||
|
||||
return ['url' => $url, 'site' => $site->getIdentifier()];
|
||||
}
|
||||
|
||||
private function getShortcutTarget(array $page): ?int
|
||||
{
|
||||
switch ((int) $page['shortcut_mode']) {
|
||||
case self::SHORTCUT_MODE_NONE:
|
||||
return (int) $page['shortcut'] > 0 ? (int) $page['shortcut'] : null;
|
||||
case self::SHORTCUT_MODE_FIRST_SUBPAGE:
|
||||
foreach ($this->pageTree->getChildren((int) $page['uid']) as $childUid) {
|
||||
if ($this->pageTree->isVisible($this->pageTree->getPage($childUid))) {
|
||||
return $childUid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
case self::SHORTCUT_MODE_PARENT_PAGE:
|
||||
return (int) $page['pid'] > 0 ? (int) $page['pid'] : null;
|
||||
default:
|
||||
// Random subpage: there is no stable URL to give.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace EP\EpProducts\Middleware;
|
||||
|
||||
use EP\EpProducts\ContentFeed\ContentFeedApi;
|
||||
use EP\EpProducts\Service\MyEpImageService;
|
||||
use EP\EpProducts\Utility\HotelIconUtility;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
@@ -41,18 +42,25 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
*/
|
||||
private $imageService;
|
||||
|
||||
/**
|
||||
* @var ContentFeedApi
|
||||
*/
|
||||
private $contentFeedApi;
|
||||
|
||||
public function __construct(
|
||||
MyEpImageService $hotelImageService,
|
||||
MyEpImageService $productImageService,
|
||||
MyEpImageService $regionImageService,
|
||||
FileRepository $fileRepository,
|
||||
ImageService $imageService
|
||||
ImageService $imageService,
|
||||
ContentFeedApi $contentFeedApi
|
||||
) {
|
||||
$this->hotelImageService = $hotelImageService;
|
||||
$this->productImageService = $productImageService;
|
||||
$this->regionImageService = $regionImageService;
|
||||
$this->fileRepository = $fileRepository;
|
||||
$this->imageService = $imageService;
|
||||
$this->contentFeedApi = $contentFeedApi;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
@@ -67,6 +75,10 @@ class ApiMiddleware implements MiddlewareInterface
|
||||
return $this->handleProductRequest($request);
|
||||
}
|
||||
|
||||
if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) {
|
||||
return $this->contentFeedApi->handle($request);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ contentFeedEnabled = 1
|
||||
# cat=ContentFeed; type=string; label=Site identifiers included in the chatbot content feed (comma-separated)
|
||||
contentFeedSites = ep-reisen,ser,ski-boarderweek,ski-wochenenden,snowzone
|
||||
|
||||
# cat=ContentFeed; type=string; label=Language ids included in the chatbot content feed (comma-separated; the English pages are untranslated placeholders today)
|
||||
contentFeedLanguages = 0
|
||||
|
||||
# cat=ContentFeed; type=string; label=Page uids whose subtrees stay out of the chatbot content feed (comma-separated; 634 = group booking links)
|
||||
contentFeedExcludedPages = 634
|
||||
|
||||
|
||||
Reference in new issue
Block a user