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.
304 lines
12 KiB
PHP
304 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\UserFunc;
|
|
|
|
use ApacheSolrForTypo3\Solr\ConnectionManager;
|
|
use ApacheSolrForTypo3\Solr\Domain\Search\ResultSet\SearchResultSetService;
|
|
use ApacheSolrForTypo3\Solr\Domain\Search\SearchRequestBuilder;
|
|
use ApacheSolrForTypo3\Solr\Search;
|
|
use ApacheSolrForTypo3\Solr\System\Configuration\ConfigurationManager;
|
|
use ApacheSolrForTypo3\Solr\System\Service\ConfigurationService;
|
|
use Doctrine\DBAL\ParameterType;
|
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* UserFunc: render EXT:solr search results as JSON (headless).
|
|
*
|
|
* Output under content.search:
|
|
* {
|
|
* "query": the search terms as used,
|
|
* "page": 1-based current page,
|
|
* "resultsPerPage": page size (plugin.tx_solr.search.results.resultsPerPage
|
|
* or the FlexForm override on the plugin),
|
|
* "numFound": total hits,
|
|
* "totalPages": ceil(numFound / resultsPerPage),
|
|
* "results": [ { "title", "url", "type", "teaser" } ],
|
|
* "filter": the active type filter or null,
|
|
* "facets": { "type": [ { "value", "count", "active" } ] } -
|
|
* counts stay complete while a filter is active,
|
|
* "suggestions": spellcheck alternatives ("did you mean"), [] if none
|
|
* }
|
|
*
|
|
* Request parameters (GET, on the page carrying the plugin):
|
|
* q = search terms; without it the empty shape above is returned
|
|
* (numFound 0) so the frontend always sees the same structure
|
|
* page = 1-based page number, optional
|
|
* filter = restrict results to one type from the `type` vocabulary
|
|
* (page|product|market|story|news|download); unknown values ignored
|
|
* The EXT:solr namespace (tx_solr[q], tx_solr[page]) is accepted as a
|
|
* fallback so classic solr URLs keep working.
|
|
*
|
|
* The search itself is EXT:solr's own pipeline (SearchRequestBuilder ->
|
|
* SearchResultSetService), i.e. identical to what the Fluid plugin runs -
|
|
* only the rendering is JSON instead of HTML. `teaser` carries the
|
|
* highlighted fragment when highlighting is enabled, otherwise a plain
|
|
* 250-char excerpt of the indexed content.
|
|
*
|
|
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
|
|
* specific tt_content row (reused by ContainerChildrenProcessor for nested
|
|
* plugins). Exception-safe.
|
|
*/
|
|
class SearchJsonRenderer
|
|
{
|
|
private ?ContentObjectRenderer $cObj = null;
|
|
|
|
/** Same duck-typed setter contract as the other renderers (see MarketListJsonRenderer). */
|
|
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
|
{
|
|
$this->cObj = $cObj;
|
|
}
|
|
|
|
private const CTYPE = 'solr_pi_results';
|
|
|
|
/**
|
|
* Solr document types (index.queue config tables) to frontend-friendly
|
|
* type labels. Unknown types pass through verbatim.
|
|
*/
|
|
private const TYPE_LABELS = [
|
|
'pages' => 'page',
|
|
'tx_vitec_domain_model_product' => 'product',
|
|
'tx_vitec_domain_model_market' => 'market',
|
|
'tx_vitec_domain_model_solution' => 'solution',
|
|
'tx_vitec_domain_model_usecase' => 'story',
|
|
'tx_news_domain_model_news' => 'news',
|
|
'tx_vitec_domain_model_download' => 'download',
|
|
];
|
|
private const TEASER_FALLBACK_LENGTH = 250;
|
|
|
|
#[AsAllowedCallable]
|
|
public function render(string $content, array $conf): string
|
|
{
|
|
$row = is_array($this->cObj?->data ?? null) ? $this->cObj->data : null;
|
|
if ($row && (string)($row['CType'] ?? '') === self::CTYPE) {
|
|
return $this->renderForRecord($row);
|
|
}
|
|
|
|
$pageId = 0;
|
|
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
|
if ($request !== null) {
|
|
$pageInfo = $request->getAttribute('frontend.page.information');
|
|
if ($pageInfo !== null) {
|
|
$pageId = (int)$pageInfo->getId();
|
|
}
|
|
}
|
|
if ($pageId <= 0) {
|
|
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
|
|
}
|
|
if ($pageId <= 0) {
|
|
return '';
|
|
}
|
|
|
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
|
|
$ces = $qb
|
|
->select('*')
|
|
->from('tt_content')
|
|
->where(
|
|
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
|
|
$qb->expr()->eq('CType', $qb->createNamedParameter(self::CTYPE, ParameterType::STRING)),
|
|
$qb->expr()->eq('deleted', 0),
|
|
$qb->expr()->eq('hidden', 0)
|
|
)
|
|
->executeQuery()
|
|
->fetchAllAssociative();
|
|
|
|
if (empty($ces)) {
|
|
return '';
|
|
}
|
|
|
|
return $this->renderForRecord($ces[0]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $contentElement
|
|
*/
|
|
public function renderForRecord(array $contentElement): string
|
|
{
|
|
try {
|
|
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
|
if ($request === null) {
|
|
return '';
|
|
}
|
|
|
|
// Search responses depend on the q/page GET parameters, which are
|
|
// excluded from cHash - cached variants would collide (config.no_cache
|
|
// is gone in TYPO3 v14, so the cache is disabled per request here).
|
|
$request->getAttribute('frontend.cache.instruction')
|
|
?->disableCache('vitec search: response varies by q/page/filter query parameters');
|
|
|
|
$params = $request->getQueryParams();
|
|
$solrNamespace = (array)($params['tx_solr'] ?? []);
|
|
$query = trim((string)($params['q'] ?? $solrNamespace['q'] ?? ''));
|
|
$page = max(1, (int)($params['page'] ?? $solrNamespace['page'] ?? 1));
|
|
|
|
// Optional type filter, friendly vocabulary (see TYPE_LABELS).
|
|
// Unknown values are ignored, never an error.
|
|
$activeType = trim((string)($params['filter'] ?? ''));
|
|
$typeFilterField = array_search($activeType, self::TYPE_LABELS, true);
|
|
if ($typeFilterField === false) {
|
|
$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 === '') {
|
|
return (string)json_encode($this->emptyResult());
|
|
}
|
|
|
|
$languageId = (int)($request->getAttribute('language')?->getLanguageId() ?? 0);
|
|
$pageId = (int)($request->getAttribute('frontend.page.information')?->getId() ?? 0);
|
|
|
|
$typoScriptConfiguration = GeneralUtility::makeInstance(ConfigurationManager::class)
|
|
->getTypoScriptFromRequest($request);
|
|
if (!empty($contentElement['pi_flexform'])) {
|
|
GeneralUtility::makeInstance(ConfigurationService::class)->overrideConfigurationWithFlexFormSettings(
|
|
(string)$contentElement['pi_flexform'],
|
|
$typoScriptConfiguration,
|
|
);
|
|
}
|
|
|
|
$connection = GeneralUtility::makeInstance(ConnectionManager::class)
|
|
->getConnectionByTypo3Site($request->getAttribute('site'), $languageId);
|
|
$search = GeneralUtility::makeInstance(Search::class, $connection);
|
|
$searchService = GeneralUtility::makeInstance(
|
|
SearchResultSetService::class,
|
|
$typoScriptConfiguration,
|
|
$search,
|
|
);
|
|
$arguments = ['q' => $query, 'page' => $page];
|
|
$filterArguments = [];
|
|
if ($activeType !== '') {
|
|
$filterArguments[] = 'type:' . $typeFilterField;
|
|
}
|
|
foreach ($facetFilters as $facetName => $facetValue) {
|
|
$filterArguments[] = $facetName . ':' . $facetValue;
|
|
}
|
|
if ($filterArguments !== []) {
|
|
$arguments['filter'] = $filterArguments;
|
|
}
|
|
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
|
|
->buildForSearch($arguments, $pageId, $languageId);
|
|
|
|
$resultSet = $searchService->search($searchRequest);
|
|
|
|
$highlighted = null;
|
|
try {
|
|
$highlighted = $resultSet->getUsedSearch()?->getHighlightedContent();
|
|
} catch (\Throwable) {
|
|
// no highlighting available - the excerpt fallback below covers it
|
|
}
|
|
|
|
$results = [];
|
|
foreach ($resultSet->getSearchResults() as $document) {
|
|
$id = (string)$document->getId();
|
|
if ($highlighted !== null && !empty($highlighted->{$id}->content[0])) {
|
|
$teaser = implode(' ... ', $highlighted->{$id}->content);
|
|
} else {
|
|
$teaser = mb_substr(trim((string)$document->getContent()), 0, self::TEASER_FALLBACK_LENGTH);
|
|
}
|
|
$results[] = [
|
|
'title' => (string)$document->getTitle(),
|
|
'url' => (string)$document->getUrl(),
|
|
'type' => self::TYPE_LABELS[$document->getType()] ?? (string)$document->getType(),
|
|
'teaser' => $teaser,
|
|
];
|
|
}
|
|
|
|
$suggestions = [];
|
|
try {
|
|
foreach ($resultSet->getSpellCheckingSuggestions() as $suggestion) {
|
|
$word = method_exists($suggestion, 'getSuggestion')
|
|
? (string)$suggestion->getSuggestion()
|
|
: '';
|
|
if ($word !== '') {
|
|
$suggestions[] = $word;
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// spellchecking disabled or unavailable - not essential
|
|
}
|
|
|
|
$facets = [];
|
|
try {
|
|
foreach ($resultSet->getFacets() as $facet) {
|
|
$options = [];
|
|
foreach ($facet->getOptions() as $option) {
|
|
$value = (string)$option->getUriValue();
|
|
if ($facet->getName() === 'type') {
|
|
$value = self::TYPE_LABELS[$value] ?? $value;
|
|
}
|
|
$options[] = [
|
|
'value' => $value,
|
|
'count' => $option->getDocumentCount(),
|
|
'active' => $option->getSelected(),
|
|
];
|
|
}
|
|
if ($options !== []) {
|
|
$facets[$facet->getName()] = $options;
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// faceting disabled or unavailable - facets stay empty
|
|
}
|
|
|
|
$numFound = $resultSet->getAllResultCount();
|
|
$resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results);
|
|
|
|
return (string)json_encode([
|
|
'query' => $query,
|
|
'page' => $page,
|
|
'resultsPerPage' => $resultsPerPage,
|
|
'numFound' => $numFound,
|
|
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
|
|
'filter' => $activeType !== '' ? $activeType : null,
|
|
'activeFacets' => (object)$facetFilters,
|
|
'facets' => (object)$facets,
|
|
'results' => $results,
|
|
'suggestions' => array_values(array_unique($suggestions)),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function emptyResult(): array
|
|
{
|
|
return [
|
|
'query' => '',
|
|
'page' => 1,
|
|
'resultsPerPage' => 0,
|
|
'numFound' => 0,
|
|
'totalPages' => 0,
|
|
'filter' => null,
|
|
'activeFacets' => new stdClass(),
|
|
'facets' => new \stdClass(),
|
|
'results' => [],
|
|
'suggestions' => [],
|
|
];
|
|
}
|
|
}
|