feat: serve chatbot content records at /api/content/records
This commit is contained in:
1 parent
81724c818b
commit
57b09624f4
3 files changed
+665
No files matched your search
@@ -8,6 +8,7 @@ use Psr\Http\Message\ServerRequestInterface;
|
|||||||
use TYPO3\CMS\Core\Cache\CacheManager;
|
use TYPO3\CMS\Core\Cache\CacheManager;
|
||||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chatbot content feed's backchannel endpoints under /api/content/.
|
* The chatbot content feed's backchannel endpoints under /api/content/.
|
||||||
@@ -23,6 +24,8 @@ class ContentFeedApi
|
|||||||
// often it retries. Edits reach the chatbot within about 25 minutes.
|
// often it retries. Edits reach the chatbot within about 25 minutes.
|
||||||
private const MANIFEST_CACHE_LIFETIME = 600;
|
private const MANIFEST_CACHE_LIFETIME = 600;
|
||||||
|
|
||||||
|
private const MAX_RECORDS_PER_REQUEST = 100;
|
||||||
|
|
||||||
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +43,11 @@ class ContentFeedApi
|
|||||||
*/
|
*/
|
||||||
private $manifestBuilder;
|
private $manifestBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var RecordSerializer
|
||||||
|
*/
|
||||||
|
private $recordSerializer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var FrontendInterface
|
* @var FrontendInterface
|
||||||
*/
|
*/
|
||||||
@@ -49,11 +57,13 @@ class ContentFeedApi
|
|||||||
ApiKeyAuthenticator $authenticator,
|
ApiKeyAuthenticator $authenticator,
|
||||||
ContentFeedConfiguration $configuration,
|
ContentFeedConfiguration $configuration,
|
||||||
ManifestBuilder $manifestBuilder,
|
ManifestBuilder $manifestBuilder,
|
||||||
|
RecordSerializer $recordSerializer,
|
||||||
CacheManager $cacheManager
|
CacheManager $cacheManager
|
||||||
) {
|
) {
|
||||||
$this->authenticator = $authenticator;
|
$this->authenticator = $authenticator;
|
||||||
$this->configuration = $configuration;
|
$this->configuration = $configuration;
|
||||||
$this->manifestBuilder = $manifestBuilder;
|
$this->manifestBuilder = $manifestBuilder;
|
||||||
|
$this->recordSerializer = $recordSerializer;
|
||||||
$this->cache = $cacheManager->getCache('ep_products_cache');
|
$this->cache = $cacheManager->getCache('ep_products_cache');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +84,47 @@ class ContentFeedApi
|
|||||||
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
|
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('records' === $path) {
|
||||||
|
return $this->handleRecordsRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->error('Not found', 404);
|
return $this->error('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uids that are not (or no longer) eligible are left out rather than answered with an error:
|
||||||
|
* the manifest may have changed between the consumer's two calls, and it reads a requested
|
||||||
|
* but absent uid as deleted.
|
||||||
|
*/
|
||||||
|
private function handleRecordsRequest(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$type = (string) ($request->getQueryParams()['type'] ?? '');
|
||||||
|
|
||||||
|
if (!isset(RecordFinder::TYPES[$type])) {
|
||||||
|
return $this->error('Unknown type, expected one of: ' . implode(', ', array_keys(RecordFinder::TYPES)), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uids = array_values(array_unique(array_filter(
|
||||||
|
GeneralUtility::intExplode(',', (string) ($request->getQueryParams()['uids'] ?? ''), true),
|
||||||
|
function (int $uid): bool {
|
||||||
|
return $uid > 0;
|
||||||
|
}
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ([] === $uids) {
|
||||||
|
return $this->error('No uids given', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($uids) > self::MAX_RECORDS_PER_REQUEST) {
|
||||||
|
return $this->error(sprintf('At most %d uids per request', self::MAX_RECORDS_PER_REQUEST), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'type' => $type,
|
||||||
|
'items' => $this->recordSerializer->serialize($type, $uids),
|
||||||
|
], 200, [], self::JSON_FLAGS);
|
||||||
|
}
|
||||||
|
|
||||||
private function getManifest(): array
|
private function getManifest(): array
|
||||||
{
|
{
|
||||||
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
|
$manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER);
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\ContentFeed;
|
||||||
|
|
||||||
|
use EP\EpProducts\Utility\HotelIconUtility;
|
||||||
|
use TYPO3\CMS\Core\Database\Connection;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds GET /api/content/records: the editorial content of products, hotels, destinations,
|
||||||
|
* concepts and blog articles. Filter attributes and text only - no dates, prices or images.
|
||||||
|
* Dates and prices are the consumer's own data; images play no part in a text chatbot.
|
||||||
|
*
|
||||||
|
* Rich-text fields go out as stored HTML with `t3://` links resolved; the consumer converts
|
||||||
|
* them to Markdown together with the rendered pages, so there is one HTML-to-text step.
|
||||||
|
*
|
||||||
|
* See .ddev/plans/CHATBOT-MCP-01-TYPO3-CONTENT-FEED.md §5.
|
||||||
|
*/
|
||||||
|
class RecordSerializer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RecordFinder
|
||||||
|
*/
|
||||||
|
private $recordFinder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var UrlResolver
|
||||||
|
*/
|
||||||
|
private $urlResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var RteLinkResolver
|
||||||
|
*/
|
||||||
|
private $rteLinkResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConnectionPool
|
||||||
|
*/
|
||||||
|
private $connectionPool;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
RecordFinder $recordFinder,
|
||||||
|
UrlResolver $urlResolver,
|
||||||
|
RteLinkResolver $rteLinkResolver,
|
||||||
|
ConnectionPool $connectionPool
|
||||||
|
) {
|
||||||
|
$this->recordFinder = $recordFinder;
|
||||||
|
$this->urlResolver = $urlResolver;
|
||||||
|
$this->rteLinkResolver = $rteLinkResolver;
|
||||||
|
$this->connectionPool = $connectionPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $uids
|
||||||
|
* @return array[] one item per eligible uid; ineligible uids are left out
|
||||||
|
*/
|
||||||
|
public function serialize(string $type, array $uids): array
|
||||||
|
{
|
||||||
|
$rows = $this->recordFinder->find($type, ['*'], $uids);
|
||||||
|
|
||||||
|
if ([] === $rows) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'product':
|
||||||
|
return $this->serializeProducts($rows);
|
||||||
|
case 'hotel':
|
||||||
|
return $this->serializeHotels($rows);
|
||||||
|
case 'news':
|
||||||
|
return $this->serializeNews($rows);
|
||||||
|
default:
|
||||||
|
return array_values(array_map(function (array $row) use ($type): array {
|
||||||
|
return $this->serializeDestination($type, $row);
|
||||||
|
}, $rows));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeProducts(array $rows): array
|
||||||
|
{
|
||||||
|
$uids = array_keys($rows);
|
||||||
|
$hotels = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_local', 'uid_foreign', $uids);
|
||||||
|
$regions = $this->findMmUids('tx_epproducts_product_region_mm', 'uid_local', 'uid_foreign', $uids);
|
||||||
|
$faqs = $this->findFaqs($uids);
|
||||||
|
$faqTimestamps = $this->recordFinder->findFaqTimestamps();
|
||||||
|
$infoboxes = $this->findInfoboxTexts(array_column($rows, 'non_bookable_info'));
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($rows as $uid => $row) {
|
||||||
|
$season = trim((string) $row['season']);
|
||||||
|
|
||||||
|
$items[] = $this->withLink($row, max((int) $row['tstamp'], $faqTimestamps[$uid] ?? 0)) + [
|
||||||
|
'code' => $this->text($row['code']),
|
||||||
|
'busProId' => (int) $row['bus_pro_id'] ?: null,
|
||||||
|
'name' => $this->text($row['name']),
|
||||||
|
'subline' => $this->text($row['subline']),
|
||||||
|
'headline' => $this->text($row['headline']),
|
||||||
|
'season' => '' === $season ? 'w' : $season,
|
||||||
|
'daytrip' => (bool) $row['daytrip'],
|
||||||
|
'bookable' => (bool) $row['bookable'],
|
||||||
|
'searchable' => (bool) $row['searchable'],
|
||||||
|
'skipassIncluded' => (bool) $row['skipass_included'],
|
||||||
|
'conceptUid' => (int) $row['concept'] ?: null,
|
||||||
|
'countryUid' => (int) $row['country'] ?: null,
|
||||||
|
'regionUid' => (int) $row['region'] ?: null,
|
||||||
|
'cityUid' => (int) $row['city'] ?: null,
|
||||||
|
'additionalRegionUids' => $regions[$uid] ?? [],
|
||||||
|
'hotelUids' => $hotels[$uid] ?? [],
|
||||||
|
'keywords' => $this->text($row['keywords']),
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'teaserLong' => $this->html($row['teaser_long']),
|
||||||
|
'feature' => $this->text($row['feature']),
|
||||||
|
'conceptDescription' => $this->html($row['concept_description']),
|
||||||
|
'boardDescription' => $this->html($row['board_description']),
|
||||||
|
'includedServices' => $this->html($row['included_services']),
|
||||||
|
'additionalServices' => $this->html($row['additional_services']),
|
||||||
|
'programmeDescription' => $this->html($row['programme_description']),
|
||||||
|
'skiPassDescription' => $this->html($row['ski_pass_description']),
|
||||||
|
'nonBookableInfo' => $infoboxes[(int) $row['non_bookable_info']] ?? null,
|
||||||
|
],
|
||||||
|
'faqs' => $faqs[$uid] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeHotels(array $rows): array
|
||||||
|
{
|
||||||
|
$uids = array_keys($rows);
|
||||||
|
$aliases = $this->findMatchcodes($uids);
|
||||||
|
$matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps();
|
||||||
|
$products = $this->findMmUids('tx_epproducts_product_hotel_mm', 'uid_foreign', 'uid_local', $uids, 'sorting_foreign');
|
||||||
|
$eligibleProducts = $this->recordFinder->find('product', ['uid'], array_values(array_unique(array_merge([], ...array_values($products)))));
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($rows as $uid => $row) {
|
||||||
|
$code = trim((string) $row['code']);
|
||||||
|
$codes = array_values(array_unique(array_filter(array_merge([$code], $aliases[$uid] ?? []), 'strlen')));
|
||||||
|
|
||||||
|
$items[] = $this->withLink($row, max((int) $row['tstamp'], $matchcodeTimestamps[$uid] ?? 0)) + [
|
||||||
|
'code' => '' === $code ? null : $code,
|
||||||
|
'codes' => $codes,
|
||||||
|
'name' => $this->text($row['name']),
|
||||||
|
'shortName' => $this->text($row['short_name']),
|
||||||
|
'headline' => $this->text($row['headline']),
|
||||||
|
'type' => (int) $row['type'],
|
||||||
|
'category' => (int) $row['category'],
|
||||||
|
'new' => (bool) $row['new'],
|
||||||
|
'earlybird' => (bool) $row['earlybird'],
|
||||||
|
'countryUid' => (int) $row['country'] ?: null,
|
||||||
|
'regionUid' => (int) $row['region'] ?: null,
|
||||||
|
'cityUid' => (int) $row['city'] ?: null,
|
||||||
|
'productUids' => array_values(array_filter($products[$uid] ?? [], function (int $productUid) use ($eligibleProducts): bool {
|
||||||
|
return isset($eligibleProducts[$productUid]);
|
||||||
|
})),
|
||||||
|
'address' => $this->text($row['address']),
|
||||||
|
'latitude' => $this->text($row['latitude']),
|
||||||
|
'longitude' => $this->text($row['longitude']),
|
||||||
|
'facts' => [
|
||||||
|
'bedsCount' => (int) $row['beds_count'] ?: null,
|
||||||
|
'parkingCount' => (int) $row['parking_count'] ?: null,
|
||||||
|
'altitude' => $this->text($row['altitude']),
|
||||||
|
'distanceBus' => $this->text($row['distance_bus']),
|
||||||
|
'distanceLift' => $this->text($row['distance_lift']),
|
||||||
|
'shower' => $this->text($row['shower']),
|
||||||
|
'partyroom' => (bool) $row['partyroom'],
|
||||||
|
'gamesroom' => (bool) $row['gamesroom'],
|
||||||
|
'sauna' => (bool) $row['sauna'],
|
||||||
|
'wifi' => (bool) $row['wifi'],
|
||||||
|
],
|
||||||
|
'icons' => HotelIconUtility::getIcons($row),
|
||||||
|
'keywords' => $this->text($row['keywords']),
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'description' => $this->html($row['description']),
|
||||||
|
'features' => $this->html($row['features']),
|
||||||
|
'roomTypes' => $this->html($row['room_types']),
|
||||||
|
'additionalInformation' => $this->html($row['additional_information']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeDestination(string $type, array $row): array
|
||||||
|
{
|
||||||
|
$item = $this->withLink($row, (int) $row['tstamp']) + [
|
||||||
|
'name' => $this->text($row['name']),
|
||||||
|
'headline' => $this->text($row['headline']),
|
||||||
|
];
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'region':
|
||||||
|
return $item + [
|
||||||
|
'season' => $this->text($row['season']),
|
||||||
|
'keywords' => $this->text($row['keywords']),
|
||||||
|
'countryUid' => (int) $row['country'] ?: null,
|
||||||
|
'facts' => [
|
||||||
|
'length' => $this->text($row['length']),
|
||||||
|
'height' => $this->text($row['height']),
|
||||||
|
'lifts' => $this->text($row['lifts']),
|
||||||
|
],
|
||||||
|
'webcam' => $this->findWebcamUrl((int) $row['webcam']),
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'description' => $this->html($row['description']),
|
||||||
|
'feature' => $this->text($row['feature']),
|
||||||
|
'skiArea' => $this->html($row['ski_area']),
|
||||||
|
'skiAreaExtended' => $this->html($row['ski_area_extended']),
|
||||||
|
'skiPass' => $this->html($row['ski_pass']),
|
||||||
|
'news' => $this->html($row['news']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
case 'country':
|
||||||
|
return $item + [
|
||||||
|
'code' => $this->text($row['code']),
|
||||||
|
'season' => $this->text($row['season']),
|
||||||
|
'keywords' => $this->text($row['keywords']),
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'description' => $this->html($row['description']),
|
||||||
|
'descriptionExtended' => $this->html($row['description_extended']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
case 'city':
|
||||||
|
return $item + [
|
||||||
|
'season' => $this->text($row['season']),
|
||||||
|
'keywords' => $this->text($row['keywords']),
|
||||||
|
'countryUid' => (int) $row['country'] ?: null,
|
||||||
|
'regionUid' => (int) $row['region'] ?: null,
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'description' => $this->html($row['description']),
|
||||||
|
'descriptionExtended' => $this->html($row['description_extended']),
|
||||||
|
'party' => $this->html($row['party']),
|
||||||
|
'leisure' => $this->html($row['leisure']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
default: // concept
|
||||||
|
return $item + [
|
||||||
|
'code' => $this->text($row['code']),
|
||||||
|
'text' => [
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'description' => $this->html($row['description']),
|
||||||
|
'descriptionExtended' => $this->html($row['description_extended']),
|
||||||
|
'popupText' => $this->html($row['popup_text']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeNews(array $rows): array
|
||||||
|
{
|
||||||
|
$uids = array_keys($rows);
|
||||||
|
$tags = $this->findNewsTags($uids);
|
||||||
|
$categories = $this->findNewsCategories($uids);
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($rows as $uid => $row) {
|
||||||
|
$link = $this->urlResolver->getNewsLink($uid, (int) $row['pid']);
|
||||||
|
|
||||||
|
if (null === $link) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'uid' => $uid,
|
||||||
|
'url' => $link['url'],
|
||||||
|
'site' => $link['site'],
|
||||||
|
'modified' => date('c', (int) $row['tstamp']),
|
||||||
|
'title' => $this->text($row['title']),
|
||||||
|
'teaser' => $this->html($row['teaser']),
|
||||||
|
'bodytext' => $this->html($row['bodytext']),
|
||||||
|
'datetime' => (int) $row['datetime'] > 0 ? date('c', (int) $row['datetime']) : null,
|
||||||
|
'tags' => $tags[$uid] ?? [],
|
||||||
|
'categories' => $categories[$uid] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity and link fields every record carries, with the same values as the manifest.
|
||||||
|
*/
|
||||||
|
private function withLink(array $row, int $modified): array
|
||||||
|
{
|
||||||
|
$link = $this->urlResolver->getRecordLink((string) $row['detail_page']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => (int) $row['uid'],
|
||||||
|
'url' => $link['url'],
|
||||||
|
'site' => $link['site'],
|
||||||
|
'modified' => date('c', $modified),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $uids
|
||||||
|
* @return array<int, int[]> related uids by $keyField, in $sortField order
|
||||||
|
*/
|
||||||
|
private function findMmUids(string $table, string $keyField, string $valueField, array $uids, string $sortField = 'sorting'): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select($keyField, $valueField)
|
||||||
|
->from($table)
|
||||||
|
->where($queryBuilder->expr()->in($keyField, $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY)))
|
||||||
|
->orderBy($keyField)
|
||||||
|
->addOrderBy($sortField)
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$related = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$related[(int) $row[$keyField]][] = (int) $row[$valueField];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $related;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $productUids
|
||||||
|
* @return array<int, array[]> FAQs by product uid, in the order the editor arranged them
|
||||||
|
*/
|
||||||
|
private function findFaqs(array $productUids): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('mm.uid_local AS product', 'faq.question', 'faq.answer', 'faq.category')
|
||||||
|
->from('tx_epproducts_domain_model_faq', 'faq')
|
||||||
|
->join('faq', 'tx_epproducts_product_faq_mm', 'mm', 'mm.uid_foreign = faq.uid')
|
||||||
|
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($productUids, Connection::PARAM_INT_ARRAY)))
|
||||||
|
->orderBy('mm.uid_local')
|
||||||
|
->addOrderBy('mm.sorting')
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$faqs = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$faqs[(int) $row['product']][] = [
|
||||||
|
'question' => $this->text($row['question']),
|
||||||
|
'answer' => $this->html($row['answer']),
|
||||||
|
'category' => $this->text($row['category']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $faqs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matchcode aliases by hotel uid. With the hotel's own code they are every code BusPro may use
|
||||||
|
* for it - the same set DateImportService::getHotelMappings() resolves.
|
||||||
|
*
|
||||||
|
* @param int[] $hotelUids
|
||||||
|
* @return array<int, string[]>
|
||||||
|
*/
|
||||||
|
private function findMatchcodes(array $hotelUids): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('entity', 'code')
|
||||||
|
->from('tx_epproducts_domain_model_matchcode')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel')),
|
||||||
|
$queryBuilder->expr()->in('entity', $queryBuilder->createNamedParameter($hotelUids, Connection::PARAM_INT_ARRAY))
|
||||||
|
)
|
||||||
|
->orderBy('code')
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$codes = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$codes[(int) $row['entity']][] = trim((string) $row['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $infoboxUids
|
||||||
|
* @return array<int, string|null>
|
||||||
|
*/
|
||||||
|
private function findInfoboxTexts(array $infoboxUids): array
|
||||||
|
{
|
||||||
|
$infoboxUids = array_values(array_filter(array_map('intval', $infoboxUids)));
|
||||||
|
|
||||||
|
if ([] === $infoboxUids) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_infobox');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('uid', 'text')
|
||||||
|
->from('tx_epproducts_domain_model_infobox')
|
||||||
|
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter(array_unique($infoboxUids), Connection::PARAM_INT_ARRAY)))
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$texts = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$texts[(int) $row['uid']] = $this->html($row['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findWebcamUrl(int $webcamUid): ?string
|
||||||
|
{
|
||||||
|
if (0 === $webcamUid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_webcam');
|
||||||
|
$url = $queryBuilder
|
||||||
|
->select('url')
|
||||||
|
->from('tx_epproducts_domain_model_webcam')
|
||||||
|
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($webcamUid, \PDO::PARAM_INT)))
|
||||||
|
->execute()
|
||||||
|
->fetchOne()
|
||||||
|
;
|
||||||
|
|
||||||
|
return false === $url ? null : $this->text($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $newsUids
|
||||||
|
* @return array<int, string[]>
|
||||||
|
*/
|
||||||
|
private function findNewsTags(array $newsUids): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_tag');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('mm.uid_local AS news', 'tag.title')
|
||||||
|
->from('tx_news_domain_model_tag', 'tag')
|
||||||
|
->join('tag', 'tx_news_domain_model_news_tag_mm', 'mm', 'mm.uid_foreign = tag.uid')
|
||||||
|
->where($queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY)))
|
||||||
|
->orderBy('mm.sorting')
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$tags[(int) $row['news']][] = trim((string) $row['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $newsUids
|
||||||
|
* @return array<int, string[]>
|
||||||
|
*/
|
||||||
|
private function findNewsCategories(array $newsUids): array
|
||||||
|
{
|
||||||
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('mm.uid_foreign AS news', 'category.title')
|
||||||
|
->from('sys_category', 'category')
|
||||||
|
->join('category', 'sys_category_record_mm', 'mm', 'mm.uid_local = category.uid')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news')),
|
||||||
|
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories')),
|
||||||
|
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, Connection::PARAM_INT_ARRAY))
|
||||||
|
)
|
||||||
|
->orderBy('mm.sorting_foreign')
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssociative()
|
||||||
|
;
|
||||||
|
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$categories[(int) $row['news']][] = trim((string) $row['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich text as stored, with internal links made absolute.
|
||||||
|
*/
|
||||||
|
private function html($value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return '' === $value ? null : $this->rteLinkResolver->resolve($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text with whitespace collapsed. Product names carry literal line breaks the website
|
||||||
|
* splits on for layout ("Apartments \nVal Thorens"); the chatbot needs them as one line.
|
||||||
|
*/
|
||||||
|
private function text($value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) preg_replace('/\s+/u', ' ', (string) $value));
|
||||||
|
|
||||||
|
return '' === $value ? null : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace EP\EpProducts\ContentFeed;
|
||||||
|
|
||||||
|
use TYPO3\CMS\Core\LinkHandling\LinkService;
|
||||||
|
use TYPO3\CMS\Core\Resource\File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites `t3://` links in stored RTE HTML to absolute URLs.
|
||||||
|
*
|
||||||
|
* parseFunc_RTE would do this, but it needs a TSFE and the API middleware runs before one exists.
|
||||||
|
* Only links are touched; the rest of the HTML is passed on as stored. A link that cannot be
|
||||||
|
* resolved to something public (a page outside the feed's sites, a deleted page) loses its href
|
||||||
|
* and keeps its text.
|
||||||
|
*/
|
||||||
|
class RteLinkResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var LinkService
|
||||||
|
*/
|
||||||
|
private $linkService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var UrlResolver
|
||||||
|
*/
|
||||||
|
private $urlResolver;
|
||||||
|
|
||||||
|
public function __construct(LinkService $linkService, UrlResolver $urlResolver)
|
||||||
|
{
|
||||||
|
$this->linkService = $linkService;
|
||||||
|
$this->urlResolver = $urlResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(string $html): string
|
||||||
|
{
|
||||||
|
if (false === strpos($html, 't3://')) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) preg_replace_callback(
|
||||||
|
'/\shref=(["\'])(t3:\/\/[^"\']*)\1/i',
|
||||||
|
function (array $matches): string {
|
||||||
|
$url = $this->resolveLink(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5));
|
||||||
|
|
||||||
|
return null === $url ? '' : ' href="' . htmlspecialchars($url) . '"';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLink(string $link): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $this->linkService->resolve($link);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LinkService::TYPE_PAGE === ($data['type'] ?? null)) {
|
||||||
|
$pageUid = $this->urlResolver->parsePageReference('t3://page?uid=' . ($data['pageuid'] ?? ''));
|
||||||
|
$target = null === $pageUid ? null : $this->urlResolver->resolveLinkTarget($pageUid);
|
||||||
|
$url = null === $target ? null : $this->urlResolver->getPageUrl($target);
|
||||||
|
|
||||||
|
if (null !== $url && '' !== (string) ($data['fragment'] ?? '')) {
|
||||||
|
$url .= '#' . $data['fragment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LinkService::TYPE_FILE === ($data['type'] ?? null) && ($data['file'] ?? null) instanceof File) {
|
||||||
|
return $this->absolute((string) $data['file']->getPublicUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LinkService::TYPE_URL === ($data['type'] ?? null)) {
|
||||||
|
return (string) $data['url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File URLs come back relative to the document root; the consumer has no page to resolve
|
||||||
|
* them against for record text, so make them absolute against the main site.
|
||||||
|
*/
|
||||||
|
private function absolute(string $url): ?string
|
||||||
|
{
|
||||||
|
if ('' === $url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === preg_match('/^https?:\/\//', $url)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sites = $this->urlResolver->getIncludedSites();
|
||||||
|
|
||||||
|
if ([] === $sites) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim((string) $sites[0]->getBase(), '/') . '/' . ltrim($url, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user