mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
451 lines
13 KiB
JavaScript
451 lines
13 KiB
JavaScript
export class ExportManager extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
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;
|
|
}
|
|
|
|
connectedCallback() {
|
|
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() {
|
|
this.stopPolling();
|
|
}
|
|
|
|
setStatus(message, isError) {
|
|
if (!this.status) return;
|
|
this.status.textContent = message || "";
|
|
this.status.classList.remove("text-red-600", "text-green-600");
|
|
if (isError) {
|
|
this.status.classList.add("text-red-600");
|
|
} else if (message) {
|
|
this.status.classList.add("text-green-600");
|
|
}
|
|
}
|
|
|
|
async handleRun(event, exportType) {
|
|
event.preventDefault();
|
|
if (!this.runUrl) return;
|
|
this.setStatus("Export wird gestartet...");
|
|
const button = event.target.closest("[data-role='run-export']");
|
|
if (button) button.disabled = true;
|
|
|
|
const payload = new URLSearchParams();
|
|
payload.set("csrf_token", this.getCsrfToken());
|
|
payload.set("export_type", exportType || "data");
|
|
|
|
try {
|
|
const response = await fetch(this.runUrl, {
|
|
method: "POST",
|
|
body: payload,
|
|
credentials: "same-origin",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message = await this.extractError(response);
|
|
this.setStatus(message || "Export konnte nicht gestartet werden.", true);
|
|
return;
|
|
}
|
|
|
|
const json = await this.safeJson(response);
|
|
if (json && json.error) {
|
|
this.setStatus(json.error, true);
|
|
return;
|
|
}
|
|
|
|
this.setStatus("Export läuft im Hintergrund.");
|
|
await this.refreshList();
|
|
this.startPolling();
|
|
} catch (err) {
|
|
this.setStatus("Export konnte nicht gestartet werden.", true);
|
|
} finally {
|
|
if (button) button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async handleAction(event) {
|
|
const runTarget = event.target.closest("[data-role='run-export']");
|
|
if (runTarget) {
|
|
const exportType = runTarget.getAttribute("data-export-type") || "data";
|
|
await this.handleRun(event, exportType);
|
|
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");
|
|
if (action === "delete") {
|
|
const id = target.getAttribute("data-id");
|
|
if (!id || !this.deleteUrl) return;
|
|
const confirmed = confirm("Soll der Export wirklich gelöscht werden?");
|
|
if (!confirmed) return;
|
|
await this.deleteExport(id);
|
|
}
|
|
}
|
|
|
|
async deleteExport(id) {
|
|
const payload = new URLSearchParams();
|
|
payload.set("csrf_token", this.getCsrfToken());
|
|
try {
|
|
const response = await fetch(`${this.deleteUrl}${id}`, {
|
|
method: "POST",
|
|
body: payload,
|
|
credentials: "same-origin",
|
|
});
|
|
if (!response.ok) {
|
|
const message = await this.extractError(response);
|
|
this.setStatus(message || "Export konnte nicht gelöscht werden.", true);
|
|
return;
|
|
}
|
|
const json = await this.safeJson(response);
|
|
if (json && json.error) {
|
|
this.setStatus(json.error, true);
|
|
return;
|
|
}
|
|
this.setStatus("Export gelöscht.");
|
|
await this.refreshList();
|
|
} catch {
|
|
this.setStatus("Export konnte nicht gelöscht werden.", true);
|
|
}
|
|
}
|
|
|
|
async refreshList() {
|
|
if (!this.list || !this.listUrl) return;
|
|
try {
|
|
const response = await fetch(this.listUrl, { credentials: "same-origin" });
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const html = await response.text();
|
|
this.list.innerHTML = html;
|
|
this.syncPollingState();
|
|
} catch {
|
|
// ignore refresh errors
|
|
}
|
|
}
|
|
|
|
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() {
|
|
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();
|
|
}
|
|
}
|
|
|
|
startPolling() {
|
|
if (this.pollTimer) return;
|
|
this.pollTimer = window.setInterval(() => {
|
|
this.refreshList();
|
|
this.refreshFts5Status();
|
|
}, this.pollIntervalMs);
|
|
}
|
|
|
|
stopPolling() {
|
|
if (!this.pollTimer) return;
|
|
window.clearInterval(this.pollTimer);
|
|
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();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async extractError(response) {
|
|
try {
|
|
const json = await response.json();
|
|
if (json && json.error) {
|
|
return json.error;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
return await response.text();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
getCsrfToken() {
|
|
if (this.csrf) return this.csrf;
|
|
const fallback = document.querySelector("input[name='csrf_token']");
|
|
if (fallback && fallback.value) {
|
|
this.csrf = fallback.value;
|
|
}
|
|
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;
|
|
}
|
|
}
|