+FTS5 Rebuild

This commit is contained in:
Simon Martens
2026-01-30 16:22:19 +01:00
parent 52fecc0d05
commit 82c3c9c1e3
17 changed files with 1475 additions and 174 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,11 +3,10 @@
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
<div class="flex-col w-full">
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Datenexport</h1>
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Einstellungen</h1>
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
<i class="ri-question-line"></i>
Export von Daten u. Dateien als ZIP-Ordner. Die Exporte werden gespeichert und nach dem
Ablauf von sieben Tagen automatisch gelöscht.
Export von Daten u. Dateien. Verwaltung der Suchindizes und globalen Einstellungen.
</div>
</div>
</div>
@@ -17,43 +16,85 @@
data-run-url="/redaktion/exports/run/"
data-list-url="/redaktion/exports/list/"
data-delete-url="/redaktion/exports/delete/"
data-fts5-rebuild-url="/redaktion/exports/fts5/rebuild/"
data-fts5-status-url="/redaktion/exports/fts5/status/"
data-csrf="{{ $model.csrf_token }}">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<div class="flex flex-col gap-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Daten-Export erstellen</h2>
<p class="text-sm text-slate-600 mt-1">Sichert alle Daten der Tabellen als
XML-Dateien. Der Export läuft im Hintergrund.</p>
{{ template "_usermessage" $model }}
<div class="bg-slate-50 rounded-md shadow-sm border border-slate-200 p-6 mb-6">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<div class="flex flex-col gap-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Daten-Export erstellen</h2>
<p class="text-sm text-slate-600 mt-1">Sichert alle Daten der Tabellen als
XML-Dateien. Der Export läuft im Hintergrund.</p>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="data">
<i class="ri-download-2-line"></i> Export starten
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="data">
<i class="ri-download-2-line"></i> Export starten
</button>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Dateien sichern</h2>
<p class="text-sm text-slate-600 mt-1">Exportiert Bilder und Dateien als ZIP. Der
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
<i class="ri-folder-zip-line"></i> Dateien sichern
</button>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Dateien sichern</h2>
<p class="text-sm text-slate-600 mt-1">Exportiert Bilder und Dateien als ZIP. Der
Export kann eine Weile in Anspruch nehmen und läuft ebenfalls im Hintergrund.</p>
</div>
<div class="flex items-center gap-3">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50" data-role="run-export" data-export-type="files">
<i class="ri-folder-zip-line"></i> Dateien sichern
</button>
</div>
</div>
<div class="text-sm text-slate-600" data-role="status"></div>
<div class="text-sm text-slate-600" data-role="status"></div>
<div class="">
<div class="flex items-center justify-between mb-4 border-b border-slate-200 pb-2">
<h3 class="text-base font-semibold text-slate-900">Letzte Exporte</h3>
<div class="text-xs text-slate-500">Aktualisiert automatisch</div>
</div>
<div class="flex flex-col gap-3" data-role="export-list">
{{ template "_export_list" $model }}
<div class="">
<div class="flex items-center justify-between mb-4 border-b border-slate-200 pb-2">
<h3 class="text-base font-semibold text-slate-900">Letzte Exporte</h3>
<div class="text-xs text-slate-500">Aktualisiert automatisch</div>
</div>
<div class="flex flex-col gap-3" data-role="export-list">
{{ template "_export_list" $model }}
</div>
</div>
</div>
</div>
</div>
<section class="bg-slate-50 rounded-md shadow-sm border border-slate-200 p-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-slate-900">Suchindex neu aufbauen</h2>
<p class="text-sm text-slate-600 mt-1">Löscht und erstellt den Suchindex aus den
bestehenden Einträgen neu. Kann bei Problemen bei der Suche und Auffindbarkeit von
Bänden und Beiträgen hilfreich sein. <em>Die Datenbank sollte während dem Aufbau möglichst
nicht verändert werden.</em> Ein automatischer Rebuild findet jeden Sonntag um 00:00 Uhr statt.</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button type="button" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 whitespace-nowrap" data-role="fts5-rebuild">
<i class="ri-refresh-line"></i>
<span data-role="fts5-rebuild-label">Neuaufbau starten</span>
</button>
</div>
</div>
<div>
{{- if $model.fts5_last_rebuild_dt.IsZero | not -}}
<div class="text-xs text-slate-500">Zuletzt aufgebaut: <span data-role="fts5-last-rebuild">{{ GermanDate $model.fts5_last_rebuild_dt }} {{ GermanTime $model.fts5_last_rebuild_dt }}</span></div>
{{- else -}}
<div class="text-xs text-slate-500 hidden" data-role="fts5-last-rebuild-wrap">Zuletzt aufgebaut: <span data-role="fts5-last-rebuild"></span></div>
{{- end -}}
<div class="mt-3 hidden rounded-md border px-3 py-2 text-sm" data-role="fts5-status"></div>
<div class="mt-3 hidden" data-role="fts5-progress">
<div class="flex items-center justify-between text-xs text-slate-500 mb-1">
<span data-role="fts5-progress-text"></span>
<span data-role="fts5-progress-percent"></span>
</div>
<div class="h-2 w-full bg-slate-200 rounded">
<div class="h-2 bg-slate-700 rounded transition-all duration-200" style="width: 0%;" data-role="fts5-progress-bar"></div>
</div>
</div>
</div>
</div>
</section>
</export-manager>

