diff --git a/config/sites/vitec/config.yaml b/config/sites/vitec/config.yaml index 9b493c7..a0d7a7f 100644 --- a/config/sites/vitec/config.yaml +++ b/config/sites/vitec/config.yaml @@ -102,7 +102,7 @@ routeEnhancers: solr_enabled_read: true solr_host_read: vitecsolr.evomedien.de solr_host_write: localhost -solr_password_read: DvgpSaqrxr9QCd4ES36V +solr_password_read: '%env(SOLR_PASSWORD)%' solr_path_read: / solr_path_write: / solr_port_read: '443' @@ -110,5 +110,5 @@ solr_port_write: '8983' solr_scheme_read: https solr_scheme_write: '' solr_use_write_connection: false -solr_username_read: solr +solr_username_read: '%env(SOLR_USERNAME)%' websiteTitle: '' diff --git a/daten.md b/daten.md index ac6a5a9..9b36777 100644 --- a/daten.md +++ b/daten.md @@ -1,3 +1,3 @@ solr -DvgpSaqrxr9QCd4ES36V +9*p3^fI^EBuzf8oY diff --git a/migrations/check_solution_fields.php b/migrations/check_solution_fields.php new file mode 100644 index 0000000..7e39ea9 --- /dev/null +++ b/migrations/check_solution_fields.php @@ -0,0 +1,29 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$in = implode(',', $uids); +$rows = $pdo->query( + "SELECT uid, pid, deleted, hidden, sys_language_uid, l10n_parent, t3ver_oid, t3ver_wsid, t3ver_state, + FROM_UNIXTIME(tstamp) AS changed, title, teaser, LEFT(description, 90) AS description + FROM tx_vitec_domain_model_solution + WHERE uid IN ($in) OR t3ver_oid IN ($in) OR l10n_parent IN ($in) + ORDER BY uid" +)->fetchAll(PDO::FETCH_ASSOC); + +foreach ($rows as $r) { + $kind = $r['t3ver_oid'] > 0 + ? 'WORKSPACE-VERSION (ws ' . $r['t3ver_wsid'] . ') von uid ' . $r['t3ver_oid'] + : ($r['l10n_parent'] > 0 ? 'Uebersetzung von uid ' . $r['l10n_parent'] : 'LIVE-Datensatz'); + echo "--- uid {$r['uid']} | $kind | deleted={$r['deleted']} hidden={$r['hidden']} | geaendert {$r['changed']}\n"; + echo " title: {$r['title']}\n"; + echo " teaser: " . ($r['teaser'] === '' ? '(leer)' : $r['teaser']) . "\n"; + echo " description: " . ($r['description'] === '' || $r['description'] === null ? '(leer)' : $r['description']) . "\n"; +} diff --git a/packages/vitec/Classes/Service/SearchCardService.php b/packages/vitec/Classes/Service/SearchCardService.php new file mode 100644 index 0000000..1b1d25d --- /dev/null +++ b/packages/vitec/Classes/Service/SearchCardService.php @@ -0,0 +1,299 @@ + $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; + } +} diff --git a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php index 0c57155..219829c 100644 --- a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php @@ -31,6 +31,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; * "filter": the active type filter or null, * "facets": { "type": [ { "value", "count", "active" } ] } - * counts stay complete while a filter is active, + * "cards": editorial cards for the query (page 1 only, max 3, + * SearchCardService) - [] when none match, * "suggestions": spellcheck alternatives ("did you mean"), [] if none * } * @@ -271,6 +273,19 @@ class SearchJsonRenderer // faceting disabled or unavailable - facets stay empty } + // Editorial cards above the results - page 1 only (load-more pages + // append results, the cards are already on screen). Independent of + // the result count and the active filter; fail-soft on its own. + $cards = []; + if ($page === 1) { + try { + $cards = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SearchCardService::class) + ->cardsFor($query, $connection, $request, self::TYPE_LABELS); + } catch (\Throwable) { + $cards = []; + } + } + $numFound = $resultSet->getAllResultCount(); $resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results); @@ -283,6 +298,7 @@ class SearchJsonRenderer 'filter' => $activeType !== '' ? $activeType : null, 'activeFacets' => (object)$facetFilters, 'facets' => (object)$facets, + 'cards' => $cards, 'results' => $results, 'suggestions' => array_values(array_unique($suggestions)), ]); @@ -303,8 +319,9 @@ class SearchJsonRenderer 'numFound' => 0, 'totalPages' => 0, 'filter' => null, - 'activeFacets' => new stdClass(), + 'activeFacets' => new \stdClass(), 'facets' => new \stdClass(), + 'cards' => [], 'results' => [], 'suggestions' => [], ]; diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 5370695..13303ec 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -476,6 +476,91 @@ plugin.tx_solr.statistics.anonymizeIP = 2 # downloads/events/locations (no public detail pages), Datapath news (pid 442, # import awaiting an editorial decision). # ============================================================================= + +# Detail-page fallbacks for markets and solutions (2026-10-09). The records +# themselves are mostly empty (no teaser/description/image) - the real copy +# lives in the market/solution Content Blocks on the record's detail_page, +# filled by the workbook importers. Copied into both queues below, so search +# results AND search cards (which mirror the indexed document) get a teaser +# and a tile image without double maintenance. Selected by CType, so it works +# for layout 21 (colPos 110/120) and layout 5 (colPos 1/0) alike. +# +# Image chain: record image -> detail page intro image -> hero background. +lib.vitecIndexDetailImage = CONTENT +lib.vitecIndexDetailImage { + table = tt_content + select { + pidInList.field = detail_page + where = {#CType} = 'vitec_marketintro' + orderBy = sorting + max = 1 + } + renderObj = FILES + renderObj { + references { + table = tt_content + uid.data = field:uid + fieldName = vitec_image + } + maxItems = 1 + renderObj = IMG_RESOURCE + renderObj { + file.import.data = file:current:uid + file.treatIdAsReference = 1 + file.width = 640 + } + } + stdWrap.ifEmpty.cObject = CONTENT + stdWrap.ifEmpty.cObject { + table = tt_content + select { + pidInList.field = detail_page + where = {#CType} = 'vitec_markethero' + orderBy = sorting + max = 1 + } + renderObj = FILES + renderObj { + references { + table = tt_content + uid.data = field:uid + fieldName = vitec_bgimage + } + maxItems = 1 + renderObj = IMG_RESOURCE + renderObj { + file.import.data = file:current:uid + file.treatIdAsReference = 1 + file.width = 640 + } + } + } +} + +# Searchable text of the detail page: hero subline, intro headline, intro +# text - in page order (hero first), appended to the record's own content. +lib.vitecIndexDetailText = CONTENT +lib.vitecIndexDetailText { + table = tt_content + select { + pidInList.field = detail_page + where = {#CType} IN ('vitec_markethero', 'vitec_marketintro') + orderBy = CType ASC + } + renderObj = COA + renderObj { + 10 = TEXT + 10.field = vitec_subline + 10.noTrimWrap = || | + 20 = TEXT + 20.field = vitec_intro_headline + 20.noTrimWrap = || | + 30 = TEXT + 30.field = vitec_intro_text + 30.noTrimWrap = || | + } +} + plugin.tx_solr.index.queue { products = 1 @@ -577,6 +662,7 @@ plugin.tx_solr.index.queue { file.treatIdAsReference = 1 file.width = 640 } + stdWrap.ifEmpty.cObject < lib.vitecIndexDetailImage } type = tx_vitec_domain_model_market # only markets with an editorially assigned detail page are linkable; @@ -597,6 +683,7 @@ plugin.tx_solr.index.queue { 30 = TEXT 30.field = description 30.noTrimWrap = || | + 40 < lib.vitecIndexDetailText } } url = TEXT @@ -710,6 +797,7 @@ plugin.tx_solr.index.queue { file.treatIdAsReference = 1 file.width = 640 } + stdWrap.ifEmpty.cObject < lib.vitecIndexDetailImage } type = tx_vitec_domain_model_solution additionalWhereClause = detail_page > 0 @@ -728,6 +816,7 @@ plugin.tx_solr.index.queue { 30 = TEXT 30.field = description 30.noTrimWrap = || | + 40 < lib.vitecIndexDetailText } } url = TEXT diff --git a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_searchcard.php b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_searchcard.php new file mode 100644 index 0000000..9e077ca --- /dev/null +++ b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_searchcard.php @@ -0,0 +1,135 @@ + [ + 'title' => 'VITEC Search Card', + 'label' => 'title', + 'label_alt' => 'keywords', + 'label_alt_force' => true, + 'tstamp' => 'tstamp', + 'crdate' => 'crdate', + 'delete' => 'deleted', + 'default_sortby' => 'priority DESC, title ASC', + 'enablecolumns' => [ + 'disabled' => 'hidden', + 'starttime' => 'starttime', + 'endtime' => 'endtime', + ], + 'searchFields' => 'title,keywords,card_title,teaser', + 'iconfile' => 'EXT:vitec/Resources/Public/Icons/vitec-search-card.svg', + 'security' => [ + 'ignorePageTypeRestriction' => true, + ], + ], + 'types' => [ + '1' => [ + 'showitem' => ' + --div--;Trigger, title, keywords, priority, + --div--;Card content, record, card_title, teaser, image, --palette--;;link, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime', + ], + ], + 'palettes' => [ + 'link' => [ + 'label' => 'Button', + 'showitem' => 'link, link_label', + ], + ], + 'columns' => [ + 'title' => [ + 'label' => 'Internal name', + 'description' => 'Only for the backend list, never shown on the website.', + 'config' => [ + 'type' => 'input', + 'size' => 40, + 'max' => 255, + 'required' => true, + ], + ], + 'keywords' => [ + 'label' => 'Keywords', + 'description' => 'Comma or line separated. The card appears when the search contains one of these terms (whole words, case-insensitive; Solr synonyms count too). Example: control room, soc, noc, command centre', + 'config' => [ + 'type' => 'text', + 'rows' => 3, + 'cols' => 60, + 'required' => true, + ], + ], + 'priority' => [ + 'label' => 'Priority', + 'description' => 'At most 3 cards are shown; higher numbers win.', + 'config' => [ + 'type' => 'number', + 'default' => 0, + 'range' => ['lower' => -100, 'upper' => 100], + ], + ], + 'record' => [ + 'label' => 'Show this record', + 'description' => 'Title, teaser, image and link come from the record (as in the search results). Leave empty for a free card. A record that is hidden or not in the search index is skipped unless the fields below fill the gap.', + 'config' => [ + 'type' => 'group', + 'allowed' => 'pages,tx_vitec_domain_model_product,tx_vitec_domain_model_usecase,tx_vitec_domain_model_solution,tx_vitec_domain_model_market,tx_news_domain_model_news', + 'size' => 1, + 'maxitems' => 1, + 'minitems' => 0, + ], + ], + 'card_title' => [ + 'label' => 'Card title', + 'description' => 'Required for free cards; overrides the record title otherwise.', + 'config' => [ + 'type' => 'input', + 'size' => 40, + 'max' => 255, + ], + ], + 'teaser' => [ + 'label' => 'Teaser', + 'description' => 'Plain text, about 160 characters. Overrides the record teaser.', + 'config' => [ + 'type' => 'text', + 'rows' => 3, + 'cols' => 60, + 'max' => 400, + ], + ], + 'image' => [ + 'label' => 'Image', + 'description' => 'Overrides the record image. Delivered as a 640px tile like the result images.', + 'config' => [ + 'type' => 'file', + 'maxitems' => 1, + 'allowed' => 'common-image-types', + ], + ], + 'link' => [ + 'label' => 'Link', + 'description' => 'Required for free cards; overrides the record URL otherwise.', + 'config' => [ + 'type' => 'link', + 'allowedTypes' => ['page', 'url', 'record', 'file'], + ], + ], + 'link_label' => [ + 'label' => 'Button label', + 'description' => 'Empty = the frontend default (e.g. "Learn more").', + 'config' => [ + 'type' => 'input', + 'size' => 20, + 'max' => 80, + ], + ], + ], +]; diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index 777940d..eb8d511 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -34,6 +34,7 @@ | 1.19 | 2026‑09‑25 | **Interface change (additive).** The `vitec_card` Content Block gained the `layout_variant` value `flipcard` and a *Card Back* palette: `back_headline` (string, max 100) and `back_text` (richtext → HTML). The two fields are `displayCond`‑gated on the flipcard layout in the backend but serialised unconditionally (7.7) — the front end **shall** key the back face on `layout_variant`, never on field presence, because stored back values persist when an editor switches the layout away. Backend repair: `vitec_layout_variant` / `vitec_background_variant` are shared with `vitec_ctabanner`, and the index‑wise Content‑Blocks merge had dropped four card‑only select values (“Missing label”) — per‑type item lists now come from `columnsOverrides` (7.7); the Card‑Back palette lives in the Layout tab below the variant select. Editorial: the head‑matter version had been stuck at v1.17 since 1.18. | | 1.20 | 2026‑10‑01 | **Interface change (additive).** Sub-market page template: frontend layout 21 with one colPos group per design section (`colPos110`..`colPos160`, clause 7.17); new Content Blocks `vitec_markethero`, `vitec_marketintro`, `vitec_marketinsights`; new plugin `vitec_productfeature` (key `productfeature`, one product in the 7.15.2 item shape); story cards gained `quote`/`quotePerson`/`quoteRole`; `vitec_usecaselist` gained the layout value `testimonials`; `vitec_productlist` gained hand-picked product selection (editor order, category filter ignored). Field semantics: Template_Sub-Market_Spezifikation_2026-10-01.md. | | 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. | +| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). | This document is drafted in the style of, and adopts the terminology conventions of, ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for @@ -1074,6 +1075,12 @@ identifier). { "value": "product", "count": 13, "active": false } ] }, + "cards": [ + { "title": "Control Room Platforms", "teaser": "Unified, low-latency video …", + "image": "/fileadmin/_processed_/…/csm_gov_16-9_….jpg", + "url": "/solutions/control-room-command-centre-solutions/…", + "linkLabel": "", "type": "solution", "keyword": "control room" } + ], "results": [ { "title": "MGW Diamond-H", "url": "/product/mgw-diamond-h-hdmi-encoder", "type": "product", "teaser": "… 4K HDMI Encoder …", @@ -1107,6 +1114,31 @@ identifier). `facets.category` (product sys_category titles) appear whenever matching documents carry the fields; further facets are pure TypoScript. - `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none. +- `cards` lists editorial search cards for the query (below), `[]` if none. + +**Search cards** ("best bets", since v1.22). Editors maintain records of +`tx_vitec_domain_model_searchcard` (any storage folder; hidden/start/end time +apply). A card **shall** appear above the results when the query contains one +of its keywords as a whole word or phrase - case- and punctuation-insensitive, +including one level of the Solr managed synonyms in both directions. At most +three cards, highest `priority` first, deduplicated by `url`; delivered on +page 1 only (load-more pages carry `[]`) and independent of `filter`, the +secondary facets and `numFound` - a card may stand above zero results. + +| Field | Meaning | +|---|---| +| `title` | Card title. | +| `teaser` | Plain text (no markup, unlike result teasers), may be empty. | +| `image` | 640 px tile, root-relative, or `null`. | +| `url` | Root-relative link target (or absolute for external links). | +| `linkLabel` | Button text, `""` = front-end default (e.g. "Learn more"). | +| `type` | Result type of the referenced record (`product`, `story`, …), `custom` for a free card. | +| `keyword` | The maintained keyword that fired (diagnostics, tracking). | + +A card referencing a record takes title, teaser, image and URL from that +record’s **indexed Solr document**, so it always matches the record’s own +search result; filled card fields override. A card without a resolvable +title or URL (e.g. the record is hidden or not indexed) is skipped silently. **Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a resolvable public URL - products, markets (only those with `detail_page`), @@ -1557,4 +1589,4 @@ remediation. - Header convention — the uniform header section across CEs, plugins and containers. - `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point. -*End of document EVO‑VITEC‑HL‑001 v1.21.* +*End of document EVO‑VITEC‑HL‑001 v1.22.* diff --git a/packages/vitec/Resources/Public/Icons/vitec-search-card.svg b/packages/vitec/Resources/Public/Icons/vitec-search-card.svg new file mode 100644 index 0000000..12badcc --- /dev/null +++ b/packages/vitec/Resources/Public/Icons/vitec-search-card.svg @@ -0,0 +1 @@ + diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index 0beff96..a4f4adc 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -424,3 +424,20 @@ CREATE TABLE sys_category ( tx_vitec_success_stories varchar(1024) DEFAULT '' NOT NULL, tx_vitec_success_stories_layout varchar(30) DEFAULT 'grid' NOT NULL ); + +# +# Search cards ("best bets", 2026-10-09): editorial cards shown above the +# search results when the query contains one of the card's keywords. +# System columns (uid, pid, hidden, ...) come from the TCA. +# +CREATE TABLE tx_vitec_domain_model_searchcard ( + title varchar(255) DEFAULT '' NOT NULL, + keywords text, + priority int(11) DEFAULT '0' NOT NULL, + record varchar(255) DEFAULT '' NOT NULL, + card_title varchar(255) DEFAULT '' NOT NULL, + teaser text, + image int(11) unsigned DEFAULT '0' NOT NULL, + link varchar(1024) DEFAULT '' NOT NULL, + link_label varchar(80) DEFAULT '' NOT NULL +); diff --git a/public/_frontend/index.html b/public/_frontend/index.html index 2bd5bd0..3987e41 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -23,8 +23,8 @@ VITEC - - + +