From cf7a04ea15dd8db482649e79dd0fa08e9f14ea43 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 21 Aug 2026 15:44:03 +0200 Subject: [PATCH] Added Downloads to search index. --- .../Classes/Command/SolrIndexCommand.php | 15 ++ .../Middleware/DownloadFileMiddleware.php | 49 +++-- .../Classes/Service/DownloadFileResolver.php | 38 +++- .../Classes/UserFunc/SearchJsonRenderer.php | 15 +- .../Configuration/Sets/Vitecset/config.yaml | 3 + .../Sets/Vitecset/setup.typoscript | 197 ++++++++++++++++++ 6 files changed, 295 insertions(+), 22 deletions(-) diff --git a/packages/vitec/Classes/Command/SolrIndexCommand.php b/packages/vitec/Classes/Command/SolrIndexCommand.php index 3a5f868..3cef6b3 100644 --- a/packages/vitec/Classes/Command/SolrIndexCommand.php +++ b/packages/vitec/Classes/Command/SolrIndexCommand.php @@ -36,6 +36,7 @@ class SolrIndexCommand extends Command $this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Max queue items to index in this run', '20'); $this->addOption('root', 'r', InputOption::VALUE_REQUIRED, 'Root page uid of the site', '1'); $this->addOption('debug', 'd', InputOption::VALUE_NONE, 'Single-step the first queue item verbosely'); + $this->addOption('initialize', 'i', InputOption::VALUE_REQUIRED, 'Initialize the index queue for these configurations (comma list or *) before indexing'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -68,7 +69,21 @@ class SolrIndexCommand extends Command return Command::FAILURE; } + $initialize = (string)($input->getOption('initialize') ?? ''); + if ($initialize !== '') { + $names = $initialize === '*' ? ['*'] : array_map('trim', explode(',', $initialize)); + $initService = GeneralUtility::makeInstance(\ApacheSolrForTypo3\Solr\Domain\Index\Queue\QueueInitializationService::class); + $result = $initService->initializeBySiteAndIndexConfigurations($site, $names); + foreach ($result as $name => $ok) { + $output->writeln(' initialized ' . $name . ': ' . var_export($ok, true)); + } + } + if ($input->getOption('debug')) { + $queueConfig = $site->getSolrConfiguration()->getObjectByPathOrDefault('plugin.tx_solr.index.queue.'); + $output->writeln('index.queue keys: ' . implode(', ', array_keys($queueConfig))); + $output->writeln('products.table: ' . var_export($queueConfig['products.']['table'] ?? null, true)); + $queue = GeneralUtility::makeInstance(Queue::class); $items = $queue->getItemsToIndex($site, 5); $output->writeln('getItemsToIndex(5): ' . count($items) . ' items'); diff --git a/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php b/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php index e9fd9dc..8a59a3f 100644 --- a/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php +++ b/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php @@ -13,17 +13,21 @@ use TYPO3\CMS\Core\Http\Response; use TYPO3\CMS\Core\Http\Stream; /** - * Forced file download for download records: /download/file/ + * Forced file download for download records, two routes: * - * This is the frontend target of the "Download" record links from the link - * browser (config.recordLinks.download builds exactly this path). A direct - * fileadmin URL would open PDFs inline; this endpoint streams the file with - * Content-Disposition: attachment, so the browser saves it. + * /download/ - canonical, human-readable (search results, sharing) + * /download/file/ - legacy, kept because the "Download" record links + * from the link browser build exactly this path + * (config.recordLinks.download) + * + * A direct fileadmin URL would open PDFs inline; this endpoint streams the + * file with Content-Disposition: attachment, so the browser saves it. * * The file lookup lives in Service\DownloadFileResolver (FAL -> Collateral - * naming convention -> filepath column). Unknown uid, hidden record or - * missing file fall through to the regular pipeline - the reply is then the - * normal 404 page, never a broken download. + * naming convention -> filepath column), which also refuses records flagged + * private_download. Unknown uid/slug, hidden or private record or missing + * file fall through to the regular pipeline - the reply is then the normal + * 404 page, never a broken download. */ final class DownloadFileMiddleware implements MiddlewareInterface { @@ -31,21 +35,26 @@ final class DownloadFileMiddleware implements MiddlewareInterface { try { $path = $request->getUri()->getPath(); + + $file = null; if (preg_match('#^/download/file/(\\d+)/?$#', $path, $matches) === 1) { $file = DownloadFileResolver::resolve((int)$matches[1]); - if ($file !== null) { - $filename = str_replace(['"', "\r", "\n"], '', $file['name']); + } elseif (preg_match('#^/download/([a-z0-9\\-]+)/?$#', $path, $matches) === 1) { + $file = DownloadFileResolver::resolveBySlug($matches[1]); + } - return new Response( - new Stream($file['path'], 'rb'), - 200, - [ - 'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream', - 'Content-Length' => (string)$file['size'], - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', - ] - ); - } + if ($file !== null) { + $filename = str_replace(['"', "\r", "\n"], '', $file['name']); + + return new Response( + new Stream($file['path'], 'rb'), + 200, + [ + 'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream', + 'Content-Length' => (string)$file['size'], + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); } } catch (\Throwable $e) { // fall through to the regular pipeline diff --git a/packages/vitec/Classes/Service/DownloadFileResolver.php b/packages/vitec/Classes/Service/DownloadFileResolver.php index 2b887a5..f78da48 100644 --- a/packages/vitec/Classes/Service/DownloadFileResolver.php +++ b/packages/vitec/Classes/Service/DownloadFileResolver.php @@ -51,6 +51,39 @@ final class DownloadFileResolver } } + /** + * Same contract as resolve(), addressed by the record slug - the pretty + * /download/ route. Slugs are unique (eval: uniqueInPid). + * + * @return array{path: string, name: string, size: int, mimeType: string}|null + */ + public static function resolveBySlug(string $slug): ?array + { + if ($slug === '') { + return null; + } + + try { + $qb = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('tx_vitec_domain_model_download'); + $uid = $qb + ->select('uid') + ->from('tx_vitec_domain_model_download') + ->where( + $qb->expr()->eq('slug', $qb->createNamedParameter($slug, ParameterType::STRING)), + $qb->expr()->eq('deleted', 0), + $qb->expr()->eq('hidden', 0) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchOne(); + + return $uid ? self::resolve((int)$uid) : null; + } catch (\Throwable $e) { + return null; + } + } + /** @return array|null */ private static function loadDownload(int $uid): ?array { @@ -62,7 +95,10 @@ final class DownloadFileResolver ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), - $qb->expr()->eq('hidden', 0) + $qb->expr()->eq('hidden', 0), + // private downloads are never served publicly - the caller + // cannot distinguish this from a missing file, by design + $qb->expr()->eq('private_download', 0) ) ->setMaxResults(1) ->executeQuery() diff --git a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php index c688afb..167a2d0 100644 --- a/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/SearchJsonRenderer.php @@ -59,6 +59,19 @@ class SearchJsonRenderer } 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_usecase' => 'story', + 'tx_news_domain_model_news' => 'news', + 'tx_vitec_domain_model_download' => 'download', + ]; private const TEASER_FALLBACK_LENGTH = 250; #[AsAllowedCallable] @@ -173,7 +186,7 @@ class SearchJsonRenderer $results[] = [ 'title' => (string)$document->getTitle(), 'url' => (string)$document->getUrl(), - 'type' => (string)$document->getType(), + 'type' => self::TYPE_LABELS[$document->getType()] ?? (string)$document->getType(), 'teaser' => $teaser, ]; } diff --git a/packages/vitec/Configuration/Sets/Vitecset/config.yaml b/packages/vitec/Configuration/Sets/Vitecset/config.yaml index ce40a05..1151776 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/config.yaml +++ b/packages/vitec/Configuration/Sets/Vitecset/config.yaml @@ -8,3 +8,6 @@ dependencies: - typo3/fluid-styled-content - friendsoftypo3/headless - nb-headless-content-blocks/headless-content-blocks + # solr must load before us so our plugin.tx_solr overrides (statistics, + # highlighting, content extraction) survive - set order follows dependencies + - apache-solr-for-typo3/solr diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 3c071b1..d54f473 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -336,3 +336,200 @@ plugin.tx_solr.index.queue.pages.fields { } } } + +# Log every executed search (keywords, hit count) into tx_solr_statistics - +# feeds the Search Phrase Statistics backend module, most useful for the +# zero-hit list. anonymizeIP masks the last two octets before storing (GDPR). +plugin.tx_solr.statistics = 1 +plugin.tx_solr.statistics.anonymizeIP = 2 + +# ============================================================================= +# Record indexing: custom domain models as their own search documents. +# The configuration name becomes the document `type` in the search JSON, so the +# frontend can facet/label results. Every type needs a resolvable `url` - +# a hit without a link is useless, hence the where-clauses below. +# NOT indexed (yet): solutions (no slug, no detail_page - nothing to link to), +# downloads/events/locations (no public detail pages), Datapath news (pid 442, +# import awaiting an editorial decision). +# ============================================================================= +plugin.tx_solr.index.queue { + + products = 1 + products { + type = tx_vitec_domain_model_product + 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 = || | + 40 = TEXT + 40.field = description2 + 40.noTrimWrap = || | + 50 = TEXT + 50.field = capabilities + 50.noTrimWrap = || | + 60 = TEXT + 60.field = highlights + 60.noTrimWrap = || | + 70 = TEXT + 70.field = applications + 70.noTrimWrap = || | + } + } + url = TEXT + url { + typolink { + parameter = 10 + additionalParams = &tx_vitec_productshow[product]={field:uid} + additionalParams.insertData = 1 + returnLast = url + } + } + } + } + + markets = 1 + markets { + type = tx_vitec_domain_model_market + # only markets with an editorially assigned detail page are linkable; + # grows automatically as editors fill the field (4 of 34 right now) + 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 + } + } + } + } + + usecases = 1 + usecases { + type = tx_vitec_domain_model_usecase + fields { + title = title + content = SOLR_CONTENT + content { + cObject = COA + cObject { + 10 = TEXT + 10.field = subtitle + 10.noTrimWrap = || | + 20 = TEXT + 20.field = teaser + 20.noTrimWrap = || | + } + } + url = TEXT + url { + typolink { + # page 26 carries the vitec_usecaseshow plugin; the route + # enhancer turns this into /success-stories/story/ + parameter = 26 + additionalParams = &tx_vitec_usecaseshow[usecase]={field:uid} + additionalParams.insertData = 1 + returnLast = url + } + } + } + } + + news = 1 + news { + type = tx_news_domain_model_news + # type 1 ("page as news") points at pages that are already indexed as + # pages - indexing them again would duplicate every one of them + additionalWhereClause = pid = 11 AND type = '0' + fields { + title = title + content = SOLR_CONTENT + content { + cObject = COA + cObject { + 10 = TEXT + 10.field = teaser + 10.noTrimWrap = || | + 20 = TEXT + 20.field = bodytext + 20.noTrimWrap = || | + } + } + url = TEXT + url { + typolink { + parameter = 21 + additionalParams = &tx_news_pi1[controller]=News&tx_news_pi1[action]=detail&tx_news_pi1[news]={field:uid} + additionalParams.insertData = 1 + returnLast = url + } + } + } + } +} + +# Products are what visitors of a product site are usually looking for - +# without this boost, decades of news articles dominate the first result page +# for product-ish terms like "encoder". Stories get a smaller nudge. +plugin.tx_solr.search.query.boostQuery ( + (type:tx_vitec_domain_model_product)^10.0 (type:tx_vitec_domain_model_usecase)^2.0 +) + +# Downloads have no detail page but a public file endpoint served by +# DownloadFileMiddleware. The where-clause keeps private / website-hidden +# downloads out of the public index (the middleware itself does not enforce +# private_download - the index must not advertise those files). +plugin.tx_solr.index.queue { + downloads = 1 + downloads { + type = tx_vitec_domain_model_download + additionalWhereClause = private_download = 0 AND hideonwebsite = 0 + fields { + title = title + content = SOLR_CONTENT + content { + cObject = COA + cObject { + 10 = TEXT + 10.field = teaser + 10.noTrimWrap = || | + 20 = TEXT + 20.field = keywords + 20.noTrimWrap = || | + 30 = TEXT + 30.field = description + 30.noTrimWrap = || | + } + } + url = TEXT + url.dataWrap = /download/{field:slug} + } + } +}