$typeLabels Solr document type => frontend type (shared with the result list) * @return array */ public function cardsFor(string $query, SolrConnection $connection, ServerRequestInterface $request, array $typeLabels): array { $normalizedQuery = $this->normalize($query); if ($normalizedQuery === '') { return []; } $synonyms = $this->synonymIndex($connection); $cards = []; $seenUrls = []; foreach ($this->activeCards() as $row) { $keyword = $this->matchingKeyword($normalizedQuery, (string)$row['keywords'], $synonyms); if ($keyword === null) { continue; } try { $card = $this->buildCard($row, $connection, $request, $typeLabels); } catch (\Throwable) { $card = null; } if ($card === null || isset($seenUrls[$card['url']])) { continue; } $seenUrls[$card['url']] = true; $card['keyword'] = $keyword; $cards[] = $card; if (count($cards) >= self::MAX_CARDS) { break; } } return $cards; } // ---- matching ------------------------------------------------------------- /** * @param array{forward:array,reverse:array} $synonyms * @return string|null the card keyword that fired (as maintained), null = no match */ private function matchingKeyword(string $normalizedQuery, string $keywordField, array $synonyms): ?string { $haystack = ' ' . $normalizedQuery . ' '; foreach (preg_split('/[,\n\r]+/', $keywordField) ?: [] as $rawKeyword) { $keyword = $this->normalize($rawKeyword); if ($keyword === '') { continue; } $candidates = array_merge( [$keyword], $synonyms['forward'][$keyword] ?? [], $synonyms['reverse'][$keyword] ?? [] ); foreach ($candidates as $candidate) { if ($candidate !== '' && str_contains($haystack, ' ' . $candidate . ' ')) { return trim($rawKeyword); } } } return null; } /** Lowercase, every run of non-letters/digits becomes one space: "H.264" -> "h 264". */ private function normalize(string $value): string { return trim((string)preg_replace('/[^\p{L}\p{N}]+/u', ' ', mb_strtolower($value))); } /** * The Solr managed synonym map of the core, normalized like the query, * as forward (term => synonyms) and reverse (synonym => terms) index. * Cached for an hour; on any failure an empty index is cached for five * minutes, so an unreachable Solr never slows down every search. * * @return array{forward:array,reverse:array} */ private function synonymIndex(SolrConnection $connection): array { $cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('hash'); $cached = $cache->get(self::SYNONYM_CACHE_KEY); if (is_array($cached)) { return $cached; } $index = ['forward' => [], 'reverse' => []]; $lifetime = 300; try { $endpoint = $connection->getEndpoint('read'); $core = (string)$endpoint->getCore(); $url = rtrim($endpoint->getCoreBaseUri(), '/') . '/schema/analysis/synonyms/' . rawurlencode($core); $options = ['timeout' => 3]; $auth = $endpoint->getAuthentication(); if (!empty($auth['username'])) { $options['auth'] = [(string)$auth['username'], (string)($auth['password'] ?? '')]; } $response = GeneralUtility::makeInstance(RequestFactory::class)->request($url, 'GET', $options); $data = json_decode((string)$response->getBody(), true); $map = $data['synonymMappings']['managedMap'] ?? null; if (is_array($map)) { foreach ($map as $term => $targets) { $term = $this->normalize((string)$term); if ($term === '') { continue; } foreach ((array)$targets as $target) { $target = $this->normalize((string)$target); if ($target === '' || $target === $term) { continue; } $index['forward'][$term][] = $target; $index['reverse'][$target][] = $term; } } $lifetime = self::SYNONYM_CACHE_LIFETIME; } } catch (\Throwable) { // synonyms are a bonus - plain keyword matching still works } $cache->set(self::SYNONYM_CACHE_KEY, $index, [], $lifetime); return $index; } // ---- data ------------------------------------------------------------------- /** @return array> visible cards, best first (frontend restrictions apply) */ private function activeCards(): array { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE); return $queryBuilder->select('*') ->from(self::TABLE) ->orderBy('priority', 'DESC') ->addOrderBy('uid', 'ASC') ->executeQuery() ->fetchAllAssociative(); } /** * @param array $row * @param array $typeLabels * @return array{title:string,teaser:string,image:?string,url:string,linkLabel:string,type:string}|null */ private function buildCard(array $row, SolrConnection $connection, ServerRequestInterface $request, array $typeLabels): ?array { $title = ''; $teaser = ''; $image = null; $url = ''; $type = 'custom'; // Record card: the indexed document is the source of truth. $reference = (string)$row['record']; if ($reference !== '' && preg_match('/^(.+)_(\d+)$/', $reference, $m)) { $table = $m[1]; $document = $this->indexedDocument($connection, $table, (int)$m[2]); $type = $typeLabels[$table] ?? $table; if ($document !== null) { $title = trim((string)($document['title'] ?? '')); $url = trim((string)($document['url'] ?? '')); $teaser = $this->excerpt((string)($document['content'] ?? '')); $docImage = trim((string)($document['image'] ?? '')); if ($docImage !== '') { $image = $this->rootRelative($docImage); } } } // Card fields override (and carry free cards on their own). if (trim((string)$row['card_title']) !== '') { $title = trim((string)$row['card_title']); } if (trim((string)$row['teaser']) !== '') { $teaser = trim((string)$row['teaser']); } $cardImage = $this->cardImage((int)$row['uid']); if ($cardImage !== null) { $image = $cardImage; } if (trim((string)$row['link']) !== '') { $resolved = $this->resolveLink((string)$row['link'], $request); if ($resolved !== '') { $url = $resolved; } } if ($title === '' || $url === '') { return null; } return [ 'title' => $title, 'teaser' => $teaser, 'image' => $image, 'url' => $url, 'linkLabel' => trim((string)$row['link_label']), 'type' => $type, ]; } /** @return array|null fields of the record's Solr document */ private function indexedDocument(SolrConnection $connection, string $table, int $uid): ?array { if ($uid <= 0 || !preg_match('/^[a-z0-9_]+$/', $table)) { return null; } $query = new Query(); $query->setQuery(sprintf('type:%s AND uid:%d', $table, $uid)); $query->setRows(1); $query->setFields(['title', 'url', 'image', 'content', 'type']); $response = $connection->getReadService()->search($query); $documents = $response->response->docs ?? []; if (!is_array($documents) || $documents === []) { return null; } $document = $documents[0]; return method_exists($document, 'getFields') ? (array)$document->getFields() : (array)$document; } /** First reference of the card's own image, processed to a 640px tile. */ private function cardImage(int $cardUid): ?string { $references = GeneralUtility::makeInstance(FileRepository::class)->findByRelation(self::TABLE, 'image', $cardUid); if ($references === []) { return null; } $imageService = GeneralUtility::makeInstance(ImageService::class); $processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640']); return $this->rootRelative($imageService->getImageUri($processed)); } private function resolveLink(string $parameter, ServerRequestInterface $request): string { $contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class); $contentObject->setRequest($request); return (string)$contentObject->typoLink_URL(['parameter' => $parameter]); } private function excerpt(string $content): string { $content = trim((string)preg_replace('/\s+/u', ' ', strip_tags($content))); if (mb_strlen($content) <= self::TEASER_LENGTH) { return $content; } $cut = mb_substr($content, 0, self::TEASER_LENGTH); $space = mb_strrpos($cut, ' '); return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …'; } /** fileadmin/... (IMG_RESOURCE, ImageService) -> /fileadmin/..., absolute URLs untouched. */ private function rootRelative(string $path): string { if ($path === '' || str_starts_with($path, '/') || preg_match('#^https?://#', $path)) { return $path; } return '/' . $path; } }