View File

@@ -0,0 +1,34 @@
{{ $model := . }}
<div class="flex container-normal bg-slate-100 mx-auto !pt-36 px-8">
<div class="flex-col w-full">
<a href="/" class="text-gray-700 hover:text-slate-950"> <i class="ri-arrow-left-s-line"></i> Startseite </a>
<h1 class="text-2xl self-baseline w-full mt-6 mb-2 font-bold text-slate-900">Einstellungen</h1>
<div class="text-sm text-slate-600 !hyphens-auto mb-6 max-w-[70ch]">
<i class="ri-settings-3-line"></i>
Globale Einstellungen fuer die Seite und Systemfunktionen.
</div>
</div>
</div>
<div class="container-normal mx-auto px-8">
{{ template "_usermessage" $model }}
<div class="flex flex-col gap-8">
<section class="bg-white rounded-md shadow-sm border border-slate-200 p-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">FTS5 neu aufbauen</h2>
<p class="text-sm text-slate-600 mt-1">Löscht und erstellt die FTS5-Tabellen neu und füllt sie aus den bestehenden Einträgen.</p>
</div>
<form method="post" action="/redaktion/settings/fts5/rebuild/" class="flex items-center">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<button type="submit" class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50">
<i class="ri-refresh-line"></i> Neuaufbau starten
</button>
</form>
</div>
<div class="text-xs text-slate-500 mt-3">Automatischer Neuaufbau: jeden Sonntag um 00:00 Uhr.</div>
</section>
</div>
</div>

View File

