- New record type tx_vitec_domain_model_searchcard ("best bets"):
keyword list, priority, optional referenced record (page, product,
story, solution, market, news) and overridable title/teaser/image/link;
hidden/start/end time allow timed campaigns
- SearchCardService delivers content.search.cards on page 1: max. 3,
priority-ordered, deduplicated by URL, independent of filter, facets
and result count. Keywords match as whole words/phrases, case- and
punctuation-insensitive, including one level of the Solr managed
synonyms in both directions (cached for an hour, fail-soft). Record
cards read the record's indexed Solr document, so a card always
matches the record's own search result
- Index fallback for markets and solutions: when the record carries no
image or text, the document takes the intro image (then the hero
background) and the hero subline, intro headline and intro text from
the content blocks of its detail page - selected by CType, so it works
for layout 21 and layout 5 alike; improves results and cards
- Fix: a search request without q returned an empty body instead of the
documented empty shape (stdClass resolved inside the renderer namespace)
- migrations/check_solution_fields.php: shows live/workspace versions of
solution records
- Spec v1.22 (clause 7.16: cards)
300 lines
12 KiB
PHP
300 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Service;
|
|
|
|
use ApacheSolrForTypo3\Solr\Domain\Search\Query\Query;
|
|
use ApacheSolrForTypo3\Solr\System\Solr\SolrConnection;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use TYPO3\CMS\Core\Cache\CacheManager;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
|
use TYPO3\CMS\Core\Resource\FileRepository;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* Search cards ("best bets", 2026-10-09): editorial cards for
|
|
* content.search.cards (spec clause 7.16).
|
|
*
|
|
* A card matches when the search query contains one of its keywords as a
|
|
* whole word or phrase - case-insensitive, punctuation-insensitive, and
|
|
* one level of the Solr managed synonyms in both directions (keyword
|
|
* "control room" also fires for a synonym of it). At most MAX_CARDS,
|
|
* highest priority first, deduplicated by URL.
|
|
*
|
|
* Record cards take title/teaser/image/url from the record's INDEXED Solr
|
|
* document, so a card always looks like the record's search result; filled
|
|
* card fields override. A card without a resolvable URL or title is
|
|
* skipped. Everything is fail-soft: a broken card or an unreachable synonym
|
|
* resource never costs the search its results.
|
|
*/
|
|
final class SearchCardService
|
|
{
|
|
private const TABLE = 'tx_vitec_domain_model_searchcard';
|
|
private const MAX_CARDS = 3;
|
|
private const TEASER_LENGTH = 200;
|
|
private const SYNONYM_CACHE_KEY = 'vitec_searchcard_synonyms';
|
|
private const SYNONYM_CACHE_LIFETIME = 3600;
|
|
|
|
/**
|
|
* @param array<string,string> $typeLabels Solr document type => frontend type (shared with the result list)
|
|
* @return array<int,array{title:string,teaser:string,image:?string,url:string,linkLabel:string,type:string,keyword:string}>
|
|
*/
|
|
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<string,string[]>,reverse:array<string,string[]>} $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<string,string[]>,reverse:array<string,string[]>}
|
|
*/
|
|
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<int,array<string,mixed>> 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<string,mixed> $row
|
|
* @param array<string,string> $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<string,mixed>|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;
|
|
}
|
|
}
|