All checks were successful
release-tag / release-image (push) Successful in 1m35s
469 lines
19 KiB
JavaScript
469 lines
19 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||
|
||
const state = {
|
||
page: 1,
|
||
pageSize: 60,
|
||
total: 0,
|
||
totalPages: 0,
|
||
items: [],
|
||
selected: new Set(),
|
||
currentKey: null,
|
||
currentDoc: null,
|
||
currentMeta: null,
|
||
dirty: false,
|
||
activeTab: 'form',
|
||
lastBulkPreviewSignature: '',
|
||
};
|
||
|
||
const els = {
|
||
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
|
||
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
|
||
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
|
||
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
|
||
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
|
||
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
|
||
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
|
||
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
|
||
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
|
||
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
|
||
};
|
||
|
||
let searchTimer;
|
||
|
||
async function api(url, options = {}) {
|
||
const res = await fetch(url, options);
|
||
const contentType = res.headers.get('content-type') || '';
|
||
const body = contentType.includes('application/json') ? await res.json() : await res.text();
|
||
if (!res.ok) {
|
||
const msg = typeof body === 'object' && body?.error ? body.error : `${res.status} ${res.statusText}`;
|
||
throw new Error(msg);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function currentQuery(page = state.page) {
|
||
return {
|
||
q: els.searchInput.value.trim(),
|
||
auto_reply: els.autoReplyFilter.value,
|
||
language: els.languageFilter.value.trim(),
|
||
communication_style: els.styleFilter.value.trim(),
|
||
source: els.sourceFilter.value.trim(),
|
||
page,
|
||
page_size: state.pageSize,
|
||
};
|
||
}
|
||
|
||
function queryString(q) {
|
||
const p = new URLSearchParams();
|
||
Object.entries(q).forEach(([k, v]) => {
|
||
if (v !== '' && v !== null && v !== undefined && !(k === 'auto_reply' && v === 'any')) p.set(k, v);
|
||
});
|
||
return p.toString();
|
||
}
|
||
|
||
async function loadHealth() {
|
||
try {
|
||
const [h, config] = await Promise.all([api('/api/health'), api('/api/config')]);
|
||
if (config.title) {
|
||
els.brandTitle.textContent = config.title;
|
||
document.title = config.title;
|
||
}
|
||
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
|
||
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
|
||
els.healthPill.className = 'pill ok';
|
||
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
|
||
} catch (err) {
|
||
els.healthPill.textContent = 'Offline';
|
||
els.healthPill.className = 'pill';
|
||
toast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function loadList(resetPage = false) {
|
||
if (resetPage) state.page = 1;
|
||
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
|
||
try {
|
||
const data = await api(`/api/items?${queryString(currentQuery())}`);
|
||
state.page = data.page || 1;
|
||
state.total = data.total;
|
||
state.totalPages = data.total_pages;
|
||
state.items = data.items || [];
|
||
renderList();
|
||
} catch (err) {
|
||
els.resultList.innerHTML = `<div class="inline-error" style="margin:12px">${escapeHTML(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderList() {
|
||
els.resultList.innerHTML = '';
|
||
if (state.items.length === 0) {
|
||
els.resultList.innerHTML = '<div class="muted-text" style="padding:20px;text-align:center">Keine Treffer.</div>';
|
||
}
|
||
for (const item of state.items) {
|
||
const row = document.createElement('div');
|
||
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
|
||
row.dataset.key = item.key;
|
||
const checked = state.selected.has(item.key) ? 'checked' : '';
|
||
const auto = item.auto_reply === true;
|
||
row.innerHTML = `
|
||
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
|
||
<div>
|
||
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
|
||
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
|
||
<div class="result-meta">
|
||
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '–')}</span>
|
||
<span>score ${item.min_score ?? '–'}</span>
|
||
<span>${escapeHTML(item.language || '–')}</span>
|
||
<span>${escapeHTML(item.source || '')}</span>
|
||
</div>
|
||
</div>`;
|
||
const cb = $('.result-check', row);
|
||
cb.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleSelection(item.key, cb.checked);
|
||
});
|
||
row.addEventListener('click', () => openItem(item.key));
|
||
els.resultList.appendChild(row);
|
||
}
|
||
els.pageLabel.textContent = state.totalPages ? `Seite ${state.page} / ${state.totalPages}` : 'Seite 0 / 0';
|
||
els.totalLabel.textContent = `${state.total.toLocaleString('de-DE')} Treffer`;
|
||
els.prevPage.disabled = state.page <= 1;
|
||
els.nextPage.disabled = state.totalPages === 0 || state.page >= state.totalPages;
|
||
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
||
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
||
updateSelectionUI();
|
||
}
|
||
|
||
function toggleSelection(key, checked) {
|
||
if (checked) state.selected.add(key); else state.selected.delete(key);
|
||
renderSelectionOnly();
|
||
}
|
||
|
||
function renderSelectionOnly() {
|
||
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
|
||
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
|
||
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
||
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
||
}
|
||
|
||
function updateSelectionUI() { renderSelectionOnly(); }
|
||
|
||
async function openItem(key) {
|
||
if (key === state.currentKey) return;
|
||
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
|
||
try {
|
||
const data = await api(`/api/items/${encodeURIComponent(key)}`);
|
||
state.currentKey = key;
|
||
state.currentDoc = data.document;
|
||
state.currentMeta = data.meta;
|
||
state.dirty = false;
|
||
showEditor();
|
||
fillForm();
|
||
setTab('form');
|
||
renderList();
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function showEditor() {
|
||
els.emptyState.classList.add('hidden');
|
||
els.editor.classList.remove('hidden');
|
||
els.filePath.textContent = state.currentMeta?.rel_path || '–';
|
||
setDirty(false);
|
||
}
|
||
|
||
function fillForm() {
|
||
$$('[data-field]').forEach(input => {
|
||
const name = input.dataset.field;
|
||
const val = state.currentDoc?.[name];
|
||
if (input.type === 'checkbox') input.checked = Boolean(val);
|
||
else input.value = val ?? '';
|
||
});
|
||
$$('[data-list-field]').forEach(input => {
|
||
const val = state.currentDoc?.[input.dataset.listField];
|
||
input.value = Array.isArray(val) ? val.join('\n') : '';
|
||
});
|
||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||
clearRawError();
|
||
}
|
||
|
||
function syncFormToDoc() {
|
||
if (!state.currentDoc) return;
|
||
$$('[data-field]').forEach(input => {
|
||
const name = input.dataset.field;
|
||
if (input.type === 'checkbox') state.currentDoc[name] = input.checked;
|
||
else if (input.type === 'number') {
|
||
if (input.value === '') delete state.currentDoc[name];
|
||
else state.currentDoc[name] = Number(input.value);
|
||
} else state.currentDoc[name] = input.value;
|
||
});
|
||
$$('[data-list-field]').forEach(input => {
|
||
state.currentDoc[input.dataset.listField] = lines(input.value);
|
||
});
|
||
}
|
||
|
||
function syncRawToDoc() {
|
||
try {
|
||
const parsed = JSON.parse(els.rawEditor.value);
|
||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('Die JSON-Wurzel muss ein Objekt sein.');
|
||
state.currentDoc = parsed;
|
||
clearRawError();
|
||
return true;
|
||
} catch (err) {
|
||
els.rawError.textContent = `JSON-Fehler: ${err.message}`;
|
||
els.rawError.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function clearRawError() {
|
||
els.rawError.textContent = '';
|
||
els.rawError.classList.add('hidden');
|
||
}
|
||
|
||
function setDirty(v = true) {
|
||
state.dirty = v;
|
||
els.dirtyBadge.classList.toggle('hidden', !v);
|
||
}
|
||
|
||
function setTab(tab) {
|
||
if (tab === state.activeTab) return;
|
||
if (state.activeTab === 'raw' && tab === 'form') {
|
||
if (!syncRawToDoc()) return;
|
||
fillForm();
|
||
} else if (state.activeTab === 'form' && tab === 'raw') {
|
||
syncFormToDoc();
|
||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||
}
|
||
state.activeTab = tab;
|
||
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||
els.formTab.classList.toggle('active', tab === 'form');
|
||
els.rawTab.classList.toggle('active', tab === 'raw');
|
||
els.formatJsonBtn.classList.toggle('hidden', tab !== 'raw');
|
||
}
|
||
|
||
async function saveCurrent() {
|
||
if (!state.currentKey || !state.currentDoc) return;
|
||
if (state.activeTab === 'raw') {
|
||
if (!syncRawToDoc()) return;
|
||
} else syncFormToDoc();
|
||
|
||
els.saveBtn.disabled = true;
|
||
try {
|
||
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
|
||
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
|
||
});
|
||
state.currentMeta = result.meta;
|
||
setDirty(false);
|
||
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
||
await loadList(false);
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
els.saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openBulk() {
|
||
if (state.selected.size === 0 && state.total === 0) return;
|
||
resetBulkPreview();
|
||
els.bulkAllMatching.checked = state.selected.size === 0;
|
||
updateBulkTargetText();
|
||
els.bulkDialog.showModal();
|
||
}
|
||
|
||
function updateBulkTargetText() {
|
||
const all = els.bulkAllMatching.checked;
|
||
els.bulkTargetText.textContent = all
|
||
? `${state.total.toLocaleString('de-DE')} aktuelle Treffer als Ziel`
|
||
: `${state.selected.size.toLocaleString('de-DE')} explizit ausgewählte Dateien als Ziel`;
|
||
els.allMatchingHint.textContent = `Aktueller Filter: ${state.total.toLocaleString('de-DE')} Treffer`;
|
||
}
|
||
|
||
function buildPatch() {
|
||
const patch = {};
|
||
const enabled = id => $(`[data-enable="${id}"]`)?.checked;
|
||
if (enabled('bulkAutoReply')) patch.set_auto_reply = $('#bulkAutoReply').value === 'true';
|
||
if (enabled('bulkMinScore')) patch.set_min_score = Number($('#bulkMinScore').value);
|
||
if (enabled('bulkLanguage')) patch.set_language = $('#bulkLanguage').value;
|
||
if (enabled('bulkStyle')) patch.set_communication_style = $('#bulkStyle').value;
|
||
if (enabled('bulkSource')) patch.set_source = $('#bulkSource').value;
|
||
if (enabled('bulkSourceUri')) patch.set_source_uri = $('#bulkSourceUri').value;
|
||
|
||
const addK = lines($('#addKeywords').value), rmK = lines($('#removeKeywords').value);
|
||
const addC = lines($('#addCategories').value), rmC = lines($('#removeCategories').value);
|
||
if (addK.length) patch.add_keywords = addK;
|
||
if (rmK.length) patch.remove_keywords = rmK;
|
||
if (addC.length) patch.add_categories = addC;
|
||
if (rmC.length) patch.remove_categories = rmC;
|
||
|
||
const find = $('#replaceFind').value;
|
||
if (find) {
|
||
patch.find_replace = {
|
||
fields: $$('input[name="replaceField"]:checked').map(x => x.value),
|
||
find,
|
||
replace: $('#replaceWith').value,
|
||
regex: $('#replaceRegex').checked,
|
||
case_sensitive: $('#replaceCase').checked,
|
||
};
|
||
}
|
||
return patch;
|
||
}
|
||
|
||
function buildBulkRequest(dryRun) {
|
||
const q = currentQuery(1);
|
||
q.page = 0; q.page_size = 0;
|
||
return {
|
||
keys: Array.from(state.selected),
|
||
all_matching: els.bulkAllMatching.checked,
|
||
query: q,
|
||
patch: buildPatch(),
|
||
dry_run: dryRun,
|
||
};
|
||
}
|
||
|
||
async function previewBulk() {
|
||
const req = buildBulkRequest(true);
|
||
if (!Object.keys(req.patch).length) {
|
||
toast('Bitte mindestens eine Änderung festlegen.', 'error');
|
||
return;
|
||
}
|
||
els.previewBulkBtn.disabled = true;
|
||
try {
|
||
const result = await api('/api/bulk', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
|
||
});
|
||
state.lastBulkPreviewSignature = signatureFor(req);
|
||
renderBulkPreview(result);
|
||
els.applyBulkBtn.disabled = result.changed === 0;
|
||
} catch (err) {
|
||
els.bulkPreview.textContent = err.message;
|
||
els.bulkPreview.className = 'preview-box warn';
|
||
els.applyBulkBtn.disabled = true;
|
||
} finally {
|
||
els.previewBulkBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderBulkPreview(result) {
|
||
els.bulkPreview.className = `preview-box ${result.changed > 0 ? 'ok' : 'warn'}`;
|
||
els.bulkPreview.innerHTML = `
|
||
<strong>Vorschau:</strong> ${result.changed.toLocaleString('de-DE')} von ${result.targeted.toLocaleString('de-DE')} Dateien würden geändert,
|
||
${result.skipped.toLocaleString('de-DE')} bleiben unverändert.
|
||
${result.sample?.length ? `<div class="preview-samples">${result.sample.map(x => `<code>${escapeHTML(x.id || x.rel_path)} · ${escapeHTML(x.title || '')}</code>`).join('')}</div>` : ''}`;
|
||
}
|
||
|
||
async function applyBulk() {
|
||
const req = buildBulkRequest(false);
|
||
const sig = signatureFor({...req, dry_run: true});
|
||
if (sig !== state.lastBulkPreviewSignature) {
|
||
toast('Die Massenänderung wurde seit der Vorschau verändert. Bitte erneut Vorschau ausführen.', 'error');
|
||
els.applyBulkBtn.disabled = true;
|
||
return;
|
||
}
|
||
if (!confirm('Massenänderung jetzt wirklich auf die Zieldateien anwenden?')) return;
|
||
els.applyBulkBtn.disabled = true;
|
||
try {
|
||
const result = await api('/api/bulk', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
|
||
});
|
||
toast(`${result.changed.toLocaleString('de-DE')} Dateien geändert. Backup: ${shortPath(result.backup)}`, 'success');
|
||
els.bulkDialog.close();
|
||
state.selected.clear();
|
||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||
await Promise.all([loadList(true), loadHealth()]);
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function resetBulkPreview() {
|
||
state.lastBulkPreviewSignature = '';
|
||
els.bulkPreview.className = 'preview-box hidden';
|
||
els.bulkPreview.textContent = '';
|
||
els.applyBulkBtn.disabled = true;
|
||
}
|
||
|
||
function signatureFor(obj) { return JSON.stringify(obj); }
|
||
function lines(s) { return s.split(/\r?\n/).map(x => x.trim()).filter(Boolean); }
|
||
function shortPath(p) { if (!p) return '–'; const parts = p.split('/'); return parts.slice(-2).join('/'); }
|
||
function escapeHTML(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||
|
||
function toast(message, type = '') {
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${type}`;
|
||
el.textContent = message;
|
||
els.toastHost.appendChild(el);
|
||
setTimeout(() => el.remove(), 5000);
|
||
}
|
||
|
||
function debounceReload() {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => loadList(true), 240);
|
||
}
|
||
|
||
// Filters and navigation.
|
||
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
|
||
els.autoReplyFilter.addEventListener('change', () => loadList(true));
|
||
els.prevPage.addEventListener('click', () => { if (state.page > 1) { state.page--; loadList(); } });
|
||
els.nextPage.addEventListener('click', () => { if (state.page < state.totalPages) { state.page++; loadList(); } });
|
||
els.selectPage.addEventListener('change', () => {
|
||
for (const item of state.items) {
|
||
if (els.selectPage.checked) state.selected.add(item.key); else state.selected.delete(item.key);
|
||
}
|
||
renderList();
|
||
});
|
||
|
||
// Editor.
|
||
$$('[data-field], [data-list-field]').forEach(el => el.addEventListener('input', () => setDirty(true)));
|
||
els.rawEditor.addEventListener('input', () => { setDirty(true); clearRawError(); });
|
||
$$('.tab').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab)));
|
||
els.saveBtn.addEventListener('click', saveCurrent);
|
||
els.formatJsonBtn.addEventListener('click', () => {
|
||
if (syncRawToDoc()) {
|
||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||
setDirty(true);
|
||
}
|
||
});
|
||
|
||
// Bulk modal.
|
||
els.bulkBtn.addEventListener('click', openBulk);
|
||
els.bulkAllMatching.addEventListener('change', () => { updateBulkTargetText(); resetBulkPreview(); });
|
||
$$('[data-enable]').forEach(toggle => toggle.addEventListener('change', () => {
|
||
const target = document.getElementById(toggle.dataset.enable);
|
||
if (target) target.disabled = !toggle.checked;
|
||
resetBulkPreview();
|
||
}));
|
||
$$('#bulkDialog input, #bulkDialog textarea, #bulkDialog select').forEach(el => {
|
||
if (el !== els.bulkAllMatching && !el.hasAttribute('data-enable')) el.addEventListener('input', resetBulkPreview);
|
||
});
|
||
els.previewBulkBtn.addEventListener('click', previewBulk);
|
||
els.applyBulkBtn.addEventListener('click', applyBulk);
|
||
|
||
els.reloadBtn.addEventListener('click', async () => {
|
||
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Dateien neu einlesen?')) return;
|
||
try {
|
||
const r = await api('/api/reload', {method: 'POST', headers: {'Content-Type':'application/json'}, body:'{}'});
|
||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
|
||
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||
toast(`${r.count.toLocaleString('de-DE')} Dateien neu eingelesen.`, 'success');
|
||
await Promise.all([loadList(true), loadHealth()]);
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
});
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); }
|
||
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); els.searchInput.focus(); }
|
||
});
|
||
window.addEventListener('beforeunload', e => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||
|
||
loadHealth();
|
||
loadList(true);
|
||
})();
|