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\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
private const MANIFEST_CACHE_LIFETIME = 600;
|
||||
|
||||
private const MAX_RECORDS_PER_REQUEST = 100;
|
||||
|
||||
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,11 @@ class ContentFeedApi
|
||||
*/
|
||||
private $manifestBuilder;
|
||||
|
||||
/**
|
||||
* @var RecordSerializer
|
||||
*/
|
||||
private $recordSerializer;
|
||||
|
||||
/**
|
||||
* @var FrontendInterface
|
||||
*/
|
||||
@@ -49,11 +57,13 @@ class ContentFeedApi
|
||||
ApiKeyAuthenticator $authenticator,
|
||||
ContentFeedConfiguration $configuration,
|
||||
ManifestBuilder $manifestBuilder,
|
||||
RecordSerializer $recordSerializer,
|
||||
CacheManager $cacheManager
|
||||
) {
|
||||
$this->authenticator = $authenticator;
|
||||
$this->configuration = $configuration;
|
||||
$this->manifestBuilder = $manifestBuilder;
|
||||
$this->recordSerializer = $recordSerializer;
|
||||
$this->cache = $cacheManager->getCache('ep_products_cache');
|
||||
}
|
||||
|
||||
@@ -74,9 +84,47 @@ class ContentFeedApi
|
||||
return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS);
|
||||
}
|
||||
|
||||
if ('records' === $path) {
|
||||
return $this->handleRecordsRequest($request);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$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