- 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)
330 lines
14 KiB
PHP
330 lines
14 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", "image" } ],
|
|
* "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
|
|
* }
|
|
*
|
|
* 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);
|
|
}
|
|
$image = (string)($document['image'] ?? '');
|
|
// IMG_RESOURCE yields fileadmin/... without a leading slash -
|
|
// made root-relative like every other URL in the payload
|
|
if ($image !== '' && !str_starts_with($image, '/') && !preg_match('#^https?://#', $image)) {
|
|
$image = '/' . $image;
|
|
}
|
|
$results[] = [
|
|
'title' => (string)$document->getTitle(),
|
|
'url' => (string)$document->getUrl(),
|
|
'type' => self::TYPE_LABELS[$document->getType()] ?? (string)$document->getType(),
|
|
'teaser' => $teaser,
|
|
// processed tile image (640px wide) from the index; null when the
|
|
// document type has none (pages, downloads) or no image is set
|
|
'image' => $image !== '' ? $image : null,
|
|
];
|
|
}
|
|
|
|
$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
|
|
}
|
|
|
|
// 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);
|
|
|
|
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,
|
|
'cards' => $cards,
|
|
'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(),
|
|
'cards' => [],
|
|
'results' => [],
|
|
'suggestions' => [],
|
|
];
|
|
}
|
|
}
|