Files
orasche 91ad5e6596 Search cards above the results, detail-page fallback for market/solution documents
- 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)
2026-10-09 12:59:12 +02:00

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;
}
}