@@ -4,9 +4,16 @@ export class ExportManager extends HTMLElement {
this.listUrl = "";
this.runUrl = "";
this.deleteUrl = "";
this.fts5RebuildUrl = "";
this.fts5StatusUrl = "";
this.csrf = "";
this.list = null;
this.status = null;
this.fts5Status = null;
this.fts5Progress = null;
this.fts5ProgressText = null;
this.fts5ProgressPercent = null;
this.fts5ProgressBar = null;
this.pollTimer = null;
this.pollIntervalMs = 2500;
}
@@ -15,13 +22,27 @@ export class ExportManager extends HTMLElement {
this.listUrl = this.dataset.listUrl || "";
this.runUrl = this.dataset.runUrl || "";
this.deleteUrl = this.dataset.deleteUrl || "";
this.fts5RebuildUrl = this.dataset.fts5RebuildUrl || "";
this.fts5StatusUrl = this.dataset.fts5StatusUrl || "";
this.csrf = this.dataset.csrf || "";
this.list = this.querySelector("[data-role='export-list']");
this.status = this.querySelector("[data-role='status']");
this.fts5Status = this.querySelector("[data-role='fts5-status']");
this.fts5Progress = this.querySelector("[data-role='fts5-progress']");
this.fts5ProgressText = this.querySelector("[data-role='fts5-progress-text']");
this.fts5ProgressPercent = this.querySelector("[data-role='fts5-progress-percent']");
this.fts5ProgressBar = this.querySelector("[data-role='fts5-progress-bar']");
this.fts5LastRebuild = this.querySelector("[data-role='fts5-last-rebuild']");
this.fts5LastRebuildWrap = this.querySelector("[data-role='fts5-last-rebuild-wrap']");
this.fts5Button = this.querySelector("[data-role='fts5-rebuild']");
this.fts5ButtonLabel = this.querySelector("[data-role='fts5-rebuild-label']");
this.fts5StatusValue = "idle";
this.fts5HadRunning = false;
this.addEventListener("click", (event) => this.handleAction(event));
this.refreshList();
this.refreshFts5Status();
}
disconnectedCallback() {
@@ -69,7 +90,7 @@ export class ExportManager extends HTMLElement {
return;
}
this.setStatus("Export läuft im Hintergrund.");
this.setStatus("Export läuft im Hintergrund.");
await this.refreshList();
this.startPolling();
} catch (err) {
@@ -87,6 +108,12 @@ export class ExportManager extends HTMLElement {
return;
}
const fts5Target = event.target.closest("[data-role='fts5-rebuild']");
if (fts5Target) {
await this.handleFts5Rebuild(event);
return;
}
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.getAttribute("data-action");
@@ -140,10 +167,163 @@ export class ExportManager extends HTMLElement {
}
}
async refreshFts5Status() {
if (!this.fts5StatusUrl) return;
try {
const response = await fetch(this.fts5StatusUrl, { credentials: "same-origin" });
if (!response.ok) return;
const json = await this.safeJson(response);
if (!json) return;
this.updateFts5Status(json);
this.syncPollingState();
} catch {
// ignore refresh errors
}
}
updateFts5Status(data) {
if (!this.fts5Status) return;
const prevStatus = this.fts5StatusValue;
const status = this.normalizeText(data.status) || "idle";
const message = this.normalizeText(data.message || "");
const err = this.normalizeText(data.error || "");
const done = Number.isFinite(data.done) ? data.done : 0;
const total = Number.isFinite(data.total) ? data.total : 0;
const lastRebuild = this.formatGermanDateTime(this.normalizeText(data.last_rebuild || ""));
this.fts5StatusValue = status;
this.fts5Status.classList.remove(
"hidden",
"text-slate-700",
"text-green-800",
"text-red-700",
"text-amber-800",
"bg-slate-50",
"bg-green-50",
"bg-red-50",
"bg-amber-50",
"border-slate-200",
"border-green-200",
"border-red-200",
"border-amber-200",
);
if (status === "running" || status === "restarting") {
this.fts5HadRunning = true;
}
if (status === "complete" && !this.fts5HadRunning) {
this.fts5Status.textContent = "";
this.fts5Status.classList.add("hidden");
} else if (status === "error") {
this.fts5Status.textContent = err || "FTS5-Neuaufbau fehlgeschlagen.";
this.fts5Status.classList.add("text-red-700", "bg-red-50", "border-red-200");
} else if (status === "aborted") {
this.fts5Status.textContent = message || "FTS5-Neuaufbau abgebrochen.";
this.fts5Status.classList.add("text-red-700", "bg-red-50", "border-red-200");
} else if (status === "complete") {
this.fts5Status.textContent = message || "FTS5-Neuaufbau abgeschlossen.";
this.fts5Status.classList.add("text-green-800", "bg-green-50", "border-green-200");
} else if (status === "restarting") {
this.fts5Status.textContent = message || "FTS5-Neuaufbau wird neu gestartet.";
this.fts5Status.classList.add("text-amber-800", "bg-amber-50", "border-amber-200");
} else if (status === "running") {
this.fts5Status.textContent = message || "FTS5-Neuaufbau läuft.";
this.fts5Status.classList.add("text-amber-800", "bg-amber-50", "border-amber-200");
} else {
this.fts5Status.textContent = message || "";
if (!this.fts5Status.textContent) {
this.fts5Status.classList.add("hidden");
} else {
this.fts5Status.classList.add("text-slate-700", "bg-slate-50", "border-slate-200");
}
}
if (this.fts5Status.textContent) {
this.fts5Status.classList.remove("hidden");
}
if (this.fts5Progress) {
if (status === "running" || status === "restarting") {
this.fts5Progress.classList.remove("hidden");
} else {
this.fts5Progress.classList.add("hidden");
}
}
if (this.fts5Button) {
const isRunning = status === "running";
if (this.fts5ButtonLabel) {
this.fts5ButtonLabel.textContent = isRunning ? "Abbrechen & neu starten" : "Neuaufbau starten";
}
this.fts5Button.classList.toggle("bg-slate-900", !isRunning);
this.fts5Button.classList.toggle("hover:bg-slate-800", !isRunning);
this.fts5Button.classList.toggle("bg-amber-600", isRunning);
this.fts5Button.classList.toggle("hover:bg-amber-700", isRunning);
}
if (this.fts5LastRebuild && lastRebuild) {
this.fts5LastRebuild.textContent = lastRebuild;
if (this.fts5LastRebuildWrap) {
this.fts5LastRebuildWrap.classList.remove("hidden");
}
}
if (prevStatus === "running" && status !== "running") {
window.setTimeout(() => {
this.refreshFts5Status();
}, 500);
}
if ((status === "running" || status === "restarting") && total > 0) {
const percent = Math.min(100, Math.round((done / total) * 100));
if (this.fts5ProgressText) {
this.fts5ProgressText.textContent = `${done} / ${total}`;
}
if (this.fts5ProgressPercent) {
this.fts5ProgressPercent.textContent = `${percent}%`;
}
if (this.fts5ProgressBar) {
this.fts5ProgressBar.style.width = `${percent}%`;
}
} else if (status === "running" || status === "restarting") {
if (this.fts5ProgressText) {
this.fts5ProgressText.textContent = "Wird vorbereitet...";
}
if (this.fts5ProgressPercent) {
this.fts5ProgressPercent.textContent = "";
}
if (this.fts5ProgressBar) {
this.fts5ProgressBar.style.width = "0%";
}
}
}
formatGermanDateTime(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = raw.replace(/^"+|"+$/g, "");
const isoMatch = normalized.match(
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/,
);
if (!isoMatch) return normalized;
const iso = normalized.replace(" ", "T");
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return normalized;
const weekdays = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${weekdays[date.getDay()]}, ${day}. ${month} ${year} ${hours}:${minutes}`;
}
syncPollingState() {
if (!this.list) return;
const active = this.list.querySelector("[data-export-status='running'], [data-export-status='queued']");
if (active) {
const hasExports = this.list
? this.list.querySelector("[data-export-status='running'], [data-export-status='queued']")
: null;
const fts5Running = this.fts5Progress && !this.fts5Progress.classList.contains("hidden");
if (hasExports || fts5Running) {
this.startPolling();
} else {
this.stopPolling();
@@ -154,6 +334,7 @@ export class ExportManager extends HTMLElement {
if (this.pollTimer) return;
this.pollTimer = window.setInterval(() => {
this.refreshList();
this.refreshFts5Status();
}, this.pollIntervalMs);
}
@@ -163,6 +344,68 @@ export class ExportManager extends HTMLElement {
this.pollTimer = null;
}
async handleFts5Rebuild(event) {
event.preventDefault();
if (!this.fts5RebuildUrl) return;
const button = event.target.closest("[data-role='fts5-rebuild']");
if (button) button.disabled = true;
if (this.fts5Progress) {
this.fts5Progress.classList.remove("hidden");
}
if (this.fts5ProgressText) {
this.fts5ProgressText.textContent = "Wird vorbereitet...";
}
if (this.fts5ProgressPercent) {
this.fts5ProgressPercent.textContent = "";
}
if (this.fts5ProgressBar) {
this.fts5ProgressBar.style.width = "0%";
}
const payload = new URLSearchParams();
payload.set("csrf_token", this.getCsrfToken());
try {
const response = await fetch(this.fts5RebuildUrl, {
method: "POST",
body: payload,
credentials: "same-origin",
});
if (!response.ok) {
const message = await this.extractError(response);
if (this.fts5Status) {
this.fts5Status.textContent = message || "FTS5-Neuaufbau konnte nicht gestartet werden.";
this.fts5Status.classList.add("text-red-600");
}
return;
}
const json = await this.safeJson(response);
if (json && json.error) {
if (this.fts5Status) {
this.fts5Status.textContent = json.error;
this.fts5Status.classList.add("text-red-600");
}
return;
}
this.fts5HadRunning = true;
if (json && json.status === "restarting") {
this.updateFts5Status({
status: "restarting",
message: "FTS5-Neuaufbau wird neu gestartet.",
});
}
await this.refreshFts5Status();
this.startPolling();
} catch {
if (this.fts5Status) {
this.fts5Status.textContent = "FTS5-Neuaufbau konnte nicht gestartet werden.";
this.fts5Status.classList.add("text-red-600");
}
} finally {
if (button) button.disabled = false;
}
}
async safeJson(response) {
try {
return await response.json();
@@ -195,4 +438,13 @@ export class ExportManager extends HTMLElement {
}
return this.csrf;
}
normalizeText(value) {
if (value == null) return "";
let text = String(value).trim();
if (text.length >= 2 && text.startsWith("\"") && text.endsWith("\"")) {
text = text.slice(1, -1);
}
return text;
}
}

View File

@@ -339,8 +339,8 @@ export class FabMenu extends HTMLElement {
</div>
<div class="grid grid-cols-[1fr_auto] group">
<a href="/redaktion/exports/" class="flex items-center px-3 py-1.5 group-hover:bg-gray-100 transition-colors no-underline text-sm">
<i class="ri-download-2-line text-base text-gray-700 mr-2.5"></i>
<span class="text-gray-900">Datenexport</span>
<i class="ri-tools-line text-base text-gray-700 mr-2.5"></i>
<span class="text-gray-900">Daten &amp; Suche</span>
</a>
<a href="/redaktion/exports/" target="_blank" class="flex items-center justify-center px-2.5 py-1.5 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
<i class="ri-external-link-line text-base"></i>