Rename bundle namespace and prepare release 0.6.0

This commit is contained in:
Jürgen Mummert
2026-03-09 10:06:05 +01:00
commit b59bda40fc
37 changed files with 2582 additions and 0 deletions
Executable
+6
View File
@@ -0,0 +1,6 @@
.idea/
vendor/
var/cache/
var/log/
.vscode/
.DS_Store
Executable
+73
View File
@@ -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
View File
@@ -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"
}
}
Executable
+22
View File
@@ -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
View File
@@ -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));
}
}
+277
View File
@@ -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);
}
}
+57
View File
@@ -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
));
}
}
+29
View File
@@ -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,
]),
];
}
}
+9
View File
@@ -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
View File
@@ -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');
}
}
+223
View File
@@ -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
View File
@@ -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;
}
}
+26
View File
@@ -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 }
+10
View File
@@ -0,0 +1,10 @@
<?php
use Mummert\ContaoMeilisearchBundle\EventListener\MeilisearchPageMarkerListener;
$GLOBALS['TL_HOOKS']['outputFrontendTemplate'][] = [
MeilisearchPageMarkerListener::class,
'onOutputFrontendTemplate',
];
+36
View File
@@ -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 ''"
];
+19
View File
@@ -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",
];
+32
View File
@@ -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 ''"
];
+46
View File
@@ -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"
];
+36
View File
@@ -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'",
];
+95
View File
@@ -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",
],
],
];
+136
View File
@@ -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
View File
@@ -0,0 +1,7 @@
<?php
$GLOBALS['TL_LANG']['MSC']['meilisearch_priority'] = [
1 => 'Niedrig',
2 => 'Standard',
3 => 'Hoch',
];
+7
View File
@@ -0,0 +1,7 @@
<?php
$GLOBALS['TL_LANG']['FMD']['meilisearch_search'] = [
'Meilisearch-Suche',
'Suchfeld mit Meilisearch-Anbindung.'
];
+11
View File
@@ -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
View File
@@ -0,0 +1,7 @@
<?php
$GLOBALS['TL_LANG']['tl_module']['meiliLimit'] = [
'Treffer-Limit',
'Maximale Anzahl der Suchergebnisse, die Meilisearch zurückliefert.'
];
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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).'];
@@ -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 -->
+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+259
View File
@@ -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);
}
}
+84
View File
@@ -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;
}
}
}
+271
View File
@@ -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';
}
}