Added Downloads to search index.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -13,17 +13,21 @@ use TYPO3\CMS\Core\Http\Response;
|
||||
use TYPO3\CMS\Core\Http\Stream;
|
||||
|
||||
/**
|
||||
* Forced file download for download records: /download/file/<uid>
|
||||
* 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/<slug> - canonical, human-readable (search results, sharing)
|
||||
* /download/file/<uid> - 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,8 +35,14 @@ 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]);
|
||||
} elseif (preg_match('#^/download/([a-z0-9\\-]+)/?$#', $path, $matches) === 1) {
|
||||
$file = DownloadFileResolver::resolveBySlug($matches[1]);
|
||||
}
|
||||
|
||||
if ($file !== null) {
|
||||
$filename = str_replace(['"', "\r", "\n"], '', $file['name']);
|
||||
|
||||
@@ -46,7 +56,6 @@ final class DownloadFileMiddleware implements MiddlewareInterface
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// fall through to the regular pipeline
|
||||
}
|
||||
|
||||
@@ -51,6 +51,39 @@ final class DownloadFileResolver
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same contract as resolve(), addressed by the record slug - the pretty
|
||||
* /download/<slug> 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<string,mixed>|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()
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<slug>
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user