diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php index e8753ba5..cef76c89 100644 --- a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php @@ -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); diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordSerializer.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordSerializer.php new file mode 100644 index 00000000..2ed22cc0 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordSerializer.php @@ -0,0 +1,513 @@ +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 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 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/RteLinkResolver.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RteLinkResolver.php new file mode 100644 index 00000000..83dafd84 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RteLinkResolver.php @@ -0,0 +1,104 @@ +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, '/'); + } +}