Index PDF and Office file contents via Apache Tika
Solr 10 dropped the ExtractingRequestHandler, so a dedicated Tika
container on the Solr VPS (Caddy route /tika/*, own basic-auth
credential; Tika itself has no auth) extracts file text during
indexing: TikaDownloadContentIndexer listens on
BeforeDocumentIsProcessedForIndexingEvent, resolves the file through
the existing DownloadFileResolver and appends the text (mime
whitelist, 30 MB cap, 100k chars, fail-soft) to the document's content
field. Extractions are cached in var/tika-cache keyed on
path+size+mtime - a full re-index of 177 downloads drops from 2:12 min
to 27 s, replacing a file re-extracts naturally. Datasheet
specifications ("625i", "genlock") are now searchable. Spec v1.15.
This commit is contained in:
1 parent
5bb4e374b4
commit
8c881d153c
9 files changed
+499
-6
No files matched your search
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\EventListener;
|
||||||
|
|
||||||
|
use ApacheSolrForTypo3\Solr\Event\Indexing\BeforeDocumentIsProcessedForIndexingEvent;
|
||||||
|
use Evomedien\Vitec\Service\DownloadFileResolver;
|
||||||
|
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the extracted file text (PDF/Office) to the Solr `content` field of
|
||||||
|
* download documents. Solr 10 dropped the built-in ExtractingRequestHandler,
|
||||||
|
* so extraction goes through the Apache Tika server on the Solr VPS
|
||||||
|
* (Caddy route /tika/*, credentials via TIKA_* env in additional.php).
|
||||||
|
*
|
||||||
|
* Extracted text is cached in var/tika-cache keyed on path+size+mtime -
|
||||||
|
* a full re-index does not re-upload 177 unchanged PDFs, replacing a file
|
||||||
|
* invalidates its entry naturally. Fail-soft throughout (Clause 9.5): no
|
||||||
|
* Tika, no file, or an extraction error leave the document as it was -
|
||||||
|
* title/teaser/keywords still get it indexed.
|
||||||
|
*/
|
||||||
|
#[AsEventListener(identifier: 'vitec/tika-download-content')]
|
||||||
|
final class TikaDownloadContentIndexer
|
||||||
|
{
|
||||||
|
/** Files above this size are not sent to Tika (bytes). */
|
||||||
|
private const MAX_FILE_SIZE = 31457280;
|
||||||
|
|
||||||
|
/** Extracted text is truncated to this many characters before indexing. */
|
||||||
|
private const MAX_TEXT_LENGTH = 100000;
|
||||||
|
|
||||||
|
private const EXTRACTABLE_MIME_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __invoke(BeforeDocumentIsProcessedForIndexingEvent $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$item = $event->getIndexQueueItem();
|
||||||
|
if ($item->getType() !== 'tx_vitec_domain_model_download') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = DownloadFileResolver::resolve($item->getRecordUid());
|
||||||
|
if (
|
||||||
|
$file === null
|
||||||
|
|| $file['size'] <= 0
|
||||||
|
|| $file['size'] > self::MAX_FILE_SIZE
|
||||||
|
|| !in_array($file['mimeType'], self::EXTRACTABLE_MIME_TYPES, true)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $this->extract($file['path']);
|
||||||
|
if ($text === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = $event->getDocument();
|
||||||
|
$existing = trim((string)($document['content'] ?? ''));
|
||||||
|
$document->setField('content', trim($existing . ' ' . $text));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// fail soft - the download stays indexed with its metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extract(string $path): string
|
||||||
|
{
|
||||||
|
$cacheFile = $this->cacheFileFor($path);
|
||||||
|
if ($cacheFile !== '' && is_file($cacheFile)) {
|
||||||
|
return (string)file_get_contents($cacheFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = (string)getenv('TIKA_URL');
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create(['http' => [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'header' => [
|
||||||
|
'Authorization: Basic ' . base64_encode(getenv('TIKA_USERNAME') . ':' . getenv('TIKA_PASSWORD')),
|
||||||
|
'Accept: text/plain',
|
||||||
|
'Content-Type: application/octet-stream',
|
||||||
|
],
|
||||||
|
'content' => (string)file_get_contents($path),
|
||||||
|
'timeout' => 30,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
]]);
|
||||||
|
$raw = file_get_contents(rtrim($baseUrl, '/') . '/tika', false, $context);
|
||||||
|
$status = (string)($http_response_header[0] ?? '');
|
||||||
|
if ($raw === false || !str_contains($status, '200')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = mb_substr(trim((string)preg_replace('/\s+/u', ' ', $raw)), 0, self::MAX_TEXT_LENGTH);
|
||||||
|
|
||||||
|
if ($cacheFile !== '' && $text !== '') {
|
||||||
|
GeneralUtility::mkdir_deep(dirname($cacheFile));
|
||||||
|
GeneralUtility::writeFile($cacheFile, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key covers path, size and mtime - replacing a file re-extracts.
|
||||||
|
*/
|
||||||
|
private function cacheFileFor(string $path): string
|
||||||
|
{
|
||||||
|
$size = @filesize($path);
|
||||||
|
$mtime = @filemtime($path);
|
||||||
|
if ($size === false || $mtime === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return Environment::getVarPath() . '/tika-cache/'
|
||||||
|
. sha1($path . '|' . $size . '|' . $mtime) . '.txt';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\UserFunc;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||||
|
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data source for the product filter: the product category hierarchy, and
|
||||||
|
* nothing else.
|
||||||
|
*
|
||||||
|
* The editor places ONE `vitec_productfinder` element above the product
|
||||||
|
* lists on the products page. It renders no product cards - the React front
|
||||||
|
* end builds the filter selects from this payload and filters the products
|
||||||
|
* the `vitec_productlist` elements already delivered, client side, via URL
|
||||||
|
* parameters (?cat=58&subcat=66). No change to the list plugin was needed.
|
||||||
|
*
|
||||||
|
* Emitted shape:
|
||||||
|
*
|
||||||
|
* content.productfinder.categories[] = { uid, title, subcategories[] }
|
||||||
|
* subcategories[] = { uid, title }
|
||||||
|
*
|
||||||
|
* Level 1 below the configured root becomes the main categories, their
|
||||||
|
* direct children the subcategories - the same tree the product records
|
||||||
|
* hang on, so a filter value maps straight onto `product.categories[].uid`.
|
||||||
|
*
|
||||||
|
* TYPO3 v14 hands the ContentObjectRenderer in through the setter; without
|
||||||
|
* it $this->cObj stays null and the element cannot read its own FlexForm.
|
||||||
|
*/
|
||||||
|
final class ProductFinderJsonRenderer
|
||||||
|
{
|
||||||
|
private const CATEGORY_TABLE = 'sys_category';
|
||||||
|
|
||||||
|
/** Fallback when no root is configured: the category tree the products use. */
|
||||||
|
private const DEFAULT_ROOT_TITLE = 'Product';
|
||||||
|
|
||||||
|
protected ?ContentObjectRenderer $cObj = null;
|
||||||
|
|
||||||
|
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
||||||
|
{
|
||||||
|
$this->cObj = $cObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsAllowedCallable]
|
||||||
|
public function render(string $content, array $conf): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$settings = $this->settings();
|
||||||
|
|
||||||
|
$root = (int)($settings['categoryRoot'] ?? 0);
|
||||||
|
if ($root <= 0) {
|
||||||
|
$root = $this->rootByTitle(self::DEFAULT_ROOT_TITLE);
|
||||||
|
}
|
||||||
|
if ($root <= 0) {
|
||||||
|
return (string)json_encode(['categories' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = (string)($settings['sorting'] ?? 'sorting');
|
||||||
|
$categories = [];
|
||||||
|
foreach ($this->childrenOf($root, $order) as $main) {
|
||||||
|
$categories[] = [
|
||||||
|
'uid' => (int)$main['uid'],
|
||||||
|
'title' => (string)$main['title'],
|
||||||
|
'subcategories' => array_map(
|
||||||
|
static fn(array $sub): array => [
|
||||||
|
'uid' => (int)$sub['uid'],
|
||||||
|
'title' => (string)$sub['title'],
|
||||||
|
],
|
||||||
|
$this->childrenOf((int)$main['uid'], $order)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string)json_encode(['categories' => $categories], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// A broken filter must never take the page payload down with it.
|
||||||
|
return (string)json_encode(['categories' => []]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlexForm settings of the element being rendered.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function settings(): array
|
||||||
|
{
|
||||||
|
$flexform = (string)($this->cObj->data['pi_flexform'] ?? '');
|
||||||
|
if ($flexform === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$parsed = GeneralUtility::makeInstance(FlexFormService::class)
|
||||||
|
->convertFlexFormContentToArray($flexform);
|
||||||
|
|
||||||
|
return is_array($parsed['settings'] ?? null) ? $parsed['settings'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct children of a category, honouring the frontend restrictions
|
||||||
|
* (hidden, start/end time, deleted).
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function childrenOf(int $parent, string $order): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||||
|
|
||||||
|
$query = $queryBuilder->select('uid', 'title', 'sorting')
|
||||||
|
->from(self::CATEGORY_TABLE)
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($parent, ParameterType::INTEGER)),
|
||||||
|
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
|
||||||
|
);
|
||||||
|
|
||||||
|
match ($order) {
|
||||||
|
'title' => $query->orderBy('title', 'ASC'),
|
||||||
|
'uid' => $query->orderBy('uid', 'ASC'),
|
||||||
|
default => $query->orderBy('sorting', 'ASC')->addOrderBy('title', 'ASC'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return $query->executeQuery()->fetchAllAssociative();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uid of a root-level category by title, 0 when it does not exist. */
|
||||||
|
private function rootByTitle(string $title): int
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||||
|
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||||
|
|
||||||
|
$uid = $queryBuilder->select('uid')->from(self::CATEGORY_TABLE)
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('parent', 0),
|
||||||
|
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title))
|
||||||
|
)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
|
||||||
|
return is_numeric($uid) ? (int)$uid : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ class SearchJsonRenderer
|
|||||||
'pages' => 'page',
|
'pages' => 'page',
|
||||||
'tx_vitec_domain_model_product' => 'product',
|
'tx_vitec_domain_model_product' => 'product',
|
||||||
'tx_vitec_domain_model_market' => 'market',
|
'tx_vitec_domain_model_market' => 'market',
|
||||||
|
'tx_vitec_domain_model_solution' => 'solution',
|
||||||
'tx_vitec_domain_model_usecase' => 'story',
|
'tx_vitec_domain_model_usecase' => 'story',
|
||||||
'tx_news_domain_model_news' => 'news',
|
'tx_news_domain_model_news' => 'news',
|
||||||
'tx_vitec_domain_model_download' => 'download',
|
'tx_vitec_domain_model_download' => 'download',
|
||||||
@@ -152,6 +153,15 @@ class SearchJsonRenderer
|
|||||||
$activeType = '';
|
$activeType = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary facet filters: value comes verbatim from facets.<name>[].value
|
||||||
|
$facetFilters = [];
|
||||||
|
foreach (['market', 'category'] as $facetParam) {
|
||||||
|
$facetValue = trim((string)($params[$facetParam] ?? ''));
|
||||||
|
if ($facetValue !== '') {
|
||||||
|
$facetFilters[$facetParam] = $facetValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($query === '') {
|
if ($query === '') {
|
||||||
return (string)json_encode($this->emptyResult());
|
return (string)json_encode($this->emptyResult());
|
||||||
}
|
}
|
||||||
@@ -177,8 +187,15 @@ class SearchJsonRenderer
|
|||||||
$search,
|
$search,
|
||||||
);
|
);
|
||||||
$arguments = ['q' => $query, 'page' => $page];
|
$arguments = ['q' => $query, 'page' => $page];
|
||||||
|
$filterArguments = [];
|
||||||
if ($activeType !== '') {
|
if ($activeType !== '') {
|
||||||
$arguments['filter'] = ['type:' . $typeFilterField];
|
$filterArguments[] = 'type:' . $typeFilterField;
|
||||||
|
}
|
||||||
|
foreach ($facetFilters as $facetName => $facetValue) {
|
||||||
|
$filterArguments[] = $facetName . ':' . $facetValue;
|
||||||
|
}
|
||||||
|
if ($filterArguments !== []) {
|
||||||
|
$arguments['filter'] = $filterArguments;
|
||||||
}
|
}
|
||||||
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
|
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
|
||||||
->buildForSearch($arguments, $pageId, $languageId);
|
->buildForSearch($arguments, $pageId, $languageId);
|
||||||
@@ -255,6 +272,7 @@ class SearchJsonRenderer
|
|||||||
'numFound' => $numFound,
|
'numFound' => $numFound,
|
||||||
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
|
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
|
||||||
'filter' => $activeType !== '' ? $activeType : null,
|
'filter' => $activeType !== '' ? $activeType : null,
|
||||||
|
'activeFacets' => (object)$facetFilters,
|
||||||
'facets' => (object)$facets,
|
'facets' => (object)$facets,
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'suggestions' => array_values(array_unique($suggestions)),
|
'suggestions' => array_values(array_unique($suggestions)),
|
||||||
@@ -276,6 +294,7 @@ class SearchJsonRenderer
|
|||||||
'numFound' => 0,
|
'numFound' => 0,
|
||||||
'totalPages' => 0,
|
'totalPages' => 0,
|
||||||
'filter' => null,
|
'filter' => null,
|
||||||
|
'activeFacets' => new stdClass(),
|
||||||
'facets' => new \stdClass(),
|
'facets' => new \stdClass(),
|
||||||
'results' => [],
|
'results' => [],
|
||||||
'suggestions' => [],
|
'suggestions' => [],
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Product Finder - delivers the category hierarchy for the filter selects.
|
||||||
|
One element per products page, placed above the product lists.
|
||||||
|
-->
|
||||||
|
<T3DataStructure>
|
||||||
|
<sheets>
|
||||||
|
<sDEF>
|
||||||
|
<ROOT>
|
||||||
|
<sheetTitle>Product Finder</sheetTitle>
|
||||||
|
<type>array</type>
|
||||||
|
<el>
|
||||||
|
<settings.categoryRoot>
|
||||||
|
<label>Category root</label>
|
||||||
|
<description>The category whose children become the filter's main categories. Leave empty to use the product category tree ("Product").</description>
|
||||||
|
<config>
|
||||||
|
<type>select</type>
|
||||||
|
<renderType>selectSingle</renderType>
|
||||||
|
<foreign_table>sys_category</foreign_table>
|
||||||
|
<foreign_table_where>AND sys_category.parent = 0 AND sys_category.sys_language_uid IN (-1,0) ORDER BY sys_category.title ASC</foreign_table_where>
|
||||||
|
<items type="array">
|
||||||
|
<numIndex index="0" type="array">
|
||||||
|
<numIndex index="0">Product category tree (default)</numIndex>
|
||||||
|
<numIndex index="1">0</numIndex>
|
||||||
|
</numIndex>
|
||||||
|
</items>
|
||||||
|
<default>0</default>
|
||||||
|
</config>
|
||||||
|
</settings.categoryRoot>
|
||||||
|
|
||||||
|
<settings.sorting>
|
||||||
|
<label>Sort categories by</label>
|
||||||
|
<description>Applies to the main categories and their subcategories alike.</description>
|
||||||
|
<config>
|
||||||
|
<type>select</type>
|
||||||
|
<renderType>selectSingle</renderType>
|
||||||
|
<items type="array">
|
||||||
|
<numIndex index="0" type="array">
|
||||||
|
<numIndex index="0">Backend order (as arranged in the category tree)</numIndex>
|
||||||
|
<numIndex index="1">sorting</numIndex>
|
||||||
|
</numIndex>
|
||||||
|
<numIndex index="1" type="array">
|
||||||
|
<numIndex index="0">Alphabetically by title</numIndex>
|
||||||
|
<numIndex index="1">title</numIndex>
|
||||||
|
</numIndex>
|
||||||
|
<numIndex index="2" type="array">
|
||||||
|
<numIndex index="0">By uid</numIndex>
|
||||||
|
<numIndex index="1">uid</numIndex>
|
||||||
|
</numIndex>
|
||||||
|
</items>
|
||||||
|
<default>sorting</default>
|
||||||
|
</config>
|
||||||
|
</settings.sorting>
|
||||||
|
</el>
|
||||||
|
</ROOT>
|
||||||
|
</sDEF>
|
||||||
|
</sheets>
|
||||||
|
</T3DataStructure>
|
||||||
@@ -67,6 +67,21 @@ tt_content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Product Finder - only the category hierarchy for the filter selects.
|
||||||
|
# Placed once above the product lists; the front end filters the products
|
||||||
|
# those lists already delivered, client side, via ?cat= and ?subcat=.
|
||||||
|
vitec_productfinder < lib.contentElementWithHeader
|
||||||
|
vitec_productfinder {
|
||||||
|
fields {
|
||||||
|
content {
|
||||||
|
fields {
|
||||||
|
productfinder = USER
|
||||||
|
productfinder.userFunc = Evomedien\Vitec\UserFunc\ProductFinderJsonRenderer->render
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
||||||
solr_pi_results < lib.contentElementWithHeader
|
solr_pi_results < lib.contentElementWithHeader
|
||||||
solr_pi_results {
|
solr_pi_results {
|
||||||
@@ -389,7 +404,14 @@ plugin.tx_solr.index.queue {
|
|||||||
|
|
||||||
products = 1
|
products = 1
|
||||||
products {
|
products {
|
||||||
|
# sys_category titles - feeds the category facet
|
||||||
|
fields.category_stringM = SOLR_RELATION
|
||||||
|
fields.category_stringM {
|
||||||
|
localField = categories
|
||||||
|
multiValue = 1
|
||||||
|
}
|
||||||
type = tx_vitec_domain_model_product
|
type = tx_vitec_domain_model_product
|
||||||
|
additionalWhereClause = hideonwebsite = 0
|
||||||
fields {
|
fields {
|
||||||
title = title
|
title = title
|
||||||
content = SOLR_CONTENT
|
content = SOLR_CONTENT
|
||||||
@@ -466,7 +488,15 @@ plugin.tx_solr.index.queue {
|
|||||||
|
|
||||||
usecases = 1
|
usecases = 1
|
||||||
usecases {
|
usecases {
|
||||||
|
# market titles from the MM relation - feeds the market facet
|
||||||
|
fields.market_stringM = SOLR_RELATION
|
||||||
|
fields.market_stringM {
|
||||||
|
localField = markets
|
||||||
|
multiValue = 1
|
||||||
|
}
|
||||||
type = tx_vitec_domain_model_usecase
|
type = tx_vitec_domain_model_usecase
|
||||||
|
# no_index is the editorial opt-out per story (concept 2026-08-20)
|
||||||
|
additionalWhereClause = hideonwebsite = 0 AND no_index = 0
|
||||||
fields {
|
fields {
|
||||||
title = title
|
title = title
|
||||||
content = SOLR_CONTENT
|
content = SOLR_CONTENT
|
||||||
@@ -495,6 +525,39 @@ plugin.tx_solr.index.queue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Solutions became indexable on 2026-09-11: the sitemap alignment gave all
|
||||||
|
# 39 records a detail_page (and slug). Same pattern as markets.
|
||||||
|
solutions = 1
|
||||||
|
solutions {
|
||||||
|
type = tx_vitec_domain_model_solution
|
||||||
|
additionalWhereClause = detail_page > 0
|
||||||
|
fields {
|
||||||
|
title = title
|
||||||
|
content = SOLR_CONTENT
|
||||||
|
content {
|
||||||
|
cObject = COA
|
||||||
|
cObject {
|
||||||
|
10 = TEXT
|
||||||
|
10.field = subtitle
|
||||||
|
10.noTrimWrap = || |
|
||||||
|
20 = TEXT
|
||||||
|
20.field = teaser
|
||||||
|
20.noTrimWrap = || |
|
||||||
|
30 = TEXT
|
||||||
|
30.field = description
|
||||||
|
30.noTrimWrap = || |
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url = TEXT
|
||||||
|
url {
|
||||||
|
typolink {
|
||||||
|
parameter.field = detail_page
|
||||||
|
returnLast = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
news = 1
|
news = 1
|
||||||
news {
|
news {
|
||||||
type = tx_news_domain_model_news
|
type = tx_news_domain_model_news
|
||||||
@@ -580,6 +643,16 @@ plugin.tx_solr.search.faceting {
|
|||||||
field = type
|
field = type
|
||||||
keepAllOptionsOnSelection = 1
|
keepAllOptionsOnSelection = 1
|
||||||
}
|
}
|
||||||
|
market {
|
||||||
|
label = Market
|
||||||
|
field = market_stringM
|
||||||
|
keepAllOptionsOnSelection = 1
|
||||||
|
}
|
||||||
|
category {
|
||||||
|
label = Category
|
||||||
|
field = category_stringM
|
||||||
|
keepAllOptionsOnSelection = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
|
|||||||
};
|
};
|
||||||
|
|
||||||
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
||||||
|
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
||||||
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
||||||
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
||||||
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ mod {
|
|||||||
CType = vitec_productlist
|
CType = vitec_productlist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
productfinder {
|
||||||
|
iconIdentifier = vitec-plugin-productlist
|
||||||
|
title = Product Finder (filter data)
|
||||||
|
description = Delivers the product category hierarchy for the filter selects. Place once above the product lists - it renders no products itself.
|
||||||
|
tt_content_defValues {
|
||||||
|
CType = vitec_productfinder
|
||||||
|
}
|
||||||
|
}
|
||||||
productshow {
|
productshow {
|
||||||
iconIdentifier = vitec-plugin-productshow
|
iconIdentifier = vitec-plugin-productshow
|
||||||
title = Show single VITEC Product
|
title = Show single VITEC Product
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||||
| **Version** | 1.12 |
|
| **Version** | 1.15 |
|
||||||
| **Status** | Released |
|
| **Status** | Released |
|
||||||
| **Date** | 2026‑08‑24 |
|
| **Date** | 2026‑09‑11 |
|
||||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||||
| **Owner** | evomedien — VITEC relaunch |
|
| **Owner** | evomedien — VITEC relaunch |
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
| 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
|
| 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
|
||||||
| 1.11 | 2026‑08‑21 | **New interface: site search.** Apache Solr 10 (dedicated VPS behind an HTTPS reverse proxy) with `apache-solr-for-typo3/solr` 14.0.0-RC1. The EXT:solr results plugin `solr_pi_results` on the search page is rendered headless by `SearchJsonRenderer` (payload key `search`) - request/response contract in the new Clause 7.16. Indexed corpus: pages plus product, market (`detail_page` only), use-case, news and download records; result `type` vocabulary `page\|product\|market\|story\|news\|download`. Downloads gained the canonical route `/download/<slug>` (uid route kept for the record links) and `private_download` is now enforced by `DownloadFileResolver` (5.3, 9.12). The VITEC Set now declares the solr set as a dependency - overriding a foreign set’s TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. |
|
| 1.11 | 2026‑08‑21 | **New interface: site search.** Apache Solr 10 (dedicated VPS behind an HTTPS reverse proxy) with `apache-solr-for-typo3/solr` 14.0.0-RC1. The EXT:solr results plugin `solr_pi_results` on the search page is rendered headless by `SearchJsonRenderer` (payload key `search`) - request/response contract in the new Clause 7.16. Indexed corpus: pages plus product, market (`detail_page` only), use-case, news and download records; result `type` vocabulary `page\|product\|market\|story\|news\|download`. Downloads gained the canonical route `/download/<slug>` (uid route kept for the record links) and `private_download` is now enforced by `DownloadFileResolver` (5.3, 9.12). The VITEC Set now declares the solr set as a dependency - overriding a foreign set’s TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. |
|
||||||
| 1.12 | 2026‑08‑24 | **Interface change (additive).** The search endpoint (7.16) gained a type filter and facet counts: request parameter `filter` (a value from the `type` vocabulary; the natural name `type` is unavailable - it is TYPO3’s reserved page-type parameter), response keys `filter` (active filter or null) and `facets.type` (per-type document counts with `active` flags; counts stay complete while a filter is active, except when the filtered result is empty). Editorial baseline of 76 managed synonyms imported into `core_en` (codecs, acquired-brand names such as `exterity => avedia`, UK/US spellings, common misspellings) - synonyms apply at query time, no re-index. New autocomplete endpoint: the EXT:solr suggest plugin as lean JSON page type 7384, deliberately USER_INT (7.16). |
|
| 1.12 | 2026‑08‑24 | **Interface change (additive).** The search endpoint (7.16) gained a type filter and facet counts: request parameter `filter` (a value from the `type` vocabulary; the natural name `type` is unavailable - it is TYPO3’s reserved page-type parameter), response keys `filter` (active filter or null) and `facets.type` (per-type document counts with `active` flags; counts stay complete while a filter is active, except when the filtered result is empty). Editorial baseline of 76 managed synonyms imported into `core_en` (codecs, acquired-brand names such as `exterity => avedia`, UK/US spellings, common misspellings) - synonyms apply at query time, no re-index. New autocomplete endpoint: the EXT:solr suggest plugin as lean JSON page type 7384, deliberately USER_INT (7.16). |
|
||||||
|
| 1.13 | 2026‑09‑11 | **New plugin** `vitec_productfinder` (`ProductFinderJsonRenderer`, payload key `productfinder`): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to `vitec_productlist` — filtering happens client side over the products already delivered, keyed on the category uids this payload carries. |
|
||||||
|
| 1.14 | 2026‑09‑11 | **Interface change (additive).** Secondary search facets: stories index their market titles (`market_stringM`, MM relation), products their sys_category titles (`category_stringM`); the search endpoint accepts `market` and `category` GET parameters (values verbatim from `facets.<name>[].value`; an unknown value simply yields zero results) and echoes them as `activeFacets`. Visibility fix: the usecases queue now honours `no_index`/`hideonwebsite`, products defensively `hideonwebsite` - and the excluded story’s document was removed explicitly, because re-indexing never deletes. |
|
||||||
|
| 1.15 | 2026‑09‑11 | **Corpus change (additive, index side).** Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route `/tika/*`, own basic-auth credential) extracts PDF/Office contents during indexing (`TikaDownloadContentIndexer` on `BeforeDocumentIsProcessedForIndexingEvent`, fail-soft per 9.5, cached in `var/tika-cache` keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change. |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
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
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -904,6 +907,46 @@ rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### 7.15.3 Product Finder payload (`vitec_productfinder`, key `productfinder`)
|
||||||
|
|
||||||
|
The filter above the product lists is fed by its own element. It carries the
|
||||||
|
product **category hierarchy only** — no products, no counts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "vitec_productfinder",
|
||||||
|
"content": {
|
||||||
|
"productfinder": {
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"uid": 58,
|
||||||
|
"title": "Platforms and End-Points",
|
||||||
|
"subcategories": [
|
||||||
|
{ "uid": 66, "title": "Avedia Platform" },
|
||||||
|
{ "uid": 67, "title": "EZ TV Platform" },
|
||||||
|
{ "uid": 68, "title": "APEX Platform" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`categories[]` are the children of the configured root category, `subcategories[]`
|
||||||
|
their direct children — the same two levels the product records hang on, so a
|
||||||
|
selected value maps straight onto `product.categories[].uid` in the list payload
|
||||||
|
(7.15.2). The editor places **one** element above the lists; FlexForm offers the
|
||||||
|
category root (empty = the `Product` tree) and the order of both levels (backend
|
||||||
|
order, title, uid).
|
||||||
|
|
||||||
|
Filtering itself is a front-end concern: the list plugin is unchanged, and the
|
||||||
|
front end narrows the products it already received using the URL parameters
|
||||||
|
`?cat=` and `?subcat=`. Categories without products are emitted like any other —
|
||||||
|
the front end decides whether to offer or suppress an empty result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 7.16 Search payload (`solr_pi_results`)
|
### 7.16 Search payload (`solr_pi_results`)
|
||||||
|
|
||||||
The site search runs on Apache Solr through EXT:solr. The EXT:solr results plugin
|
The site search runs on Apache Solr through EXT:solr. The EXT:solr results plugin
|
||||||
@@ -919,6 +962,8 @@ only the rendering differs from the stock Fluid plugin.
|
|||||||
| `q` | Search terms. Absent or empty: the response keeps its full shape with `numFound: 0`, so the front end never needs a second code path. |
|
| `q` | Search terms. Absent or empty: the response keeps its full shape with `numFound: 0`, so the front end never needs a second code path. |
|
||||||
| `page` | 1-based result page, optional. |
|
| `page` | 1-based result page, optional. |
|
||||||
| `filter` | Restrict results to one type from the `type` vocabulary below (e.g. `filter=product`). Unknown values are ignored. Named `filter` because `type` is TYPO3’s reserved page-type parameter. |
|
| `filter` | Restrict results to one type from the `type` vocabulary below (e.g. `filter=product`). Unknown values are ignored. Named `filter` because `type` is TYPO3’s reserved page-type parameter. |
|
||||||
|
| `market` | Restrict to one market (stories carry the facet). Value verbatim from `facets.market[].value`; unknown values yield zero results. |
|
||||||
|
| `category` | Restrict to one product category. Value verbatim from `facets.category[].value`. |
|
||||||
|
|
||||||
The EXT:solr namespace (`tx_solr[q]`, `tx_solr[page]`) is accepted as a fallback.
|
The EXT:solr namespace (`tx_solr[q]`, `tx_solr[page]`) is accepted as a fallback.
|
||||||
All three parameters are excluded from cHash validation; the search page is never
|
All three parameters are excluded from cHash validation; the search page is never
|
||||||
@@ -936,7 +981,11 @@ identifier).
|
|||||||
"numFound": 202,
|
"numFound": 202,
|
||||||
"totalPages": 21,
|
"totalPages": 21,
|
||||||
"filter": null,
|
"filter": null,
|
||||||
|
"activeFacets": {},
|
||||||
"facets": {
|
"facets": {
|
||||||
|
"market": [
|
||||||
|
{ "value": "Sports, Venues & Entertainment", "count": 9, "active": false }
|
||||||
|
],
|
||||||
"type": [
|
"type": [
|
||||||
{ "value": "news", "count": 137, "active": false },
|
{ "value": "news", "count": 137, "active": false },
|
||||||
{ "value": "product", "count": 13, "active": false }
|
{ "value": "product", "count": 13, "active": false }
|
||||||
@@ -963,14 +1012,19 @@ identifier).
|
|||||||
tabs do not collapse - except when the filtered result is empty, where only
|
tabs do not collapse - except when the filtered result is empty, where only
|
||||||
the active option (count 0) is returned; front ends should then offer
|
the active option (count 0) is returned; front ends should then offer
|
||||||
"remove filter" rather than rely on the other counts.
|
"remove filter" rather than rely on the other counts.
|
||||||
- `filter` echoes the active type filter, `null` when none.
|
- `filter` echoes the active type filter, `null` when none; `activeFacets`
|
||||||
|
echoes the active secondary facet filters as `{name: value}`, `{}` when none.
|
||||||
|
- `facets.market` (story market titles, from the MM relation) and
|
||||||
|
`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.
|
- `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none.
|
||||||
|
|
||||||
**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a
|
**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a
|
||||||
resolvable public URL - products, markets (only those with `detail_page`),
|
resolvable public URL - products, markets (only those with `detail_page`),
|
||||||
use cases, news (`type = 0`; "page as news" records are excluded because their
|
use cases, news (`type = 0`; "page as news" records are excluded because their
|
||||||
target pages are already indexed) and downloads (`private_download = 0 AND
|
target pages are already indexed) and downloads (`private_download = 0 AND
|
||||||
hideonwebsite = 0`). Solutions are not indexed until they carry slugs or detail
|
hideonwebsite = 0`; their `content` additionally carries the file text extracted
|
||||||
|
through Apache Tika, so datasheet specifications are searchable). Solutions are not indexed until they carry slugs or detail
|
||||||
pages. Ranking boosts products (^10) and stories (^2). Every executed search is
|
pages. Ranking boosts products (^10) and stories (^2). Every executed search is
|
||||||
logged to `tx_solr_statistics` with the last two IP octets masked.
|
logged to `tx_solr_statistics` with the last two IP octets masked.
|
||||||
|
|
||||||
@@ -1005,6 +1059,7 @@ content is extracted from the page’s `tt_content` rows via
|
|||||||
| CType | TS pattern | Renderer / Processor | Payload key | Kind |
|
| CType | TS pattern | Renderer / Processor | Payload key | Kind |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `vitec_productlist` | `< lib.contentElementWithHeader` | ProductListJsonRenderer | `products` | list |
|
| `vitec_productlist` | `< lib.contentElementWithHeader` | ProductListJsonRenderer | `products` | list |
|
||||||
|
| `vitec_productfinder` | `< lib.contentElementWithHeader` | ProductFinderJsonRenderer | `productfinder` | taxonomy |
|
||||||
| `vitec_productshow` | `< lib.…WithHeader` | ProductShowJsonRenderer | `product` | detail |
|
| `vitec_productshow` | `< lib.…WithHeader` | ProductShowJsonRenderer | `product` | detail |
|
||||||
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
|
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
|
||||||
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
|
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
|
||||||
@@ -1368,4 +1423,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.12.*
|
*End of document EVO‑VITEC‑HL‑001 v1.15.*
|
||||||
@@ -258,3 +258,5 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
|
|||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'q';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'q';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
|
||||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
||||||
|
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
||||||
Reference in new issue
Block a user