Rename bundle namespace and prepare release 0.6.0
This commit is contained in:
Executable
+6
@@ -0,0 +1,6 @@
|
||||
.idea/
|
||||
vendor/
|
||||
var/cache/
|
||||
var/log/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,73 @@
|
||||
# Contao Meilisearch Bundle
|
||||
|
||||
Eine schlanke Schnittstelle zwischen **Contao CMS (4.13 / 5.6 / 5.7 ready) unter PHP 8.4** und einer **selbst gehosteten Meilisearch-Instanz**.
|
||||
Das Bundle erweitert den Contao-Suchindex um strukturierte Daten und ermöglicht eine performante, moderne Volltextsuche.
|
||||
Das Parsen von Dateien erfolgt über eine Apache-Tika-Instanz, welche extern bereitgestellt werden muss.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Integration von **Meilisearch** als externe Suchmaschine
|
||||
- Indexierung von:
|
||||
- Contao-Seiten
|
||||
- Inhaltselementen
|
||||
- **PDF-Dateien**
|
||||
- **Office-Dokumenten** (DOCX, XLSX, PPTX)
|
||||
- Unterstützung für:
|
||||
- Seiten-Prioritäten
|
||||
- Keywords
|
||||
- Vorschaubild
|
||||
- Kompatibel mit:
|
||||
- Contao **4.13**, **5.6** und **5.7**
|
||||
- PHP **8.4**
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Scheduled Indexing (Cron setup)
|
||||
|
||||
Das Bundle stellt eigene Commands zur Verfügung, um Dateien zu bereinigen und den Meilisearch-Index neu aufzubauen.
|
||||
Für den produktiven Einsatz wird empfohlen, diese Commands regelmäßig per **System-Crontab** auszuführen.
|
||||
|
||||
Das Bundle nutzt **keinen eigenen Contao-Cron**, sondern System-Cronjobs.
|
||||
|
||||
## Verfügbare Commands
|
||||
|
||||
### Datei-Cleanup
|
||||
|
||||
```
|
||||
/vendor/bin/contao-console meilisearch:files:cleanup
|
||||
```
|
||||
|
||||
### Datei-Parsing
|
||||
|
||||
```
|
||||
/vendor/bin/contao-console meilisearch:files:parse
|
||||
```
|
||||
|
||||
### Meilisearch-Index
|
||||
|
||||
```
|
||||
/vendor/bin/contao-console meilisearch:index
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Beispiel Crontab
|
||||
|
||||
```
|
||||
0 5 * * * /usr/bin/php8.4 /path/to/project/vendor/bin/contao-console meilisearch:files:cleanup
|
||||
1 5 * * * /usr/bin/php8.4 /path/to/project/vendor/bin/contao-console contao:crawl
|
||||
10 5 * * * /usr/bin/php8.4 /path/to/project/vendor/bin/contao-console meilisearch:files:parse
|
||||
20 5 * * * /usr/bin/php8.4 /path/to/project/vendor/bin/contao-console meilisearch:index
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
```
|
||||
>> var/logs/meilisearch_cron.log 2>&1
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mummert/contao-meilisearch-bundle",
|
||||
"description": "Contao Meilisearch integration bundle",
|
||||
"type": "contao-bundle",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"contao/core-bundle": "^4.13 || ^5.6 || ^5.7",
|
||||
"contao/calendar-bundle": "^4.13 || ^5.6 || ^5.7",
|
||||
"contao/news-bundle": "^4.13 || ^5.6 || ^5.7",
|
||||
"meilisearch/meilisearch-php": "^1.16"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Mummert\\ContaoMeilisearchBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"contao-manager-plugin": "Mummert\\ContaoMeilisearchBundle\\ContaoManager\\Plugin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 56.69 56.69">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#Unbenannter_Verlauf);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="Unbenannter_Verlauf" data-name="Unbenannter Verlauf" x1="-3.39" y1="9.23" x2="60.08" y2="45.86" gradientTransform="translate(0 55.89) scale(1 -1)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#003837"/>
|
||||
<stop offset="1" stop-color="#065a51"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="favicon_mummert_media" data-name="favicon mummert media">
|
||||
<rect class="cls-1" width="56.69" height="56.69" rx="5.67" ry="5.67"/>
|
||||
<path id="m" class="cls-2" d="M42.1,22.13c-.81-.92-2.01-1.38-3.6-1.38-1.19,0-2.13.22-2.83.67-.7.44-1.32,1.01-1.85,1.7h-.05c-.32-.74-.83-1.33-1.54-1.74s-1.54-.62-2.5-.62-1.82.2-2.55.61-1.33.97-1.81,1.67h-.05v-1.97h-4.28v10.83h-11.94v3.15h16.32v-7.66c0-.6.09-1.12.28-1.57.19-.44.46-.78.84-1.02.37-.24.81-.36,1.3-.36.74,0,1.29.24,1.63.73s.52,1.19.52,2.11v7.76h4.36v-7.74c0-.57.09-1.07.28-1.51s.46-.78.84-1.01c.37-.23.81-.34,1.3-.34.76,0,1.32.24,1.66.73s.52,1.19.52,2.11v7.76h4.36v-9.28c0-1.51-.4-2.72-1.21-3.64h0Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
+100
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Command;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class MeilisearchFilesCleanupCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('meilisearch:files:cleanup')
|
||||
->setDescription('Remove stale indexed files from tl_search_files')
|
||||
->addOption(
|
||||
'grace',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Grace period in seconds (files newer than now-grace are kept)',
|
||||
86400
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show how many entries would be removed without deleting them'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->framework->initialize();
|
||||
|
||||
$this->log('Cleaner gestartet');
|
||||
|
||||
try {
|
||||
$grace = max(0, (int) $input->getOption('grace'));
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$cutoff = time() - $grace;
|
||||
|
||||
if ($dryRun) {
|
||||
$count = $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM tl_search_files WHERE last_seen < ?',
|
||||
[$cutoff]
|
||||
);
|
||||
|
||||
$message = sprintf(
|
||||
'[DRY-RUN] %d stale file(s) would be removed (last_seen < %s)',
|
||||
$count,
|
||||
date('Y-m-d H:i:s', $cutoff)
|
||||
);
|
||||
|
||||
$output->writeln('<comment>' . $message . '</comment>');
|
||||
$this->log($message);
|
||||
|
||||
$this->log('Cleaner stopped (dry-run)');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$affected = $this->connection->executeStatement(
|
||||
'DELETE FROM tl_search_files WHERE last_seen < ?',
|
||||
[$cutoff]
|
||||
);
|
||||
|
||||
$message = sprintf(
|
||||
'Removed %d stale file(s) (last_seen < %s)',
|
||||
$affected,
|
||||
date('Y-m-d H:i:s', $cutoff)
|
||||
);
|
||||
|
||||
$output->writeln('<info>' . $message . '</info>');
|
||||
$this->log($message);
|
||||
|
||||
$this->log('Cleaner successfully stopped');
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('Cleaner ERROR: ' . $e->getMessage());
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
error_log(sprintf('[%s] %s', date('Y-m-d H:i:s'), $message));
|
||||
}
|
||||
}
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Command;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\Database;
|
||||
use Contao\System;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
class MeilisearchFilesParseCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('meilisearch:files:parse')
|
||||
->setDescription('Parse indexed files via Apache Tika and enrich tl_search_files')
|
||||
->addOption(
|
||||
'limit',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Maximum number of files to check per run'
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Do not send files to Tika'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->framework->initialize();
|
||||
$this->log('Parser gestartet');
|
||||
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$limitOption = $input->getOption('limit');
|
||||
$limit = $limitOption !== null ? max(1, (int) $limitOption) : null;
|
||||
|
||||
$tikaUrl = rtrim((string) ($GLOBALS['TL_CONFIG']['meilisearch_tika_url'] ?? ''), '/');
|
||||
if ($tikaUrl === '') {
|
||||
$output->writeln('<error>Tika URL not configured</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$sql = "SELECT * FROM tl_search_files ORDER BY tstamp ASC";
|
||||
if ($limit !== null) {
|
||||
$sql .= " LIMIT " . (int) $limit;
|
||||
}
|
||||
|
||||
$files = $db->query($sql)->fetchAllAssoc();
|
||||
|
||||
if (!$files) {
|
||||
$this->log('No files to parse');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$client = HttpClient::create([
|
||||
'timeout' => 180,
|
||||
]);
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
||||
$originalUrl = (string) $file['url'];
|
||||
$existingTitle = trim((string) ($file['title'] ?? ''));
|
||||
$normalized = $originalUrl;
|
||||
|
||||
// -------------------------------------------------
|
||||
// Normalize URL
|
||||
// -------------------------------------------------
|
||||
if (str_contains($normalized, '?')) {
|
||||
$parts = parse_url($normalized);
|
||||
if (!empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
if (!empty($query['file'])) {
|
||||
$normalized = (string) $query['file'];
|
||||
} else {
|
||||
$this->log('Not a direct file url, skip', ['url' => $originalUrl]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = strtok($normalized, '#');
|
||||
$normalized = rawurldecode($normalized);
|
||||
$normalized = ltrim($normalized, '/');
|
||||
|
||||
if (!str_starts_with($normalized, 'files/')) {
|
||||
$this->log('Not in files/, skip', ['url' => $originalUrl]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$root = defined('TL_ROOT')
|
||||
? TL_ROOT
|
||||
: System::getContainer()->getParameter('kernel.project_dir') . '/public';
|
||||
|
||||
$absolutePath = $root . '/' . $normalized;
|
||||
|
||||
if (!is_file($absolutePath)) {
|
||||
$this->log('File missing, skip', [
|
||||
'url' => $originalUrl,
|
||||
'path' => $absolutePath,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$mtime = filemtime($absolutePath) ?: 0;
|
||||
$checksum = md5($normalized . '|' . $mtime);
|
||||
|
||||
// -------------------------------------------------
|
||||
// Skip unchanged
|
||||
// -------------------------------------------------
|
||||
if ($file['checksum'] === $checksum && !empty($file['text'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('[DRY-RUN] Would parse: ' . $normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// MIME-Type
|
||||
// -------------------------------------------------
|
||||
$ext = strtolower(pathinfo($normalized, PATHINFO_EXTENSION));
|
||||
|
||||
$mimeType = match ($ext) {
|
||||
'pdf' => 'application/pdf',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($mimeType === null) {
|
||||
$this->log('Unsupported file type, skip', ['url' => $normalized]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Tika BODY (roher Plaintext)
|
||||
// -------------------------------------------------
|
||||
try {
|
||||
$this->log('Parsing file', ['url' => $normalized]);
|
||||
|
||||
$bodyResponse = $client->request(
|
||||
'PUT',
|
||||
$tikaUrl . '/tika/main',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'text/plain',
|
||||
'Content-Type' => $mimeType,
|
||||
],
|
||||
'body' => fopen($absolutePath, 'rb'),
|
||||
]
|
||||
);
|
||||
|
||||
$text = trim((string) $bodyResponse->getContent(false));
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('Body parse failed', [
|
||||
'url' => $normalized,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// TITLE: keep existing editor-defined title
|
||||
// -------------------------------------------------
|
||||
$title = $existingTitle !== '' ? $existingTitle : null;
|
||||
|
||||
// -------------------------------------------------
|
||||
// Tika METADATA (Title) – only if no existing title
|
||||
// -------------------------------------------------
|
||||
if ($title === null) {
|
||||
try {
|
||||
$metaResponse = $client->request(
|
||||
'PUT',
|
||||
$tikaUrl . '/meta',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => $mimeType,
|
||||
],
|
||||
'body' => fopen($absolutePath, 'rb'),
|
||||
]
|
||||
);
|
||||
|
||||
$meta = json_decode($metaResponse->getContent(false), true);
|
||||
|
||||
$rawTitle =
|
||||
$meta['dc:title'][0]
|
||||
?? $meta['pdf:docinfo:title'][0]
|
||||
?? null;
|
||||
|
||||
if ($rawTitle) {
|
||||
$title = html_entity_decode(
|
||||
$rawTitle,
|
||||
ENT_QUOTES | ENT_HTML5,
|
||||
'UTF-8'
|
||||
);
|
||||
}
|
||||
|
||||
} catch (\Throwable) {
|
||||
// Metadata optional
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// TITLE → ASCII SAFE (only if newly generated)
|
||||
// -------------------------------------------------
|
||||
if ($existingTitle === '' && $title) {
|
||||
$title = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $title);
|
||||
$title = preg_replace('/\s+/', ' ', $title);
|
||||
$title = trim($title);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// FALLBACK: Dateiname (only if still empty)
|
||||
// -------------------------------------------------
|
||||
if (!$title || strlen($title) < 5) {
|
||||
$title = pathinfo($normalized, PATHINFO_FILENAME);
|
||||
$title = str_replace(['_', '-'], ' ', $title);
|
||||
$title = preg_replace('/\s+/', ' ', $title);
|
||||
$title = trim($title);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Store result
|
||||
// -------------------------------------------------
|
||||
$db->prepare(
|
||||
"UPDATE tl_search_files
|
||||
SET text = ?, title = ?, checksum = ?, file_mtime = ?, tstamp = ?
|
||||
WHERE id = ?"
|
||||
)->execute(
|
||||
$text,
|
||||
$title,
|
||||
$checksum,
|
||||
$mtime,
|
||||
time(),
|
||||
$file['id']
|
||||
);
|
||||
|
||||
$this->log('File parsed', [
|
||||
'url' => $normalized,
|
||||
'chars' => mb_strlen($text),
|
||||
'title' => $title,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->log('Parser finished');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function log(string $message, array $context = []): void
|
||||
{
|
||||
$ctx = $context
|
||||
? ' | ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
: '';
|
||||
|
||||
error_log('[MeilisearchFilesParse] ' . $message . $ctx);
|
||||
}
|
||||
}
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Command;
|
||||
|
||||
use Mummert\ContaoMeilisearchBundle\Service\MeilisearchIndexService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class MeilisearchIndexCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MeilisearchIndexService $indexService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('meilisearch:index')
|
||||
->setDescription('Rebuild Meilisearch index from Contao search tables');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->log('Meilisearch index gestartet');
|
||||
$output->writeln('<info>Meilisearch index started</info>');
|
||||
|
||||
try {
|
||||
$this->indexService->run();
|
||||
|
||||
$this->log('Meilisearch index successfully stopped');
|
||||
$output->writeln('<info>Meilisearch index finished</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('Meilisearch index ERROR: ' . $e->getMessage());
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einheitliches Logging mit Zeitstempel
|
||||
*/
|
||||
private function log(string $message): void
|
||||
{
|
||||
error_log(sprintf(
|
||||
'[%s] %s',
|
||||
date('Y-m-d H:i:s'),
|
||||
$message
|
||||
));
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\ContaoManager;
|
||||
|
||||
use Contao\CalendarBundle\ContaoCalendarBundle;
|
||||
use Contao\CoreBundle\ContaoCoreBundle;
|
||||
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
|
||||
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
|
||||
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
|
||||
use Contao\NewsBundle\ContaoNewsBundle;
|
||||
use Mummert\ContaoMeilisearchBundle\ContaoMeilisearchBundle;
|
||||
|
||||
class Plugin implements BundlePluginInterface
|
||||
{
|
||||
/**
|
||||
* @return BundleConfig[]
|
||||
*/
|
||||
public function getBundles(ParserInterface $parser)
|
||||
{
|
||||
return [
|
||||
BundleConfig::create(ContaoMeilisearchBundle::class)
|
||||
->setLoadAfter([
|
||||
ContaoCoreBundle::class,
|
||||
ContaoCalendarBundle::class,
|
||||
ContaoNewsBundle::class,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class ContaoMeilisearchBundle extends Bundle
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Controller\FrontendModule;
|
||||
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||
use Contao\ModuleModel;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MeilisearchSearchController extends AbstractFrontendModuleController
|
||||
{
|
||||
protected function getResponse(
|
||||
$template,
|
||||
ModuleModel $model,
|
||||
Request $request
|
||||
): Response {
|
||||
// Beide Template-Typen unterstützen Property-Zugriff
|
||||
$template->meiliLimit = (int) ($model->meiliLimit ?: 50);
|
||||
$template->meiliHost = Config::get('meilisearch_host');
|
||||
$template->meiliIndex = Config::get('meilisearch_index');
|
||||
$template->meiliSearchKey = Config::get('meilisearch_api_search');
|
||||
|
||||
return $template->getResponse();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
|
||||
|
||||
class ContaoMeilisearchExtension extends Extension
|
||||
{
|
||||
public function load(array $configs, ContainerBuilder $container): void
|
||||
{
|
||||
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
|
||||
$loader->load('services.yaml');
|
||||
}
|
||||
}
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\EventListener;
|
||||
|
||||
use Contao\Config;
|
||||
use Contao\System;
|
||||
use Mummert\ContaoMeilisearchBundle\Service\MeilisearchFileHelper;
|
||||
|
||||
class IndexPageListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MeilisearchFileHelper $fileHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
private function debug(string $message, array $context = []): void
|
||||
{
|
||||
$ctx = $context ? ' | ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : '';
|
||||
error_log('[ContaoMeilisearch][IndexPageListener] ' . $message . $ctx);
|
||||
}
|
||||
|
||||
public function onIndexPage(string $content, array &$data, array &$set): void
|
||||
{
|
||||
$this->debug('Hook start', [
|
||||
'url' => $data['url'] ?? null,
|
||||
'protected' => $data['protected'] ?? null,
|
||||
'checksum' => $data['checksum'] ?? null,
|
||||
'set_keys' => array_keys($set),
|
||||
]);
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* SEITEN-METADATEN
|
||||
* =====================
|
||||
*/
|
||||
$hasMeta = str_contains($content, 'MEILISEARCH_JSON');
|
||||
$this->debug('Meta marker scan', [
|
||||
'contains_MEILISEARCH_JSON' => $hasMeta,
|
||||
'content_length' => strlen($content),
|
||||
]);
|
||||
|
||||
if ($hasMeta) {
|
||||
try {
|
||||
$parsed = $this->extractMeilisearchJson($content);
|
||||
$this->debug('extractMeilisearchJson(): done', [
|
||||
'parsed_is_array' => is_array($parsed),
|
||||
'parsed_keys' => is_array($parsed) ? array_keys($parsed) : null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->debug('Failed to extract MEILISEARCH_JSON', [
|
||||
'error' => $e->getMessage(),
|
||||
'class' => $e::class,
|
||||
]);
|
||||
$parsed = null;
|
||||
}
|
||||
|
||||
if (is_array($parsed)) {
|
||||
|
||||
// PRIORITY
|
||||
$priority =
|
||||
$parsed['event']['priority']
|
||||
?? $parsed['news']['priority']
|
||||
?? $parsed['page']['priority']
|
||||
?? null;
|
||||
|
||||
$this->debug('Meta: priority candidate', ['priority' => $priority]);
|
||||
|
||||
if ($priority !== null && $priority !== '') {
|
||||
$set['priority'] = (int) $priority;
|
||||
}
|
||||
|
||||
// KEYWORDS
|
||||
$keywordSources = [
|
||||
$parsed['event']['keywords'] ?? null,
|
||||
$parsed['news']['keywords'] ?? null,
|
||||
$parsed['page']['keywords'] ?? null,
|
||||
];
|
||||
|
||||
$keywords = [];
|
||||
foreach ($keywordSources as $src) {
|
||||
if (!is_string($src) || trim($src) === '') {
|
||||
continue;
|
||||
}
|
||||
foreach (preg_split('/\s+/', trim($src)) as $word) {
|
||||
$keywords[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
if ($keywords) {
|
||||
$set['keywords'] = implode(' ', array_unique($keywords));
|
||||
}
|
||||
|
||||
// IMAGEPATH
|
||||
if (!empty($parsed['page']['searchimage'] ?? null)) {
|
||||
$set['imagepath'] = trim((string) $parsed['page']['searchimage']);
|
||||
}
|
||||
|
||||
// STARTDATE
|
||||
if (is_numeric($parsed['event']['startDate'] ?? null)) {
|
||||
$set['startDate'] = (int) $parsed['event']['startDate'];
|
||||
}
|
||||
|
||||
// CHECKSUM
|
||||
$checksumSeed = (string) ($data['checksum'] ?? '');
|
||||
$checksumSeed .= '|' . ($set['keywords'] ?? '');
|
||||
$checksumSeed .= '|' . ($set['priority'] ?? '');
|
||||
$checksumSeed .= '|' . ($set['imagepath'] ?? '');
|
||||
$checksumSeed .= '|' . ($set['startDate'] ?? '');
|
||||
|
||||
$set['checksum'] = md5($checksumSeed);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* DATEI-ERKENNUNG (NUR ERKENNUNG!)
|
||||
* =====================
|
||||
*/
|
||||
if ((int) ($data['protected'] ?? 0) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Config::get('meilisearch_index_files')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$links = $this->findAllLinks($content);
|
||||
$fileLinks = [];
|
||||
|
||||
foreach ($links as $link) {
|
||||
$type = $this->detectIndexableFileType($link['url']);
|
||||
if ($type !== null) {
|
||||
$fileLinks[] = $link + ['type' => $type];
|
||||
}
|
||||
}
|
||||
|
||||
$this->debug('Indexable file links found', [
|
||||
'count' => count($fileLinks),
|
||||
]);
|
||||
|
||||
if ($fileLinks) {
|
||||
foreach ($fileLinks as $file) {
|
||||
$this->fileHelper->collect(
|
||||
$file['url'],
|
||||
$file['type'],
|
||||
(int) ($data['pid'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->debug('Hook end', [
|
||||
'final_set_keys' => array_keys($set),
|
||||
]);
|
||||
}
|
||||
|
||||
/* === Hilfsmethoden unverändert === */
|
||||
|
||||
private function extractMeilisearchJson(string $content): ?array
|
||||
{
|
||||
if (!preg_match('/<!--\s*MEILISEARCH_JSON\s*(\{.*?\})\s*-->/s', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = preg_replace('/^\xEF\xBB\xBF/', '', trim($m[1]));
|
||||
$data = json_decode($json, true);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE && is_array($data)
|
||||
? $data
|
||||
: null;
|
||||
}
|
||||
|
||||
private function findAllLinks(string $content): array
|
||||
{
|
||||
if (!preg_match_all(
|
||||
'/<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is',
|
||||
$content,
|
||||
$matches
|
||||
)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($matches[1] as $i => $href) {
|
||||
$result[] = [
|
||||
'url' => html_entity_decode($href),
|
||||
'linkText' => trim(strip_tags($matches[2][$i])) ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function detectIndexableFileType(string $url): ?string
|
||||
{
|
||||
$url = strtok($url, '#');
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (!empty($parts['path'])) {
|
||||
$ext = strtolower(pathinfo($parts['path'], PATHINFO_EXTENSION));
|
||||
if (in_array($ext, ['pdf', 'docx', 'xlsx', 'pptx'], true)) {
|
||||
return $ext;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
|
||||
foreach (['file', 'p', 'f'] as $param) {
|
||||
if (!empty($query[$param])) {
|
||||
$candidate = rawurldecode(html_entity_decode((string) $query[$param], ENT_QUOTES));
|
||||
$ext = strtolower(pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
|
||||
if (in_array($ext, ['pdf', 'docx', 'xlsx', 'pptx'], true)) {
|
||||
return $ext;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\EventListener;
|
||||
|
||||
use Contao\CalendarEventsModel;
|
||||
use Contao\Config;
|
||||
use Contao\NewsModel;
|
||||
use Contao\PageModel;
|
||||
use Contao\StringUtil;
|
||||
|
||||
class MeilisearchPageMarkerListener
|
||||
{
|
||||
public function onOutputFrontendTemplate(string $buffer, string $template): string
|
||||
{
|
||||
if (!in_array($template, ['fe_page', 'fe_page_indexing'], true)) {
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
// ⛔ Marker bereits vorhanden → nichts mehr tun
|
||||
if (str_contains($buffer, '⟦MEILISEARCH_META⟧')
|
||||
|| str_contains($buffer, 'MEILISEARCH_JSON')
|
||||
) {
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* CONTENT-BILD (TOP PRIORITÄT)
|
||||
* =====================
|
||||
*/
|
||||
$contentImageUuid = null;
|
||||
|
||||
if (preg_match('#<main\b[^>]*>(.*?)</main>#si', $buffer, $m)) {
|
||||
$mainHtml = $m[1];
|
||||
|
||||
if (preg_match(
|
||||
'#meilisearch-uuid=["\']([a-f0-9-]{36})["\']#i',
|
||||
$mainHtml,
|
||||
$mm
|
||||
)) {
|
||||
$contentImageUuid = $mm[1];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* KEYWORDS AUS FRONTEND (Catalog Manager)
|
||||
* =====================
|
||||
*/
|
||||
$frontendKeywords = [];
|
||||
|
||||
if (preg_match(
|
||||
'#<div[^>]+id=["\']keywords["\'][^>]+meilisearch-keywords=["\']([^"\']+)["\']#i',
|
||||
$buffer,
|
||||
$m
|
||||
)) {
|
||||
$frontendKeywords = preg_split('/\s+/', trim($m[1]));
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* PAGE (Basisdaten)
|
||||
* =====================
|
||||
*/
|
||||
$pageImageUuid = null;
|
||||
|
||||
if (isset($GLOBALS['objPage']) && $GLOBALS['objPage'] instanceof PageModel) {
|
||||
$page = $GLOBALS['objPage'];
|
||||
|
||||
$data['page'] = [];
|
||||
|
||||
if (!empty($page->priority)) {
|
||||
$data['page']['priority'] = (int) $page->priority;
|
||||
}
|
||||
|
||||
if (!empty($page->keywords)) {
|
||||
$data['page']['keywords'] = trim((string) $page->keywords);
|
||||
}
|
||||
|
||||
if (!empty($page->searchimage)) {
|
||||
$raw = (string) $page->searchimage;
|
||||
|
||||
if (preg_match('/^[a-f0-9-]{36}$/i', $raw)) {
|
||||
$pageImageUuid = $raw;
|
||||
} else {
|
||||
try {
|
||||
$pageImageUuid = StringUtil::binToUuid($raw);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* JSON-LD AUSWERTEN
|
||||
* =====================
|
||||
*/
|
||||
preg_match_all(
|
||||
'#<script type="application/ld\+json">\s*(.*?)\s*</script>#s',
|
||||
$buffer,
|
||||
$matches
|
||||
);
|
||||
|
||||
foreach ($matches[1] as $jsonRaw) {
|
||||
$json = json_decode($jsonRaw, true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$graph = $json['@graph'] ?? [];
|
||||
if (!is_array($graph)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($graph as $entry) {
|
||||
|
||||
/*
|
||||
* EVENT
|
||||
*/
|
||||
if (($entry['@type'] ?? null) === 'Event' && !empty($entry['@id'])) {
|
||||
if (preg_match('#/schema/events/(\d+)#', $entry['@id'], $m)) {
|
||||
$event = CalendarEventsModel::findByPk((int) $m[1]);
|
||||
|
||||
if ($event !== null) {
|
||||
$data['event'] = [];
|
||||
|
||||
if (!empty($event->priority)) {
|
||||
$data['event']['priority'] = (int) $event->priority;
|
||||
}
|
||||
|
||||
if (!empty($event->keywords)) {
|
||||
$data['event']['keywords'] = trim((string) $event->keywords);
|
||||
}
|
||||
|
||||
if ($event->addImage && !empty($event->singleSRC)) {
|
||||
$data['event']['searchimage'] = StringUtil::binToUuid($event->singleSRC);
|
||||
}
|
||||
|
||||
if (!empty($event->startDate)) {
|
||||
$data['event']['startDate'] = (int) $event->startDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* NEWS
|
||||
*/
|
||||
if (($entry['@type'] ?? null) === 'NewsArticle' && !empty($entry['@id'])) {
|
||||
if (preg_match('#/schema/news/(\d+)#', $entry['@id'], $m)) {
|
||||
$news = NewsModel::findByPk((int) $m[1]);
|
||||
|
||||
if ($news !== null) {
|
||||
$data['news'] = [];
|
||||
|
||||
if (!empty($news->priority)) {
|
||||
$data['news']['priority'] = (int) $news->priority;
|
||||
}
|
||||
|
||||
if (!empty($news->keywords)) {
|
||||
$data['news']['keywords'] = trim((string) $news->keywords);
|
||||
}
|
||||
|
||||
if ($news->addImage && !empty($news->singleSRC)) {
|
||||
$data['news']['searchimage'] = StringUtil::binToUuid($news->singleSRC);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* KEYWORDS ZUSAMMENFÜHREN
|
||||
* =====================
|
||||
*/
|
||||
$allKeywords = [];
|
||||
|
||||
if (!empty($data['page']['keywords'])) {
|
||||
$allKeywords = array_merge(
|
||||
$allKeywords,
|
||||
preg_split('/\s+/', $data['page']['keywords'])
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['event']['keywords'])) {
|
||||
$allKeywords = array_merge(
|
||||
$allKeywords,
|
||||
preg_split('/\s+/', $data['event']['keywords'])
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['news']['keywords'])) {
|
||||
$allKeywords = array_merge(
|
||||
$allKeywords,
|
||||
preg_split('/\s+/', $data['news']['keywords'])
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($frontendKeywords)) {
|
||||
$allKeywords = array_merge($allKeywords, $frontendKeywords);
|
||||
}
|
||||
|
||||
$allKeywords = array_unique(
|
||||
array_filter(
|
||||
array_map('trim', $allKeywords)
|
||||
)
|
||||
);
|
||||
|
||||
if ($allKeywords !== []) {
|
||||
$data['page']['keywords'] = implode(' ', $allKeywords);
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* FINALE SEARCHIMAGE-ENTSCHEIDUNG
|
||||
* =====================
|
||||
*/
|
||||
$finalSearchImageUuid = null;
|
||||
|
||||
if ($contentImageUuid !== null) {
|
||||
$finalSearchImageUuid = $contentImageUuid;
|
||||
}
|
||||
elseif (!empty($data['event']['searchimage'])) {
|
||||
$finalSearchImageUuid = $data['event']['searchimage'];
|
||||
}
|
||||
elseif (!empty($data['news']['searchimage'])) {
|
||||
$finalSearchImageUuid = $data['news']['searchimage'];
|
||||
}
|
||||
elseif ($pageImageUuid) {
|
||||
$finalSearchImageUuid = $pageImageUuid;
|
||||
}
|
||||
else {
|
||||
$fallback = Config::get('meilisearch_fallback_image');
|
||||
if ($fallback) {
|
||||
$finalSearchImageUuid = $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
if ($finalSearchImageUuid !== null) {
|
||||
$data['page'] ??= [];
|
||||
$data['page']['searchimage'] = $finalSearchImageUuid;
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
/*
|
||||
* =====================
|
||||
* META-SPAN
|
||||
* =====================
|
||||
*/
|
||||
$metaParts = [];
|
||||
|
||||
if (!empty($data['page']['priority'])) {
|
||||
$metaParts[] = 'page_priority=' . $data['page']['priority'];
|
||||
}
|
||||
if (!empty($data['page']['keywords'])) {
|
||||
$metaParts[] = 'page_keywords=' . $data['page']['keywords'];
|
||||
}
|
||||
if (!empty($data['page']['searchimage'])) {
|
||||
$metaParts[] = 'page_searchimage=' . $data['page']['searchimage'];
|
||||
}
|
||||
if ($contentImageUuid) {
|
||||
$metaParts[] = 'content_searchimage=' . $contentImageUuid;
|
||||
}
|
||||
if (!empty($data['event']['startDate'])) {
|
||||
$metaParts[] = 'event_startDate=' . $data['event']['startDate'];
|
||||
}
|
||||
|
||||
$hiddenMeta =
|
||||
"\n<span class=\"meilisearch-meta\" style=\"display:none !important\">" .
|
||||
"⟦MEILISEARCH_META⟧ " .
|
||||
htmlspecialchars(implode(' | ', $metaParts), ENT_QUOTES) .
|
||||
" ⟦/MEILISEARCH_META⟧" .
|
||||
"</span>\n";
|
||||
|
||||
$marker =
|
||||
"\n<!--\nMEILISEARCH_JSON\n" .
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) .
|
||||
"\n-->\n";
|
||||
|
||||
$injection = $hiddenMeta . $marker;
|
||||
|
||||
return str_contains($buffer, '</main>')
|
||||
? str_replace('</main>', $injection . '</main>', $buffer)
|
||||
: $buffer . $injection;
|
||||
}
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
# Alias MUSS vorhanden sein (richtig platziert)
|
||||
Psr\Container\ContainerInterface: '@service_container'
|
||||
|
||||
Mummert\ContaoMeilisearchBundle\:
|
||||
resource: '../../{Command,EventListener,Service}'
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Mummert\ContaoMeilisearchBundle\EventListener\MeilisearchPageMarkerListener:
|
||||
autowire: true
|
||||
autoconfigure: false
|
||||
tags:
|
||||
- { name: contao.hook, hook: outputFrontendTemplate, method: onOutputFrontendTemplate }
|
||||
|
||||
Mummert\ContaoMeilisearchBundle\EventListener\IndexPageListener:
|
||||
autowire: true
|
||||
autoconfigure: false
|
||||
tags:
|
||||
- { name: contao.hook, hook: indexPage, method: onIndexPage }
|
||||
|
||||
Mummert\ContaoMeilisearchBundle\Controller\FrontendModule\MeilisearchSearchController:
|
||||
autowire: true
|
||||
autoconfigure: false
|
||||
tags:
|
||||
- { name: contao.frontend_module, type: meilisearch_search, category: search }
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Mummert\ContaoMeilisearchBundle\EventListener\MeilisearchPageMarkerListener;
|
||||
|
||||
|
||||
$GLOBALS['TL_HOOKS']['outputFrontendTemplate'][] = [
|
||||
MeilisearchPageMarkerListener::class,
|
||||
'onOutputFrontendTemplate',
|
||||
];
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
|
||||
/**
|
||||
* Palettes
|
||||
*/
|
||||
PaletteManipulator::create()
|
||||
->addLegend('meilisearch_legend', 'pal_expert_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addField('priority', 'meilisearch_legend')
|
||||
->addField('keywords', 'meilisearch_legend')
|
||||
->applyToPalette('default', 'tl_calendar_events');
|
||||
|
||||
/**
|
||||
* Priority
|
||||
*/
|
||||
$dca['fields']['priority'] = [
|
||||
'inputType' => 'select',
|
||||
'options' => [1, 2, 3],
|
||||
'reference' => &$GLOBALS['TL_LANG']['MSC']['meilisearch_priority'],
|
||||
'default' => 2,
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => "int(1) NOT NULL default '2'"
|
||||
];
|
||||
|
||||
/**
|
||||
* Keywords
|
||||
*/
|
||||
$dca['fields']['keywords'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['tl_class' => 'w50', 'maxlength' => 255],
|
||||
'sql' => "varchar(255) NOT NULL default ''"
|
||||
];
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_module']['palettes']['meilisearch_search'] =
|
||||
'{title_legend},name,type;
|
||||
{search_legend},meiliLimit;
|
||||
{protected_legend:hide},protected;
|
||||
{expert_legend:hide},cssID';
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_module']['fields']['meiliLimit'] = [
|
||||
'label' => &$GLOBALS['TL_LANG']['tl_module']['meiliLimit'],
|
||||
'inputType' => 'text',
|
||||
'default' => 50,
|
||||
'eval' => [
|
||||
'rgxp' => 'digit',
|
||||
'mandatory' => true,
|
||||
'tl_class' => 'w50',
|
||||
],
|
||||
'sql' => "int(10) unsigned NOT NULL default 50",
|
||||
];
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_news'];
|
||||
|
||||
PaletteManipulator::create()
|
||||
->addLegend('meilisearch_legend', 'pal_expert_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addField('priority', 'meilisearch_legend')
|
||||
->addField('keywords', 'meilisearch_legend')
|
||||
->applyToPalette('default', 'tl_news');
|
||||
|
||||
/**
|
||||
* Priority
|
||||
*/
|
||||
$dca['fields']['priority'] = [
|
||||
'inputType' => 'select',
|
||||
'options' => [1, 2, 3],
|
||||
'reference' => &$GLOBALS['TL_LANG']['MSC']['meilisearch_priority'],
|
||||
'default' => 2,
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => "int(1) NOT NULL default '2'"
|
||||
];
|
||||
|
||||
/**
|
||||
* Keywords
|
||||
*/
|
||||
$dca['fields']['keywords'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['tl_class' => 'w50', 'maxlength' => 255],
|
||||
'sql' => "varchar(255) NOT NULL default ''"
|
||||
];
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
|
||||
$dca = &$GLOBALS['TL_DCA']['tl_page'];
|
||||
|
||||
PaletteManipulator::create()
|
||||
->addLegend('meilisearch_legend', 'pal_expert_legend', PaletteManipulator::POSITION_AFTER)
|
||||
->addField('priority', 'meilisearch_legend')
|
||||
->addField('keywords', 'meilisearch_legend')
|
||||
->addField('searchimage', 'meilisearch_legend')
|
||||
->applyToPalette('regular', 'tl_page');
|
||||
|
||||
/**
|
||||
* Priority
|
||||
*/
|
||||
$dca['fields']['priority'] = [
|
||||
'inputType' => 'select',
|
||||
'options' => [1, 2, 3],
|
||||
'reference' => &$GLOBALS['TL_LANG']['MSC']['meilisearch_priority'],
|
||||
'default' => 2,
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => "int(1) NOT NULL default '2'"
|
||||
];
|
||||
|
||||
/**
|
||||
* Keywords
|
||||
*/
|
||||
$dca['fields']['keywords'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => ['tl_class' => 'w50', 'maxlength' => 255],
|
||||
'sql' => "varchar(255) NOT NULL default ''"
|
||||
];
|
||||
|
||||
/**
|
||||
* Search image
|
||||
*/
|
||||
$dca['fields']['searchimage'] = [
|
||||
'inputType' => 'fileTree',
|
||||
'eval' => [
|
||||
'tl_class' => 'w50',
|
||||
'filesOnly' => true,
|
||||
'fieldType' => 'radio'
|
||||
],
|
||||
'sql' => "varbinary(16) NULL"
|
||||
];
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_search']['fields']['keywords'] = [
|
||||
'label' => ['Keywords', 'Suchbegriffe für die Indexierung'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['tl_class' => 'w50', 'maxlength' => 255],
|
||||
'sql' => "varchar(255) NOT NULL default ''",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_search']['fields']['priority'] = [
|
||||
'label' => ['Priorität', 'Priorität für die Suchergebnisse'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'select',
|
||||
'options' => [1, 2, 3],
|
||||
'eval' => ['tl_class' => 'w50'],
|
||||
'sql' => "int(1) NOT NULL default '2'",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_search']['fields']['imagepath'] = [
|
||||
'label' => ['Suchbild', 'UUID des Suchbildes'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['maxlength' => 512],
|
||||
'sql' => "varchar(512) NOT NULL default ''",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_search']['fields']['startDate'] = [
|
||||
'label' => ['Startdatum', 'Startdatum für die Suchergebnisse (Unix-Timestamp)'],
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['tl_class' => 'w50', 'rgxp' => 'digit'],
|
||||
'sql' => "bigint(20) NOT NULL default '0'",
|
||||
];
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use Contao\DC_Table;
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_search_files'] = [
|
||||
'config' => [
|
||||
'dataContainer' => DC_Table::class,
|
||||
'sql' => [
|
||||
'keys' => [
|
||||
'id' => 'primary',
|
||||
'page_id' => 'index',
|
||||
'url' => 'unique',
|
||||
'type' => 'index',
|
||||
'checksum' => 'index',
|
||||
'uuid' => 'index',
|
||||
'last_seen' => 'index',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'fields' => [
|
||||
'id' => [
|
||||
'sql' => "int(10) unsigned NOT NULL auto_increment",
|
||||
],
|
||||
|
||||
'tstamp' => [
|
||||
'sql' => "int(10) unsigned NOT NULL default 0",
|
||||
],
|
||||
|
||||
/*
|
||||
* Zeitpunkt, wann die Datei zuletzt beim Crawl gesehen wurde
|
||||
* → Basis für Cleanup
|
||||
*/
|
||||
'last_seen' => [ // ⬅️ NEU
|
||||
'sql' => "int(10) unsigned NOT NULL default 0",
|
||||
],
|
||||
|
||||
/*
|
||||
* Dateityp: pdf | docx | xlsx | pptx
|
||||
*/
|
||||
'type' => [
|
||||
'sql' => "varchar(16) NOT NULL default 'pdf'",
|
||||
],
|
||||
|
||||
/*
|
||||
* Absolute oder normalisierte Datei-URL
|
||||
* z. B. /files/pdf/foo.pdf
|
||||
*/
|
||||
'url' => [
|
||||
'sql' => "varchar(1024) NOT NULL default ''",
|
||||
],
|
||||
|
||||
/*
|
||||
* Linktext oder Dateiname
|
||||
*/
|
||||
'title' => [
|
||||
'sql' => "varchar(255) NOT NULL default ''",
|
||||
],
|
||||
|
||||
/*
|
||||
* Geparster Datei-Text (PDF / Office)
|
||||
*/
|
||||
'text' => [
|
||||
'sql' => "mediumtext NULL",
|
||||
],
|
||||
|
||||
'uuid' => [
|
||||
'sql' => "binary(16) NULL",
|
||||
],
|
||||
|
||||
/*
|
||||
* md5(url + filemtime)
|
||||
* → erkennt Änderungen zuverlässig
|
||||
*/
|
||||
'checksum' => [
|
||||
'sql' => "char(32) NOT NULL default ''",
|
||||
],
|
||||
|
||||
/*
|
||||
* Herkunftsseite (tl_page.id)
|
||||
* → optional, Debug / Referenz
|
||||
*/
|
||||
'page_id' => [
|
||||
'sql' => "int(10) unsigned NOT NULL default 0",
|
||||
],
|
||||
|
||||
/*
|
||||
* Dateizeitstempel
|
||||
* → wichtig für Re-Indexierung
|
||||
*/
|
||||
'file_mtime' => [
|
||||
'sql' => "int(10) unsigned NOT NULL default 0",
|
||||
],
|
||||
],
|
||||
];
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||
use Contao\System;
|
||||
|
||||
/**
|
||||
* -------------------------------------------------
|
||||
* Fields
|
||||
* -------------------------------------------------
|
||||
*/
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_host'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => [
|
||||
'mandatory' => true,
|
||||
'rgxp' => 'url',
|
||||
'tl_class' => 'w50',
|
||||
],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_index'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => [
|
||||
'mandatory' => true,
|
||||
'tl_class' => 'w50',
|
||||
],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_api_write'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => [
|
||||
'mandatory' => true,
|
||||
'tl_class' => 'w50',
|
||||
'hideInput' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_api_search'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => [
|
||||
'mandatory' => true,
|
||||
'tl_class' => 'w50',
|
||||
'hideInput' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_imagesize'] = [
|
||||
'inputType' => 'select',
|
||||
'options_callback' => static function () {
|
||||
$db = System::getContainer()->get('database_connection');
|
||||
$rows = $db->fetchAllAssociative('SELECT id, name FROM tl_image_size ORDER BY name');
|
||||
|
||||
$options = ['' => '-'];
|
||||
foreach ($rows as $row) {
|
||||
$options[$row['id']] = $row['name'] . ' (ID ' . $row['id'] . ')';
|
||||
}
|
||||
|
||||
return $options;
|
||||
},
|
||||
'eval' => [
|
||||
'tl_class' => 'w50',
|
||||
'chosen' => true,
|
||||
'includeBlankOption' => true,
|
||||
],
|
||||
'sql' => "int(10) unsigned NOT NULL default 0",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_fallback_image'] = [
|
||||
'inputType' => 'fileTree',
|
||||
'eval' => [
|
||||
'filesOnly' => true,
|
||||
'fieldType' => 'radio',
|
||||
'tl_class' => 'w50',
|
||||
],
|
||||
'sql' => "varbinary(16) NULL",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_index_past_events'] = [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => [
|
||||
'tl_class' => 'w50 clr',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------
|
||||
* Datei-Indexierung (Tika)
|
||||
* -------------------------------------------------
|
||||
*/
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_index_files'] = [
|
||||
'inputType' => 'checkbox',
|
||||
'eval' => [
|
||||
'tl_class' => 'w50',
|
||||
'submitOnChange' => true,
|
||||
],
|
||||
'sql' => "char(1) NOT NULL default '0'",
|
||||
];
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['fields']['meilisearch_tika_url'] = [
|
||||
'inputType' => 'text',
|
||||
'eval' => [
|
||||
'rgxp' => 'url',
|
||||
'mandatory' => true,
|
||||
'tl_class' => 'w50 clr',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* -------------------------------------------------
|
||||
* Selector / Subpalette
|
||||
* -------------------------------------------------
|
||||
*/
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['palettes']['__selector__'][] = 'meilisearch_index_files';
|
||||
|
||||
$GLOBALS['TL_DCA']['tl_settings']['subpalettes']['meilisearch_index_files']
|
||||
= 'meilisearch_tika_url';
|
||||
|
||||
/**
|
||||
* -------------------------------------------------
|
||||
* Palette
|
||||
* -------------------------------------------------
|
||||
*/
|
||||
|
||||
PaletteManipulator::create()
|
||||
->addLegend('meilisearch_legend', null, PaletteManipulator::POSITION_AFTER, true)
|
||||
->addField('meilisearch_host', 'meilisearch_legend')
|
||||
->addField('meilisearch_index', 'meilisearch_legend')
|
||||
->addField('meilisearch_api_write', 'meilisearch_legend')
|
||||
->addField('meilisearch_api_search', 'meilisearch_legend')
|
||||
->addField('meilisearch_imagesize', 'meilisearch_legend')
|
||||
->addField('meilisearch_fallback_image', 'meilisearch_legend')
|
||||
->addField('meilisearch_index_past_events', 'meilisearch_legend')
|
||||
->addField('meilisearch_index_files', 'meilisearch_legend')
|
||||
->applyToPalette('default', 'tl_settings');
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
$GLOBALS['TL_LANG']['MSC']['meilisearch_priority'] = [
|
||||
1 => 'Niedrig',
|
||||
2 => 'Standard',
|
||||
3 => 'Hoch',
|
||||
];
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
|
||||
$GLOBALS['TL_LANG']['FMD']['meilisearch_search'] = [
|
||||
'Meilisearch-Suche',
|
||||
'Suchfeld mit Meilisearch-Anbindung.'
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// Legend
|
||||
$GLOBALS['TL_LANG']['tl_calendar_events']['meilisearch_legend'] = 'Einstellungen für die Suche';
|
||||
|
||||
// Fields
|
||||
$GLOBALS['TL_LANG']['tl_calendar_events']['priority'][0] = 'Priorität';
|
||||
$GLOBALS['TL_LANG']['tl_calendar_events']['priority'][1] = 'Priorität für die Darstellung in den Suchergebnissen (1 = niedrig, 3 = hoch).';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_calendar_events']['keywords'][0] = 'Keywords';
|
||||
$GLOBALS['TL_LANG']['tl_calendar_events']['keywords'][1] = 'Zusätzliche Suchbegriffe für die Indexierung.';
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_module']['meiliLimit'] = [
|
||||
'Treffer-Limit',
|
||||
'Maximale Anzahl der Suchergebnisse, die Meilisearch zurückliefert.'
|
||||
];
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// Legend
|
||||
$GLOBALS['TL_LANG']['tl_news']['meilisearch_legend'] = 'Einstellungen für die Suche';
|
||||
|
||||
// Fields
|
||||
$GLOBALS['TL_LANG']['tl_news']['priority'][0] = 'Priorität';
|
||||
$GLOBALS['TL_LANG']['tl_news']['priority'][1] = 'Priorität für die Darstellung in den Suchergebnissen (1 = niedrig, 3 = hoch).';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_news']['keywords'][0] = 'Keywords';
|
||||
$GLOBALS['TL_LANG']['tl_news']['keywords'][1] = 'Zusätzliche Suchbegriffe für die Indexierung.';
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
// Legend
|
||||
$GLOBALS['TL_LANG']['tl_page']['meilisearch_legend'] = 'Einstellungen für die Suche';
|
||||
|
||||
// Fields
|
||||
$GLOBALS['TL_LANG']['tl_page']['priority'][0] = 'Priorität';
|
||||
$GLOBALS['TL_LANG']['tl_page']['priority'][1] = 'Priorität für die Darstellung in den Suchergebnissen (1 = niedrig, 3 = hoch).';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_page']['keywords'][0] = 'Keywords';
|
||||
$GLOBALS['TL_LANG']['tl_page']['keywords'][1] = 'Zusätzliche Suchbegriffe für die Indexierung.';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_page']['searchimage'][0] = 'Suchbild';
|
||||
$GLOBALS['TL_LANG']['tl_page']['searchimage'][1] = 'Vorschaubild für die Anzeige in Suchergebnissen.';
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_legend'] = 'Meilisearch Einstellungen';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_host'][0] = 'Meilisearch URL';
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_host'][1] = 'URL der Meilisearch Instanz (z. B. https://search.domain.tld).';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_index'][0] = 'Meilisearch Index';
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_index'][1] = 'Index in der Meilisearch Instanz.';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_api_write'][0] = 'API Write Key';
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_api_write'][1] = 'API-Schlüssel für den Schreib-Zugriff auf Meilisearch.';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_api_search'][0] = 'API Search Key';
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_api_search'][1] = 'API-Schlüssel für den Suche-Zugriff auf Meilisearch.';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_imagesize'][0] = 'Bildgröße für Vorschaubilder';
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_imagesize'][1] = 'Bildgröße aus den Contao-Bildgrößen (tl_image_size).';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_fallback_image'] = [
|
||||
'Fallback-Bild für die Suche',
|
||||
'Dieses Bild wird verwendet, wenn für eine Seite, News oder ein Event kein Suchbild gesetzt ist.',
|
||||
];
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_index_past_events'][0]
|
||||
= 'Abgelaufene Events indexieren';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_index_past_events'][1]
|
||||
= 'Vergangene Kalender-Events werden ebenfalls in Meilisearch indexiert.';
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_index_files'] = [
|
||||
'Dateien indexieren',
|
||||
'Aktiviert die Indexierung von PDF-Dateien sowie DOCX, XLSX und PPTX.',
|
||||
];
|
||||
|
||||
$GLOBALS['TL_LANG']['tl_settings']['meilisearch_tika_url']
|
||||
= ['Apache Tika URL', 'URL der Apache Tika Instanz (z. B. https://tika.domain.tld).'];
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
{#
|
||||
Meilisearch Frontend Search
|
||||
Contao 5 – Frontend Module Template
|
||||
#}
|
||||
|
||||
<!-- indexer::stop -->
|
||||
{% block meilisearch %}
|
||||
<div
|
||||
id="topsearch"
|
||||
class="meilisearch-search"
|
||||
data-limit="{{ meiliLimit }}"
|
||||
>
|
||||
<div class="headersearch">
|
||||
<form id="search-form" onsubmit="return false;">
|
||||
<div class="formbody">
|
||||
<div class="widget widget-text">
|
||||
<label for="search_input" class="invisible">
|
||||
{{ 'Suchen'|trans }}
|
||||
</label>
|
||||
|
||||
<div class="search-field">
|
||||
<input
|
||||
type="search"
|
||||
name="keywords"
|
||||
id="search_input"
|
||||
class="text"
|
||||
placeholder="Suchbegriff eingeben…"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="clear-button is-hidden"
|
||||
aria-label="Suche löschen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="search-results"></div>
|
||||
|
||||
<template id="search-result-template">
|
||||
<div class="search-item">
|
||||
<div class="siteimage">
|
||||
<img src="" alt="" loading="lazy">
|
||||
</div>
|
||||
|
||||
<div class="teaser">
|
||||
<div class="title"></div>
|
||||
<div class="extract"></div>
|
||||
<div class="pfad"></div>
|
||||
</div>
|
||||
|
||||
<a class="masterurl" href="#" title=""></a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const CDN_URLS = [
|
||||
'https://cdn.jsdelivr.net/npm/meilisearch@0.39.0/dist/bundles/meilisearch.umd.min.js',
|
||||
'https://unpkg.com/meilisearch@0.39.0/dist/bundles/meilisearch.umd.min.js'
|
||||
];
|
||||
|
||||
function loadClient(urls, onDone) {
|
||||
if (typeof MeiliSearch !== 'undefined') {
|
||||
onDone(true, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!urls.length) {
|
||||
onDone(false, 'Alle CDN-Quellen fehlgeschlagen (mögliche CSP-Blockierung von script-src).');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = urls.shift();
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
|
||||
script.onload = () => {
|
||||
if (typeof MeiliSearch !== 'undefined') {
|
||||
onDone(true, null);
|
||||
} else {
|
||||
loadClient(urls, onDone);
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => loadClient(urls, onDone);
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
|
||||
const wrapper = document.querySelector('.meilisearch-search');
|
||||
if (!wrapper) return;
|
||||
|
||||
const input = wrapper.querySelector('#search_input');
|
||||
const clear = wrapper.querySelector('.clear-button');
|
||||
const results = wrapper.querySelector('#search-results');
|
||||
const template = wrapper.querySelector('#search-result-template');
|
||||
|
||||
if (!input || !clear || !results || !template) {
|
||||
console.warn('[Meilisearch] Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = parseInt(wrapper.dataset.limit, 10) || 50;
|
||||
|
||||
const client = new MeiliSearch({
|
||||
host: '{{ meiliHost }}',
|
||||
apiKey: '{{ meiliSearchKey }}'
|
||||
});
|
||||
|
||||
const index = client.index('{{ meiliIndex }}');
|
||||
|
||||
let abortController = null;
|
||||
|
||||
// ----------------------------
|
||||
// Clear button
|
||||
// ----------------------------
|
||||
clear.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
results.innerHTML = '';
|
||||
clear.classList.add('is-hidden');
|
||||
|
||||
// ✅ WICHTIG: Suchmodus verlassen
|
||||
document.body.classList.remove('search-active');
|
||||
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// ----------------------------
|
||||
// Input handling
|
||||
// ----------------------------
|
||||
input.addEventListener('input', async () => {
|
||||
|
||||
const query = input.value.trim();
|
||||
|
||||
clear.classList.toggle('is-hidden', query.length === 0);
|
||||
|
||||
if (query.length < 2) {
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await index.search(query, {
|
||||
limit,
|
||||
|
||||
attributesToRetrieve: [
|
||||
'title',
|
||||
'url',
|
||||
'text',
|
||||
'poster',
|
||||
'priority',
|
||||
'type'
|
||||
],
|
||||
|
||||
attributesToHighlight: ['text'],
|
||||
attributesToCrop: ['text'],
|
||||
cropLength: 50,
|
||||
cropMarker: '…',
|
||||
|
||||
sort: ['startDate:asc', 'priority:desc']
|
||||
});
|
||||
|
||||
renderResults(response.hits);
|
||||
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error('[Meilisearch]', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------
|
||||
// Render results via <template>
|
||||
// ----------------------------
|
||||
function renderResults(hits) {
|
||||
|
||||
results.innerHTML = '';
|
||||
|
||||
if (!hits || !hits.length) {
|
||||
document.body.classList.remove('search-active');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.add('search-active');
|
||||
|
||||
for (const hit of hits) {
|
||||
|
||||
// console.log('hit.type =', hit.type); //
|
||||
|
||||
const node = template.content.cloneNode(true);
|
||||
const item = node.children[0]; // 🔒 sicher
|
||||
|
||||
// DEBUG – einmal prüfen
|
||||
console.log('TYPE:', hit.type);
|
||||
|
||||
// Type → CSS-Klasse
|
||||
if (hit.type) {
|
||||
item.classList.add(
|
||||
String(hit.type)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '')
|
||||
);
|
||||
}
|
||||
|
||||
const image = item.querySelector('.siteimage');
|
||||
const img = image.querySelector('img');
|
||||
const title = item.querySelector('.title');
|
||||
const extract = item.querySelector('.extract');
|
||||
const path = item.querySelector('.pfad');
|
||||
const link = item.querySelector('.masterurl');
|
||||
|
||||
// Title & Link
|
||||
title.textContent = hit.title || '';
|
||||
link.href = hit.url || '#';
|
||||
link.title = hit.title || '';
|
||||
|
||||
// Extract
|
||||
if (hit._formatted?.text) {
|
||||
extract.innerHTML = hit._formatted.text;
|
||||
} else {
|
||||
extract.textContent = '';
|
||||
}
|
||||
|
||||
// Path
|
||||
if (hit.url) {
|
||||
path.textContent = hit.url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
// Image
|
||||
if (hit.poster) {
|
||||
img.src = hit.poster;
|
||||
img.alt = hit.title || '';
|
||||
image.classList.remove('is-empty');
|
||||
} else {
|
||||
img.removeAttribute('src');
|
||||
img.alt = '';
|
||||
image.classList.add('is-empty');
|
||||
}
|
||||
|
||||
results.appendChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadClient([...CDN_URLS], (ok, reason) => {
|
||||
if (!ok) {
|
||||
console.error('[Meilisearch] Browser client konnte nicht geladen werden. ' + reason);
|
||||
return;
|
||||
}
|
||||
|
||||
initSearch();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
<!-- indexer::continue -->
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filetype-docx" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zm-6.839 9.688v-.522a1.5 1.5 0 0 0-.117-.641.86.86 0 0 0-.322-.387.86.86 0 0 0-.469-.129.87.87 0 0 0-.471.13.87.87 0 0 0-.32.386 1.5 1.5 0 0 0-.117.641v.522q0 .384.117.641a.87.87 0 0 0 .32.387.9.9 0 0 0 .471.126.9.9 0 0 0 .469-.126.86.86 0 0 0 .322-.386 1.55 1.55 0 0 0 .117-.642m.803-.516v.513q0 .563-.205.973a1.47 1.47 0 0 1-.589.627q-.381.216-.917.216a1.86 1.86 0 0 1-.92-.216 1.46 1.46 0 0 1-.589-.627 2.15 2.15 0 0 1-.205-.973v-.513q0-.569.205-.975.205-.411.59-.627.386-.22.92-.22.535 0 .916.22.383.219.59.63.204.406.204.972M1 15.925v-3.999h1.459q.609 0 1.005.235.396.233.589.68.196.445.196 1.074 0 .634-.196 1.084-.197.451-.595.689-.396.237-.999.237zm1.354-3.354H1.79v2.707h.563q.277 0 .483-.082a.8.8 0 0 0 .334-.252q.132-.17.196-.422a2.3 2.3 0 0 0 .068-.592q0-.45-.118-.753a.9.9 0 0 0-.354-.454q-.237-.152-.61-.152Zm6.756 1.116q0-.373.103-.633a.87.87 0 0 1 .301-.398.8.8 0 0 1 .475-.138q.225 0 .398.097a.7.7 0 0 1 .273.26.85.85 0 0 1 .12.381h.765v-.073a1.33 1.33 0 0 0-.466-.964 1.4 1.4 0 0 0-.49-.272 1.8 1.8 0 0 0-.606-.097q-.534 0-.911.223-.375.222-.571.633-.197.41-.197.978v.498q0 .568.194.976.195.406.571.627.375.216.914.216.44 0 .785-.164t.551-.454a1.27 1.27 0 0 0 .226-.674v-.076h-.765a.8.8 0 0 1-.117.364.7.7 0 0 1-.273.248.9.9 0 0 1-.401.088.85.85 0 0 1-.478-.131.83.83 0 0 1-.298-.393 1.7 1.7 0 0 1-.103-.627zm5.092-1.76h.894l-1.275 2.006 1.254 1.992h-.908l-.85-1.415h-.035l-.852 1.415h-.862l1.24-2.015-1.228-1.984h.932l.832 1.439h.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filetype-pdf" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM1.6 11.85H0v3.999h.791v-1.342h.803q.43 0 .732-.173.305-.175.463-.474a1.4 1.4 0 0 0 .161-.677q0-.375-.158-.677a1.2 1.2 0 0 0-.46-.477q-.3-.18-.732-.179m.545 1.333a.8.8 0 0 1-.085.38.57.57 0 0 1-.238.241.8.8 0 0 1-.375.082H.788V12.48h.66q.327 0 .512.181.185.183.185.522m1.217-1.333v3.999h1.46q.602 0 .998-.237a1.45 1.45 0 0 0 .595-.689q.196-.45.196-1.084 0-.63-.196-1.075a1.43 1.43 0 0 0-.589-.68q-.396-.234-1.005-.234zm.791.645h.563q.371 0 .609.152a.9.9 0 0 1 .354.454q.118.302.118.753a2.3 2.3 0 0 1-.068.592 1.1 1.1 0 0 1-.196.422.8.8 0 0 1-.334.252 1.3 1.3 0 0 1-.483.082h-.563zm3.743 1.763v1.591h-.79V11.85h2.548v.653H7.896v1.117h1.606v.638z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 932 B |
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filetype-pptx" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM1.5 11.85h1.6q.434 0 .732.179.302.175.46.477t.158.677-.16.677q-.159.299-.464.474a1.45 1.45 0 0 1-.732.173H2.29v1.342H1.5zm2.06 1.714a.8.8 0 0 0 .085-.381q0-.34-.185-.521-.185-.182-.513-.182h-.659v1.406h.66a.8.8 0 0 0 .374-.082.57.57 0 0 0 .238-.24m1.302-1.714h1.6q.434 0 .732.179.302.175.46.477t.158.677-.16.677q-.158.299-.464.474a1.45 1.45 0 0 1-.732.173h-.803v1.342h-.79zm2.06 1.714a.8.8 0 0 0 .085-.381q0-.34-.185-.521-.184-.182-.513-.182H5.65v1.406h.66a.8.8 0 0 0 .374-.082.57.57 0 0 0 .238-.24m2.852 2.285v-3.337h1.137v-.662H7.846v.662H8.98v3.337zm3.796-3.999h.893l-1.274 2.007 1.254 1.992h-.908l-.85-1.415h-.035l-.853 1.415h-.861l1.24-2.016-1.228-1.983h.931l.832 1.439h.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 937 B |
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filetype-xlsx" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM7.86 14.841a1.13 1.13 0 0 0 .401.823q.195.162.479.252.284.091.665.091.507 0 .858-.158.355-.158.54-.44a1.17 1.17 0 0 0 .187-.656q0-.336-.135-.56a1 1 0 0 0-.375-.357 2 2 0 0 0-.565-.21l-.621-.144a1 1 0 0 1-.405-.176.37.37 0 0 1-.143-.299q0-.234.184-.384.188-.152.513-.152.214 0 .37.068a.6.6 0 0 1 .245.181.56.56 0 0 1 .12.258h.75a1.1 1.1 0 0 0-.199-.566 1.2 1.2 0 0 0-.5-.41 1.8 1.8 0 0 0-.78-.152q-.44 0-.777.15-.336.149-.527.421-.19.273-.19.639 0 .302.123.524t.351.367q.229.143.54.213l.618.144q.31.073.462.193a.39.39 0 0 1 .153.326.5.5 0 0 1-.085.29.56.56 0 0 1-.255.193q-.168.07-.413.07-.176 0-.32-.04a.8.8 0 0 1-.249-.115.58.58 0 0 1-.255-.384zm-3.726-2.909h.893l-1.274 2.007 1.254 1.992h-.908l-.85-1.415h-.035l-.853 1.415H1.5l1.24-2.016-1.228-1.983h.931l.832 1.438h.036zm1.923 3.325h1.697v.674H5.266v-3.999h.791zm7.636-3.325h.893l-1.274 2.007 1.254 1.992h-.908l-.85-1.415h-.035l-.853 1.415h-.861l1.24-2.016-1.228-1.983h.931l.832 1.438h.036z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
Executable
+259
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Service;
|
||||
|
||||
use Contao\FilesModel;
|
||||
use Contao\StringUtil;
|
||||
use Contao\System;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
class MeilisearchFileHelper
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentrale Datei-Verarbeitung
|
||||
*/
|
||||
public function collect(string $url, string $type, int $pageId): void
|
||||
{
|
||||
$this->log('collect() start', [
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'pageId' => $pageId,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------
|
||||
// 1. URL normalisieren
|
||||
// -------------------------------------------------
|
||||
$cleanUrl = strtok($url, '#');
|
||||
$parts = parse_url($cleanUrl);
|
||||
|
||||
if (!$parts) {
|
||||
$this->log('Invalid URL, skip');
|
||||
return;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 2. Externe Datei? → skip
|
||||
// -------------------------------------------------
|
||||
if (!empty($parts['host'])) {
|
||||
$currentRequest = System::getContainer()
|
||||
->get('request_stack')
|
||||
->getCurrentRequest();
|
||||
|
||||
$pageHost = $currentRequest
|
||||
? parse_url($currentRequest->getSchemeAndHttpHost(), PHP_URL_HOST)
|
||||
: null;
|
||||
|
||||
if ($pageHost && $parts['host'] !== $pageHost) {
|
||||
$this->log('External file detected, skip', [
|
||||
'host' => $parts['host'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 3. Pfad-Kandidaten sammeln (ohne Annahmen!)
|
||||
// -------------------------------------------------
|
||||
$query = [];
|
||||
if (!empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
}
|
||||
|
||||
$pathCandidates = [];
|
||||
|
||||
// direkter Pfad
|
||||
if (!empty($parts['path'])) {
|
||||
$pathCandidates[] = $parts['path'];
|
||||
}
|
||||
|
||||
// Download-Parameter
|
||||
foreach (['file', 'f', 'p'] as $param) {
|
||||
if (!empty($query[$param])) {
|
||||
$pathCandidates[] = $query[$param];
|
||||
}
|
||||
}
|
||||
|
||||
// normalisieren
|
||||
$pathCandidates = array_values(array_unique(array_filter(array_map(
|
||||
static function ($candidate) {
|
||||
$candidate = rawurldecode(html_entity_decode((string) $candidate, ENT_QUOTES));
|
||||
return ltrim($candidate, '/') ?: null;
|
||||
},
|
||||
$pathCandidates
|
||||
))));
|
||||
|
||||
$this->log('Path candidates (normalized)', [
|
||||
'candidates' => $pathCandidates,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------
|
||||
// 4. FilesModel (DBAFS) auflösen → UUID
|
||||
// -------------------------------------------------
|
||||
$fileModel = null;
|
||||
|
||||
foreach ($pathCandidates as $candidate) {
|
||||
|
||||
// 1) direkt
|
||||
$model = FilesModel::findByPath($candidate);
|
||||
if ($model && $model->uuid) {
|
||||
$fileModel = $model;
|
||||
$this->log('Resolved via FilesModel (direct)', [
|
||||
'candidate' => $candidate,
|
||||
'path' => $model->path,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// 2) fallback: files/ davor
|
||||
if (!str_starts_with($candidate, 'files/')) {
|
||||
$model = FilesModel::findByPath('files/' . $candidate);
|
||||
if ($model && $model->uuid) {
|
||||
$fileModel = $model;
|
||||
$this->log('Resolved via FilesModel (files/ prefix)', [
|
||||
'candidate' => $candidate,
|
||||
'path' => $model->path,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$fileModel) {
|
||||
$this->log('No Contao file model found, skip', [
|
||||
'candidates' => $pathCandidates,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedPath = (string) $fileModel->path;
|
||||
$uuidBin = $fileModel->uuid;
|
||||
$uuid = StringUtil::binToUuid($uuidBin);
|
||||
$canonicalUrl = '/' . ltrim($normalizedPath, '/');
|
||||
|
||||
$this->log('UUID resolved', [
|
||||
'path' => $canonicalUrl,
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------
|
||||
// 5. Datei im Filesystem prüfen
|
||||
// -------------------------------------------------
|
||||
$projectDir = System::getContainer()->getParameter('kernel.project_dir');
|
||||
$abs = $projectDir . '/public/' . $normalizedPath;
|
||||
|
||||
if (!is_file($abs)) {
|
||||
$this->log('Resolved model but file missing on filesystem, skip', [
|
||||
'path' => $normalizedPath,
|
||||
'abs' => $abs,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 6. Redaktionellen Titel aus tl_files.meta
|
||||
// -------------------------------------------------
|
||||
$title = null;
|
||||
$meta = StringUtil::deserialize($fileModel->meta, true);
|
||||
|
||||
// 1) bevorzugte Sprache (falls vorhanden)
|
||||
$lang = $GLOBALS['TL_LANGUAGE'] ?? null;
|
||||
if ($lang && !empty($meta[$lang]['title'])) {
|
||||
$title = trim((string) $meta[$lang]['title']);
|
||||
}
|
||||
|
||||
// 2) Fallback: erste verfügbare Sprache
|
||||
if ($title === null && is_array($meta)) {
|
||||
foreach ($meta as $langKey => $langMeta) {
|
||||
if (!empty($langMeta['title'])) {
|
||||
$title = trim((string) $langMeta['title']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($title) {
|
||||
$this->log('Title resolved from tl_files', [
|
||||
'title' => $title,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// 7. Datei-Infos
|
||||
// -------------------------------------------------
|
||||
$mtime = filemtime($abs) ?: 0;
|
||||
$checksum = md5($normalizedPath . '|' . $mtime);
|
||||
$now = time();
|
||||
|
||||
// -------------------------------------------------
|
||||
// 8. Upsert über UUID
|
||||
// -------------------------------------------------
|
||||
$existing = $this->connection->fetchAssociative(
|
||||
'SELECT id FROM tl_search_files WHERE uuid = ?',
|
||||
[$uuidBin]
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
$data = [
|
||||
'tstamp' => $now,
|
||||
'last_seen' => $now,
|
||||
'type' => $type,
|
||||
'url' => $canonicalUrl,
|
||||
'page_id' => $pageId,
|
||||
'file_mtime' => $mtime,
|
||||
'checksum' => $checksum,
|
||||
];
|
||||
|
||||
if ($title !== null) {
|
||||
$data['title'] = $title;
|
||||
}
|
||||
|
||||
$this->connection->update(
|
||||
'tl_search_files',
|
||||
$data,
|
||||
['id' => $existing['id']]
|
||||
);
|
||||
|
||||
$this->log('File updated by UUID', [
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
} else {
|
||||
$this->connection->insert(
|
||||
'tl_search_files',
|
||||
[
|
||||
'tstamp' => $now,
|
||||
'last_seen' => $now,
|
||||
'type' => $type,
|
||||
'url' => $canonicalUrl,
|
||||
'title' => $title ?? basename($normalizedPath),
|
||||
'page_id' => $pageId,
|
||||
'file_mtime' => $mtime,
|
||||
'checksum' => $checksum,
|
||||
'uuid' => $uuidBin,
|
||||
]
|
||||
);
|
||||
|
||||
$this->log('File inserted by UUID', [
|
||||
'uuid' => $uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->log('collect() end');
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// Logging
|
||||
// -------------------------------------------------
|
||||
private function log(string $message, array $context = []): void
|
||||
{
|
||||
$ctx = $context
|
||||
? ' | ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
: '';
|
||||
|
||||
error_log('[ContaoMeilisearch][MeilisearchFileHelper] ' . $message . $ctx);
|
||||
}
|
||||
}
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Service;
|
||||
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Image\Studio\Studio;
|
||||
use Contao\FilesModel;
|
||||
|
||||
class MeilisearchImageHelper
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly Studio $studio,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Wandelt eine Bild-UUID aus tl_search.imagepath
|
||||
* in einen generierten Asset-Pfad (/assets/images/…)
|
||||
*/
|
||||
public function resolveImagePath(?string $uuid): ?string
|
||||
{
|
||||
if (!$uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Contao-Framework initialisieren (CLI & Frontend)
|
||||
try {
|
||||
$this->framework->initialize();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] ImageHelper: Framework init failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var FilesModel|null $file */
|
||||
try {
|
||||
$file = FilesModel::findByUuid($uuid);
|
||||
} catch (\Throwable $e) {
|
||||
error_log(
|
||||
'[ContaoMeilisearch] ImageHelper: FilesModel lookup failed (' . $uuid . '): ' . $e->getMessage()
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$file) {
|
||||
error_log('[ContaoMeilisearch] ImageHelper: File not found for UUID ' . $uuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ImageSize aus tl_settings
|
||||
$imageSizeId = (int) Config::get('meilisearch_imagesize');
|
||||
|
||||
// Fallback: Originaldatei
|
||||
if ($imageSizeId <= 0) {
|
||||
return $file->path;
|
||||
}
|
||||
|
||||
try {
|
||||
$figure = $this->studio
|
||||
->createFigureBuilder()
|
||||
->from($file->path)
|
||||
->setSize($imageSizeId)
|
||||
->build();
|
||||
|
||||
$image = $figure->getImage();
|
||||
|
||||
if ($image === null) {
|
||||
error_log(
|
||||
'[ContaoMeilisearch] ImageHelper: Image generation failed for ' . $file->path
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $image->getImageSrc() ?: null;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log(
|
||||
'[ContaoMeilisearch] ImageHelper: Image processing failed for '
|
||||
. $file->path . ': ' . $e->getMessage()
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+271
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace Mummert\ContaoMeilisearchBundle\Service;
|
||||
|
||||
use Contao\Config;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Meilisearch\Client;
|
||||
use Meilisearch\Endpoints\Indexes;
|
||||
|
||||
class MeilisearchIndexService
|
||||
{
|
||||
private Client $client;
|
||||
private string $indexName;
|
||||
|
||||
/**
|
||||
* Statische Icons für Datei-Typen (Bundle-Assets)
|
||||
*/
|
||||
private const FILETYPE_ICON_MAP = [
|
||||
'pdf' => '/bundles/contaomeilisearch/icons/filetype-pdf.svg',
|
||||
'docx' => '/bundles/contaomeilisearch/icons/filetype-docx.svg',
|
||||
'xlsx' => '/bundles/contaomeilisearch/icons/filetype-xlsx.svg',
|
||||
'pptx' => '/bundles/contaomeilisearch/icons/filetype-pptx.svg',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ContaoFramework $framework,
|
||||
private readonly MeilisearchImageHelper $imageHelper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Entry point for command & cron
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
try {
|
||||
$this->framework->initialize();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Framework initialization failed: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$host = (string) Config::get('meilisearch_host');
|
||||
$apiKey = (string) Config::get('meilisearch_api_write');
|
||||
$this->indexName = (string) Config::get('meilisearch_index');
|
||||
|
||||
if ($host === '' || $this->indexName === '') {
|
||||
error_log('[ContaoMeilisearch] Meilisearch is not configured in tl_settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->client = new Client($host, $apiKey);
|
||||
$index = $this->client->index($this->indexName);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to connect to Meilisearch: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->ensureIndexSettings($index);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to update index settings: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$index->deleteAllDocuments();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to delete documents: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->indexTlSearch($index);
|
||||
$this->indexTlSearchFiles($index);
|
||||
}
|
||||
|
||||
private function ensureIndexSettings(Indexes $index): void
|
||||
{
|
||||
$index->updateSettings([
|
||||
'searchableAttributes' => ['title', 'keywords', 'text'],
|
||||
'sortableAttributes' => ['priority', 'startDate'],
|
||||
'filterableAttributes' => ['type', 'filetype'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⛔ MEILISEARCH_META aus Text entfernen
|
||||
*/
|
||||
private function stripMeilisearchMeta(string $text): string
|
||||
{
|
||||
$text = preg_replace(
|
||||
'/⟦MEILISEARCH_META⟧.*?⟦\/MEILISEARCH_META⟧/su',
|
||||
'',
|
||||
$text
|
||||
);
|
||||
|
||||
$text = preg_replace('/\s{2,}/u', ' ', $text);
|
||||
$text = preg_replace('/\n{2,}/u', "\n", $text);
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* startDate aus schema.org Event extrahieren
|
||||
*/
|
||||
private function extractEventStartDate(?string $meta): ?int
|
||||
{
|
||||
if (!$meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($meta, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if (($entry['@type'] ?? null) !== 'https://schema.org/Event') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($entry['https://schema.org/startDate'])) {
|
||||
return strtotime($entry['https://schema.org/startDate']) ?: null;
|
||||
}
|
||||
|
||||
if (!empty($entry['startDate'])) {
|
||||
return strtotime($entry['startDate']) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* tl_search indexieren (Seiten / News / Events)
|
||||
*/
|
||||
private function indexTlSearch(Indexes $index): void
|
||||
{
|
||||
try {
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_search');
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to read tl_search: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexPastEvents = (bool) Config::get('meilisearch_index_past_events');
|
||||
$today = strtotime('today');
|
||||
|
||||
$documents = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$type = $this->detectTypeFromMeta($row['meta'] ?? null);
|
||||
|
||||
$eventStart = null;
|
||||
if ($type === 'event') {
|
||||
$eventStart = $this->extractEventStartDate($row['meta'] ?? null);
|
||||
if (!$indexPastEvents && $eventStart !== null && $eventStart < $today) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$doc = [
|
||||
'id' => $type . '_' . $row['id'],
|
||||
'type' => $type,
|
||||
'title' => $row['title'],
|
||||
'text' => $this->stripMeilisearchMeta((string) $row['text']),
|
||||
'url' => $row['url'],
|
||||
'protected' => (bool) $row['protected'],
|
||||
'checksum' => $row['checksum'],
|
||||
'keywords' => (string) ($row['keywords'] ?? ''),
|
||||
'priority' => (int) ($row['priority'] ?? 0),
|
||||
];
|
||||
|
||||
if ($eventStart !== null) {
|
||||
$doc['startDate'] = $eventStart;
|
||||
}
|
||||
|
||||
if (!empty($row['imagepath'])) {
|
||||
$imagePath = $this->imageHelper->resolveImagePath($row['imagepath']);
|
||||
if ($imagePath !== null) {
|
||||
$doc['poster'] = $imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
$documents[] = $doc;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to build tl_search document: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($documents !== []) {
|
||||
$index->addDocuments($documents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tl_search_files indexieren (PDF / Office)
|
||||
*/
|
||||
private function indexTlSearchFiles(Indexes $index): void
|
||||
{
|
||||
try {
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_search_files');
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to read tl_search_files: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
$documents = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$fileType = in_array($row['type'], ['pdf', 'docx', 'xlsx', 'pptx'], true)
|
||||
? $row['type']
|
||||
: 'pdf';
|
||||
|
||||
$documents[] = [
|
||||
'id' => 'file_' . $row['id'],
|
||||
'type' => 'file',
|
||||
'filetype' => $fileType,
|
||||
'title' => $row['title'] ?: basename($row['url']),
|
||||
'text' => (string) $row['text'],
|
||||
'url' => $row['url'],
|
||||
'checksum' => $row['checksum'],
|
||||
'poster' => self::FILETYPE_ICON_MAP[$fileType]
|
||||
?? self::FILETYPE_ICON_MAP['pdf'],
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[ContaoMeilisearch] Failed to build file document: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($documents !== []) {
|
||||
$index->addDocuments($documents);
|
||||
}
|
||||
}
|
||||
|
||||
private function detectTypeFromMeta(?string $meta): string
|
||||
{
|
||||
if (!$meta) {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
$data = json_decode($meta, true);
|
||||
if (!is_array($data)) {
|
||||
return 'page';
|
||||
}
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if (($entry['@type'] ?? null) === 'https://schema.org/Event') {
|
||||
return 'event';
|
||||
}
|
||||
if (($entry['@type'] ?? null) === 'https://schema.org/NewsArticle') {
|
||||
return 'news';
|
||||
}
|
||||
}
|
||||
|
||||
return 'page';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user