Files
imap-mail-filter-service/app/templates/filters.html
T
duffyduckandClaude Opus 5.5 d5cb0a83f3 new action: attach the message as PDF and file it
For senders whose mail body is the receipt (e.g. GoCardless payouts)
there is no attachment to file. The new action "Als PDF anhängen +
nach <Ordner>" renders the mail like a print from a mail client
(header block From/To/Date/Subject, then the content) and replaces it
in the target folder with: original content + PDF + the original mail
byte-for-byte as Original.eml, so the unaltered original is retained
(GoBD).

- Rendering with WeasyPrint. Images are fetched by us and inlined as
  data: URIs; WeasyPrint itself may only read data:. Fetching is
  restricted to http(s), public addresses (private, loopback,
  link-local and PDF_BLOCKED_NETWORKS, default 172.0.0.0/12, are
  refused; redirects are checked hop by hop), with time and size
  limits. cid: images come from the MIME parts.
- The replacement is APPENDed with the original flags and INTERNALDATE,
  verified in the target via its new Message-ID, and only then is the
  original deleted. Already converted mails are only moved.
- "PDF-Vorschau" in the rule dialog renders the newest matching mail
  read-only and opens it in the browser.
- List/log show 📄 targets; warnings for PDF + move/delete and for
  PDF + "Hat Anhang = Ja"; log folder search covers PDF targets.
- Docker image gets Pango and fonts (~316 MB total).

Tested with the real GoCardless mail (3 remote images, rendered both
locally and inside the built image) and a simulated IMAP server for
success, rejected APPEND, missing copy and already converted mails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:51:26 +02:00

1027 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Filterregeln — IMAP Mail Filter{% endblock %}
{% block content %}
<div class="page-head">
<h1>Filterregeln</h1>
<select id="account-select" class="account-pick" onchange="onAccountChange()" title="Konto">
<option value="">— Konto wählen —</option>
{% for acc in accounts %}
<option value="{{ acc.id }}" {{ 'selected' if selected_account_id == acc.id else '' }}>{{ acc.name }}</option>
{% endfor %}
</select>
<span id="filters-summary" class="muted small-text"></span>
<div class="toolbar-actions">
<button class="secondary outline" onclick="resetAllProcessed()" title="Verwirft den 'verarbeitet'-Marker aller Mails. Beim nächsten Poll werden alle Mails im Ordner neu gegen alle Regeln geprüft. Sicher — schon verschobene Mails liegen nicht mehr in der Quelle.">Alle neu prüfen</button>
<button onclick="openNewFilter()">+ Neue Regel</button>
</div>
</div>
<div id="toolbar" class="toolbar" hidden>
<input type="search" id="filter-search" placeholder="Suchen: Name, Absender, Ordner, „am (exakt)“ …" oninput="renderFilters()">
<div class="seg" id="quick-filter">
<button data-q="all" class="active" onclick="setQuick('all')">Alle</button>
<button data-q="active" onclick="setQuick('active')">Aktiv</button>
<button data-q="inactive" onclick="setQuick('inactive')">Inaktiv</button>
<button data-q="warn" onclick="setQuick('warn')">⚠ Warnungen</button>
<button data-q="never" onclick="setQuick('never')">Nie getroffen</button>
</div>
</div>
<div id="filters-container">
<p class="muted">Bitte ein Konto auswählen.</p>
</div>
<dialog id="filter-dialog">
<article class="rule-dialog">
<header>
<button aria-label="Close" rel="prev" onclick="closeDialog()"></button>
<h3 id="dialog-title">Neue Filterregel</h3>
</header>
<form id="filter-form" onsubmit="return false;">
<input type="hidden" id="filter-id" value="">
<section class="dlg-section">
<div class="grid-3">
<label class="span-2">
Name
<input type="text" id="filter-name" required placeholder="z.B. Rechnungen Hetzner">
</label>
<label>
Priorität <small class="muted">(niedriger = früher)</small>
<input type="number" id="filter-priority" placeholder="auto">
</label>
</div>
<div class="grid-3">
<label class="span-2">
Quellordner
<div role="group">
<input type="text" id="filter-source-folder" value="INBOX" readonly>
<button type="button" class="outline" onclick="openFolderBrowser('filter-source-folder')">Durchsuchen</button>
</div>
</label>
<div class="switches">
<label><input type="checkbox" role="switch" id="filter-enabled" checked> Aktiv</label>
<label title="Wenn diese Regel greift, werden nachfolgende Regeln für diese Mail nicht mehr geprüft."><input type="checkbox" role="switch" id="filter-stop-processing"> Danach stoppen</label>
</div>
</div>
</section>
<section class="dlg-section">
<h4>Bedingungen <small class="muted">— alle müssen zutreffen</small></h4>
<div id="conditions-list"></div>
<button type="button" class="outline small" onclick="addCondition(); refreshWarnings();">+ Bedingung</button>
</section>
<section class="dlg-section">
<h4>Aktionen</h4>
<div id="actions-list"></div>
<button type="button" class="outline small" onclick="addAction(); refreshWarnings();">+ Aktion</button>
</section>
<div id="dialog-warnings"></div>
<section class="dlg-section test-section">
<div class="test-head">
<h4>Regel testen</h4>
<span>
<button type="button" class="outline small" id="pdf-preview-btn" onclick="runPdfPreview()" hidden title="PDF der neuesten passenden Mail erzeugen und anzeigen — es wird nichts verändert">PDF-Vorschau</button>
<button type="button" class="outline small" id="test-btn" onclick="runTest()">Gegen Postfach testen</button>
</span>
</div>
<p class="muted small-text">Prüft die aktuelle (ungespeicherte) Regel nur lesend gegen die neuesten passenden Mails im Quellordner. Es wird nichts verschoben.</p>
<div id="test-result"></div>
</section>
<footer>
<div class="dlg-footer">
<button type="button" class="secondary outline" onclick="closeDialog()">Abbrechen</button>
<button type="button" id="save-btn" onclick="saveFilter()">Speichern</button>
</div>
</footer>
</form>
</article>
</dialog>
<dialog id="folder-browser">
<article style="min-width: 50vw; max-width: 700px;">
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('folder-browser').close()"></button>
<h3>Ordner auswählen</h3>
</header>
<div id="folder-tree" style="max-height: 400px; overflow-y: auto;">
<p aria-busy="true">Ordner werden geladen...</p>
</div>
<div id="folder-status" style="display:none; margin-top:0.5rem; padding:0.5rem; border-radius:4px; background: var(--pico-muted-border-color);"></div>
</article>
</dialog>
{% endblock %}
{% block scripts %}
<style>
.muted { color: var(--pico-muted-color); }
.small-text { font-size:0.85rem; }
.page-head { display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap; margin-bottom:0.75rem; }
.page-head h1 { margin:0; }
.page-head .account-pick { width:auto; min-width:200px; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
.page-head #filters-summary { flex:1; }
.toolbar-actions { display:flex; gap:0.4rem; }
.toolbar-actions button { margin:0; padding:0.35rem 0.8rem; font-size:0.85rem; }
.toolbar { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; margin-bottom:0.6rem; }
.toolbar input[type=search] { flex:1 1 280px; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
.seg { display:inline-flex; flex-wrap:wrap; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; background:#fff; }
.toolbar .seg button { transition:none; }
.toolbar .seg button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#374151;
margin:0; border-radius:0; padding:0.3rem 0.7rem; font-size:0.8rem; box-shadow:none; }
.toolbar .seg button + button { border-left:1px solid var(--app-border, #e3e6ea); }
.toolbar .seg button:hover { --pico-background-color:#f1f3f5; }
.toolbar .seg button.active { --pico-background-color:var(--pico-primary); --pico-color:var(--pico-primary-inverse); }
/* Kompakte Zeilenliste: eine Zeile pro Regel, feste Spalten */
.rules { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; font-size:0.85rem; }
.rules .r { display:grid; grid-template-columns:34px 38px minmax(140px, 1.1fr) minmax(0, 2.4fr) minmax(120px, 1fr) 140px 84px;
gap:0.6rem; align-items:center; padding:0.3rem 0.75rem; border-bottom:1px solid #eef0f3; min-height:36px; }
.rules .r:last-child { border-bottom:0; }
.rules .r:hover { background:#f8f9fb; }
.rules .r.head { background:#f6f7f9; font-size:0.72rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; color:var(--pico-muted-color); min-height:0; padding-top:0.35rem; padding-bottom:0.35rem; }
.rules .r.head:hover { background:#f6f7f9; }
.rules .r.disabled > *:not(.c-toggle):not(.c-btns) { opacity:0.45; }
.rules .r.warn { box-shadow:inset 3px 0 0 #e0a800; }
.rules input[role=switch] { margin:0; width:1.9em; height:1.05em; }
.c-prio { color:var(--pico-muted-color); font-variant-numeric:tabular-nums; text-align:right; }
.c-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.c-name .warn-ico { cursor:help; margin-left:0.25rem; }
.c-name .tag { font-size:0.68rem; font-weight:600; padding:0 0.35rem; border-radius:3px; margin-left:0.3rem; vertical-align:1px; }
.tag.stop { background:#fde2e1; color:#9b1c1c; }
.tag.src { background:#e8eefc; color:#274b9f; }
.c-cond { display:flex; gap:0.25rem; overflow:hidden; white-space:nowrap; mask-image:linear-gradient(to right, #000 92%, transparent); }
.chip { font-size:0.75rem; padding:0 0.4rem; border-radius:3px; background:#f1f3f5; border:1px solid #e6e9ed; white-space:nowrap; line-height:1.55; }
.chip b { font-weight:600; }
.chip.neg { background:#fff4e5; border-color:#ffd8a8; }
.c-target { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#1f6f43; }
.c-stat { font-size:0.78rem; color:var(--pico-muted-color); white-space:nowrap; text-align:right; font-variant-numeric:tabular-nums; }
.c-stat strong { color:var(--pico-color); }
.c-stat.never { color:#b36b00; }
.c-btns { display:flex; justify-content:flex-end; gap:0.1rem; }
.c-btns button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:var(--pico-muted-color);
margin:0; padding:0.15rem 0.35rem; font-size:0.95rem; line-height:1; box-shadow:none; }
.c-btns button:hover { --pico-background-color:#eef0f3; --pico-color:var(--pico-color); }
.c-btns button.del:hover { --pico-color:#b42318; }
dialog article.rule-dialog { width:min(960px, 96vw); max-width:none; }
.dlg-section { margin-bottom:1.25rem; }
.dlg-section h4 { margin-bottom:0.5rem; }
.grid-3 { display:grid; grid-template-columns:repeat(3, 1fr); gap:0 1rem; }
.grid-3 .span-2 { grid-column:span 2; }
.switches { display:flex; flex-direction:column; justify-content:center; gap:0.25rem; padding-top:1.4rem; }
.switches label { margin-bottom:0; }
.row-line { display:grid; gap:0.4rem; align-items:center; margin-bottom:0.4rem; }
.cond-row .row-line { grid-template-columns:150px 170px 1fr auto auto; }
.act-row .row-line { grid-template-columns:240px 1fr auto; }
.row-line select, .row-line input { margin-bottom:0; }
.row-line .icon-btn { margin:0; padding:0.3rem 0.6rem; }
.neg-toggle { margin:0; font-size:0.8rem; white-space:nowrap; }
.dlg-footer { display:flex; justify-content:flex-end; gap:0.5rem; }
.dlg-footer button { margin:0; }
#dialog-warnings .warn-box { border-left:4px solid #e0a800; background:#fff8e6; padding:0.6rem 0.9rem; border-radius:4px; margin-bottom:1rem; font-size:0.9rem; }
#dialog-warnings ul { margin:0.25rem 0 0; padding-left:1.1rem; }
.test-section { border-top:1px solid var(--pico-muted-border-color); padding-top:1rem; }
.test-head { display:flex; justify-content:space-between; align-items:center; }
.test-head h4 { margin:0; }
.test-head button { margin:0; }
.test-summary { font-size:0.9rem; margin:0.5rem 0; }
.test-list { max-height:260px; overflow-y:auto; border:1px solid var(--pico-muted-border-color); border-radius:4px; }
.test-item { padding:0.4rem 0.7rem; font-size:0.82rem; border-bottom:1px solid var(--pico-muted-border-color); }
.test-item:last-child { border-bottom:0; }
.test-item.ok { background:#eefaf2; }
.test-item .reason { color:#9b1c1c; }
.test-item .meta { color:var(--pico-muted-color); }
.folder-row { display:flex; align-items:center; padding:0.35rem 0.5rem; border-radius:4px; cursor:pointer; transition:background 0.15s; }
.folder-row:hover { background:var(--pico-primary-focus); }
.folder-row .folder-icon { flex-shrink:0; width:1.4em; margin-right:0.4rem; }
.folder-row .folder-label { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.folder-row .folder-path { font-size:0.75rem; opacity:0.45; margin-left:0.5rem; white-space:nowrap; }
.folder-row .folder-add-btn { flex-shrink:0; background:none; border:none; cursor:pointer; font-size:1.1rem; padding:0.1rem 0.3rem; border-radius:4px; opacity:0.5; color:var(--pico-color); }
.folder-row .folder-add-btn:hover { opacity:1; background:var(--pico-primary-focus); }
.folder-new-input { display:flex; align-items:center; gap:0.3rem; padding:0.25rem 0; }
.folder-new-input input { margin-bottom:0; padding:0.3rem 0.5rem; font-size:0.9rem; height:auto; }
.folder-new-input button { margin-bottom:0; padding:0.3rem 0.6rem; font-size:0.85rem; white-space:nowrap; }
@media (max-width: 1000px) {
.rules .r { grid-template-columns:34px minmax(0,1fr) 84px; }
.rules .r .c-prio, .rules .r .c-stat, .rules .r.head { display:none; }
.rules .r .c-cond, .rules .r .c-target { grid-column:2 / 3; }
.rules .r .c-btns { grid-row:1; grid-column:3; }
}
@media (max-width: 760px) {
.grid-3 { grid-template-columns:1fr; }
.grid-3 .span-2 { grid-column:auto; }
.switches { padding-top:0; flex-direction:row; gap:1rem; }
.cond-row .row-line { grid-template-columns:1fr 1fr; }
.cond-row .cond-value-container { grid-column:1 / -1; }
.act-row .row-line { grid-template-columns:1fr auto; }
.act-row .act-param-group { grid-column:1 / -1; }
}
</style>
<script>
const FIELDS = [
{value: 'from', label: 'Von', type: 'text'},
{value: 'to', label: 'An', type: 'text'},
{value: 'subject', label: 'Betreff', type: 'text'},
{value: 'body', label: 'Text', type: 'text'},
{value: 'has_attachment', label: 'Hat Anhang', type: 'bool'},
{value: 'date', label: 'Datum', type: 'date'},
];
const TEXT_MATCH_TYPES = [
{value: 'contains', label: 'enthält'},
{value: 'regex', label: 'Regex'},
{value: 'exact', label: 'exakt'},
];
const DATE_MATCH_TYPES = [
{value: 'after', label: 'ab / nach', inputType: 'date', placeholder: ''},
{value: 'before', label: 'vor', inputType: 'date', placeholder: ''},
{value: 'on_date', label: 'am (exakt)', inputType: 'date', placeholder: ''},
{value: 'date_range', label: 'Zeitraum (von–bis)', inputType: 'daterange', placeholder: ''},
{value: 'year', label: 'im Jahr', inputType: 'number', placeholder: 'z.B. 2026'},
{value: 'last_n_days', label: 'letzte N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'last_n_weeks', label: 'letzte N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'last_n_months', label: 'letzte N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
{value: 'older_than_days', label: 'älter als N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'older_than_weeks', label: 'älter als N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'older_than_months', label: 'älter als N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
];
const ALL_MATCH_TYPES = [...TEXT_MATCH_TYPES, ...DATE_MATCH_TYPES];
const ACTION_TYPES = [
{value: 'move', label: 'Verschieben nach', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'},
{value: 'pdf', label: 'Als PDF anhängen + nach', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'},
{value: 'forward', label: 'Weiterleiten an', needsParam: true, needsFolder: false, paramLabel: 'E-Mail-Adresse'},
{value: 'delete', label: 'Löschen (in Papierkorb)', needsParam: false, needsFolder: false},
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false, needsFolder: false},
];
const ACCOUNT_KEY = 'mailfilter.account';
let currentFilters = [];
let quickFilter = 'all';
let lastTest = null;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fieldLabel(v) { return FIELDS.find(x => x.value === v)?.label || v; }
function matchLabel(v) { return ALL_MATCH_TYPES.find(x => x.value === v)?.label || v; }
function actionLabel(v) { return ACTION_TYPES.find(x => x.value === v)?.label || v; }
function parseUtc(iso) {
// Der Server liefert UTC-Zeitstempel ohne Zeitzone — ohne 'Z' würde der
// Browser sie als Ortszeit lesen und die Uhrzeit um 1–2 h verschieben.
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
}
function fmtDate(iso) {
if (!iso) return '';
return parseUtc(iso).toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit', year:'2-digit'});
}
function fmtDateTime(iso, withYear = true) {
if (!iso) return '';
const d = parseUtc(iso);
const date = d.toLocaleDateString('de-DE', withYear ? {day:'2-digit', month:'2-digit', year:'2-digit'} : {day:'2-digit', month:'2-digit'});
const time = d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
return `${date} ${time}`;
}
function fmtDateValue(v) {
// '2026-01-01' → '01.01.2026'; Zeitraum 'a,b' → 'a – b'
return String(v || '').split(',').map(p => {
const m = p.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${m[3]}.${m[2]}.${m[1]}` : p.trim();
}).join(' – ');
}
async function readJson(resp) {
const text = await resp.text();
try { return JSON.parse(text); } catch { return {detail: text}; }
}
// ---------- Warnungen ----------
function ruleWarnings(r) {
const w = [];
const conds = r.conditions || [];
const acts = r.actions || [];
if (!conds.length) w.push('Keine Bedingungen — die Regel greift nie.');
if (!acts.length) w.push('Keine Aktion — ein Treffer bewirkt nichts.');
const seen = new Set();
for (const c of conds) {
const key = [c.field, c.match_type, (c.value || '').trim().toLowerCase(), !!c.negate].join('|');
if (seen.has(key)) { w.push('Doppelte Bedingung: ' + fieldLabel(c.field) + ' ' + matchLabel(c.match_type) + ' „' + c.value + '“.'); }
seen.add(key);
const v = (c.value || '').trim();
if ((c.field === 'from' || c.field === 'to') && c.match_type !== 'regex' && (v.includes('<') || (v.includes('@') && /\s/.test(v)))) {
w.push(fieldLabel(c.field) + ': „' + v + '“ enthält einen Anzeigenamen. Besser nur die Adresse eintragen (Anzeigenamen variieren).');
}
if (c.field === 'date' && c.match_type === 'on_date') {
w.push('Datum „am (exakt) ' + fmtDateValue(v) + '“ greift nur an genau diesem Tag. Gemeint war vermutlich „ab / nach“.');
}
if (c.field !== 'date' && c.field !== 'has_attachment' && !v) {
w.push(fieldLabel(c.field) + ': leerer Wert.');
}
if (c.match_type === 'regex') {
try { new RegExp(v); } catch { w.push('Ungültiger Regex: „' + v + '“.'); }
}
}
for (const a of acts) {
if (a.action_type === 'move' && !a.parameter) w.push('Verschieben ohne Zielordner.');
if (a.action_type === 'pdf' && !a.parameter) w.push('„Als PDF anhängen“ ohne Zielordner.');
if (a.action_type === 'forward' && !a.parameter) w.push('Weiterleiten ohne Zieladresse.');
if ((a.action_type === 'move' || a.action_type === 'pdf') && a.parameter && a.parameter === r.source_folder) w.push('Zielordner ist gleich dem Quellordner.');
}
if (acts.some(a => a.action_type === 'pdf')) {
if (acts.some(a => a.action_type === 'move' || a.action_type === 'delete'))
w.push('„Als PDF anhängen“ legt die Mail bereits im Zielordner ab — ein zusätzliches Verschieben/Löschen ist nicht nötig und würde scheitern.');
if (conds.some(c => c.field === 'has_attachment' && c.value === 'true' && !c.negate))
w.push('„Hat Anhang = Ja“ passt nicht zu „Als PDF anhängen“: die PDF-Aktion ist für Mails gedacht, bei denen die Nachricht selbst der Beleg ist.');
}
return w;
}
// ---------- Liste ----------
function onAccountChange() {
const v = document.getElementById('account-select').value;
try { v ? localStorage.setItem(ACCOUNT_KEY, v) : localStorage.removeItem(ACCOUNT_KEY); } catch {}
loadFilters();
}
async function loadFilters() {
const accountId = document.getElementById('account-select').value;
const container = document.getElementById('filters-container');
const toolbar = document.getElementById('toolbar');
if (!accountId) {
toolbar.hidden = true;
document.getElementById('filters-summary').textContent = '';
container.innerHTML = '<p class="muted">Bitte ein Konto auswählen.</p>';
return;
}
toolbar.hidden = false;
container.innerHTML = '<p aria-busy="true">Regeln werden geladen…</p>';
const resp = await fetch(`/api/filters/account/${accountId}`);
currentFilters = resp.ok ? await resp.json() : [];
renderFilters();
}
function setQuick(q) {
quickFilter = q;
document.querySelectorAll('#quick-filter button').forEach(b => b.classList.toggle('active', b.dataset.q === q));
renderFilters();
}
function filterHaystack(f) {
const parts = [f.name, f.source_folder, f.stop_processing ? 'stop' : '', f.enabled ? 'aktiv' : 'inaktiv'];
for (const c of f.conditions || []) parts.push(c.field, c.match_type, c.value, fieldLabel(c.field), matchLabel(c.match_type), c.negate ? 'nicht not' : '');
for (const a of f.actions || []) parts.push(a.action_type, a.parameter || '', actionLabel(a.action_type));
return parts.join(' ').toLowerCase();
}
// Kurzform einer Bedingung für die Listenzeile, z.B. "Von: billing@hetzner.com",
// "Betreff = Rechnung", "📎", "ab 01.01.26"
const COND_ORDER = {from: 0, to: 1, subject: 2, body: 3, has_attachment: 4, date: 5};
const DATE_SHORT = {after: 'ab', before: 'vor', on_date: 'am', date_range: '', year: 'Jahr',
last_n_days: 'letzte Tage:', last_n_weeks: 'letzte Wochen:', last_n_months: 'letzte Monate:',
older_than_days: 'älter Tage:', older_than_weeks: 'älter Wochen:', older_than_months: 'älter Monate:'};
function condText(c) {
const neg = c.negate ? 'NICHT ' : '';
if (c.field === 'has_attachment') {
return ((c.value === 'true') !== !!c.negate) ? '📎 Anhang' : 'ohne Anhang';
}
if (c.field === 'date') return `${neg}${DATE_SHORT[c.match_type] ?? matchLabel(c.match_type)} ${fmtDateValue(c.value)}`.trim();
const op = c.match_type === 'exact' ? ' =' : c.match_type === 'regex' ? ' ~' : ':';
return `${neg}${fieldLabel(c.field)}${op} ${c.value}`;
}
function condChip(c) {
// Häufige Bedingungen in der Liste platzsparend darstellen
if (c.field === 'has_attachment') {
const yes = (c.value === 'true') !== !!c.negate;
return `<span class="chip" title="${yes ? 'mit Anhang' : 'ohne Anhang'}">${yes ? '📎' : '🚫📎'}</span>`;
}
if (c.field === 'date') {
const short = condText(c).replace(/(\d{2})\.(\d{2})\.\d{2}(\d{2})/g, '$1.$2.$3');
const warn = c.match_type === 'on_date' ? ' style="background:#fff3cd;border-color:#ffe08a"' : '';
return `<span class="chip${c.negate ? ' neg' : ''}"${warn} title="${esc(condText(c))}">${esc(short)}</span>`;
}
const text = condText(c);
const i = text.indexOf(': ');
const html = i > 0 && c.field !== 'date' ? `${esc(text.slice(0, i + 1))} <b>${esc(text.slice(i + 2))}</b>` : esc(text);
const cls = c.negate ? ' neg' : '';
const warn = c.field === 'date' && c.match_type === 'on_date' ? ' style="background:#fff3cd;border-color:#ffe08a"' : '';
return `<span class="chip${cls}"${warn}>${html}</span>`;
}
function targetText(f) {
return (f.actions || []).map(a => {
if (a.action_type === 'move') return '📁 ' + (a.parameter || '?').replace(/^INBOX[./]/, '');
if (a.action_type === 'pdf') return '📄 ' + (a.parameter || '?').replace(/^INBOX[./]/, '');
if (a.action_type === 'forward') return '↪ ' + (a.parameter || '?');
if (a.action_type === 'delete') return '🗑 Papierkorb';
if (a.action_type === 'mark_read') return '✓ gelesen';
return a.action_type;
}).join(', ') || '—';
}
function renderFilters() {
const container = document.getElementById('filters-container');
const filters = currentFilters;
const summary = document.getElementById('filters-summary');
if (!filters.length) {
summary.textContent = '';
container.innerHTML = `<article><p>Noch keine Filterregeln für dieses Konto.</p><button onclick="openNewFilter()">Erste Regel anlegen</button></article>`;
return;
}
const term = document.getElementById('filter-search').value.trim().toLowerCase();
const enriched = filters.map(f => ({f, warnings: ruleWarnings(f)}));
const visible = enriched.filter(({f, warnings}) => {
if (term && !filterHaystack(f).includes(term)) return false;
if (quickFilter === 'active') return f.enabled;
if (quickFilter === 'inactive') return !f.enabled;
if (quickFilter === 'warn') return warnings.length > 0;
if (quickFilter === 'never') return !f.match_count;
return true;
});
const nActive = filters.filter(f => f.enabled).length;
const nWarn = enriched.filter(e => e.warnings.length).length;
summary.innerHTML = `${filters.length} Regeln · ${nActive} aktiv` + (nWarn ? ` · <strong style="color:#8a6100">${nWarn} mit Warnung</strong>` : '') + (visible.length !== filters.length ? ` · ${visible.length} angezeigt` : '');
if (!visible.length) {
container.innerHTML = '<p class="muted"><em>Keine Regeln passen zu Suche/Filter.</em></p>';
return;
}
let html = `<div class="rules">
<div class="r head"><span></span><span style="text-align:right">Prio</span><span>Name</span><span>Bedingungen</span><span>Ziel</span><span style="text-align:right">Treffer</span><span></span></div>`;
for (const {f, warnings} of visible) {
const conds = [...(f.conditions || [])].sort((a, b) => (COND_ORDER[a.field] ?? 9) - (COND_ORDER[b.field] ?? 9));
const condTitle = conds.map(condText).join('\n');
const stat = f.match_count
? `<span class="c-stat" title="${f.match_count} Treffer, zuletzt am ${fmtDateTime(f.last_match_at)} Uhr"><strong>${f.match_count}</strong> · ${fmtDateTime(f.last_match_at, false)}</span>`
: `<span class="c-stat never" title="Noch kein Treffer seit Einführung der Statistik">—</span>`;
html += `
<div class="r${f.enabled ? '' : ' disabled'}${warnings.length ? ' warn' : ''}" ondblclick="editFilter(${f.id})">
<span class="c-toggle"><input type="checkbox" role="switch" ${f.enabled ? 'checked' : ''} title="${f.enabled ? 'Aktiv' : 'Inaktiv'}" onchange="toggleEnabled(${f.id}, this.checked)"></span>
<span class="c-prio">${f.priority}</span>
<span class="c-name" title="${esc(f.name)}">${esc(f.name)}${f.stop_processing ? '<span class="tag stop" title="Nach Treffer keine weiteren Regeln">stop</span>' : ''}${f.source_folder !== 'INBOX' ? `<span class="tag src" title="Quellordner">${esc(f.source_folder)}</span>` : ''}${warnings.length ? `<span class="warn-ico" title="${esc(warnings.join('\n'))}">⚠️</span>` : ''}</span>
<span class="c-cond" title="${esc(condTitle)}">${conds.map(condChip).join('') || '<span class="chip">keine Bedingung</span>'}</span>
<span class="c-target" title="${esc(targetText(f))}">${esc(targetText(f))}</span>
${stat}
<span class="c-btns">
<button onclick="editFilter(${f.id})" title="Bearbeiten">✎</button>
<button onclick="duplicateFilter(${f.id})" title="Als Vorlage kopieren">⧉</button>
<button class="del" onclick="deleteFilter(${f.id})" title="Löschen">🗑</button>
</span>
</div>`;
}
html += '</div><p class="muted small-text" style="margin-top:0.4rem">Doppelklick auf eine Zeile öffnet die Regel. Regeln laufen von niedriger zu hoher Priorität.</p>';
container.innerHTML = html;
}
async function toggleEnabled(id, enabled) {
const resp = await fetch(`/api/filters/${id}/enabled`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({enabled}),
});
if (!resp.ok) {
const d = await readJson(resp);
alert('Fehler: ' + (d.detail || resp.status));
}
const f = currentFilters.find(x => x.id === id);
if (f && resp.ok) f.enabled = enabled;
renderFilters();
}
// ---------- Dialog: Bedingungen ----------
function addCondition(data = null) {
const list = document.getElementById('conditions-list');
const div = document.createElement('div');
div.className = 'cond-row';
const selectedField = FIELDS.find(f => f.value === (data?.field || 'from')) || FIELDS[0];
div.innerHTML = `
<div class="row-line">
<select class="cond-field" onchange="onCondFieldChange(this)">
${FIELDS.map(f => `<option value="${f.value}" data-type="${f.type}" ${selectedField.value === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
<div class="cond-match-container"></div>
<div class="cond-value-container"></div>
<label class="neg-toggle" title="Bedingung umkehren"><input type="checkbox" class="cond-negate" ${data?.negate ? 'checked' : ''} onchange="refreshWarnings()"> NICHT</label>
<button type="button" class="outline secondary icon-btn" title="Bedingung entfernen" onclick="this.closest('.cond-row').remove(); refreshWarnings();">✕</button>
</div>`;
list.appendChild(div);
updateConditionInputs(div, selectedField.type, data);
}
function onCondFieldChange(select) {
const row = select.closest('.cond-row');
updateConditionInputs(row, select.options[select.selectedIndex].dataset.type, null);
refreshWarnings();
}
function updateConditionInputs(row, fieldType, data) {
const matchContainer = row.querySelector('.cond-match-container');
const valueContainer = row.querySelector('.cond-value-container');
if (fieldType === 'date') {
const selected = DATE_MATCH_TYPES.find(m => m.value === (data?.match_type || 'after')) || DATE_MATCH_TYPES[0];
matchContainer.innerHTML = `<select class="cond-match" onchange="onDateMatchChange(this)">
${DATE_MATCH_TYPES.map(m => `<option value="${m.value}" ${selected.value === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>`;
renderDateValueInput(valueContainer, selected, data?.value || '');
} else if (fieldType === 'bool') {
matchContainer.innerHTML = `<select class="cond-match"><option value="exact" selected>ist</option></select>`;
valueContainer.innerHTML = `<select class="cond-value" onchange="refreshWarnings()">
<option value="true" ${data?.value !== 'false' ? 'selected' : ''}>Ja</option>
<option value="false" ${data?.value === 'false' ? 'selected' : ''}>Nein</option>
</select>`;
} else {
matchContainer.innerHTML = `<select class="cond-match" onchange="refreshWarnings()">
${TEXT_MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>`;
valueContainer.innerHTML = `<input type="text" class="cond-value" value="${esc(data?.value || '')}" placeholder="Wert, z.B. rechnung@firma.de" oninput="refreshWarnings()">`;
}
}
function onDateMatchChange(select) {
const row = select.closest('.cond-row');
const info = DATE_MATCH_TYPES.find(m => m.value === select.value);
renderDateValueInput(row.querySelector('.cond-value-container'), info, '');
refreshWarnings();
}
function renderDateValueInput(container, info, value) {
if (info.inputType === 'daterange') {
const [a, b] = value ? value.split(',') : ['', ''];
container.innerHTML = `<div style="display:flex; gap:0.3rem; align-items:center;">
<input type="date" class="cond-value-from" value="${esc(a || '')}" onchange="refreshWarnings()">
<span>–</span>
<input type="date" class="cond-value-to" value="${esc(b || '')}" onchange="refreshWarnings()">
</div>`;
} else if (info.inputType === 'date') {
container.innerHTML = `<input type="date" class="cond-value" value="${esc(value)}" onchange="refreshWarnings()">`;
} else {
container.innerHTML = `<input type="number" class="cond-value" value="${esc(value)}" placeholder="${esc(info.placeholder)}" min="1" oninput="refreshWarnings()">`;
}
}
// ---------- Dialog: Aktionen ----------
let actionSeq = 0;
function addAction(data = null) {
const list = document.getElementById('actions-list');
const div = document.createElement('div');
div.className = 'act-row';
const selected = ACTION_TYPES.find(a => a.value === (data?.action_type || 'move')) || ACTION_TYPES[0];
const inputId = `act_param_${++actionSeq}`;
div.innerHTML = `
<div class="row-line">
<select class="act-type" onchange="toggleActionParam(this)">
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-needs-folder="${a.needsFolder}" data-param-label="${a.paramLabel || ''}" ${selected.value === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
</select>
<div class="act-param-group" role="group" style="margin:0; ${selected.needsParam ? '' : 'visibility:hidden;'}">
<input type="text" id="${inputId}" name="${inputId}" class="act-param" value="${esc(data?.parameter || '')}" placeholder="${esc(selected.paramLabel || '')}" ${selected.needsFolder ? 'readonly' : ''} oninput="refreshWarnings()">
<button type="button" class="outline act-browse-btn" onclick="openFolderBrowser('${inputId}')" style="${selected.needsFolder ? '' : 'display:none;'}">Durchsuchen</button>
</div>
<button type="button" class="outline secondary icon-btn" title="Aktion entfernen" onclick="this.closest('.act-row').remove(); refreshWarnings();">✕</button>
</div>`;
list.appendChild(div);
}
function toggleActionParam(select) {
const opt = select.options[select.selectedIndex];
const group = select.closest('.act-row').querySelector('.act-param-group');
const input = group.querySelector('.act-param');
const needsParam = opt.dataset.needsParam === 'true';
const needsFolder = opt.dataset.needsFolder === 'true';
group.style.visibility = needsParam ? '' : 'hidden';
if (!needsParam) input.value = '';
input.placeholder = opt.dataset.paramLabel;
input.readOnly = needsFolder;
group.querySelector('.act-browse-btn').style.display = needsFolder ? '' : 'none';
refreshWarnings();
}
// ---------- Dialog: Öffnen / Sammeln / Speichern ----------
function resetDialog(title) {
document.getElementById('dialog-title').textContent = title;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
document.getElementById('test-result').innerHTML = '';
lastTest = null;
}
function nextPriority() {
const max = currentFilters.reduce((m, f) => Math.max(m, f.priority), -Infinity);
return isFinite(max) ? max + 10 : 10;
}
function openNewFilter() {
resetDialog('Neue Filterregel');
document.getElementById('filter-id').value = '';
document.getElementById('filter-name').value = '';
document.getElementById('filter-priority').value = nextPriority();
document.getElementById('filter-source-folder').value = 'INBOX';
document.getElementById('filter-enabled').checked = true;
document.getElementById('filter-stop-processing').checked = false;
addCondition({field: 'from', match_type: 'contains', value: ''});
addCondition({field: 'date', match_type: 'after', value: `${new Date().getFullYear()}-01-01`});
addAction({action_type: 'move'});
refreshWarnings();
document.getElementById('filter-dialog').showModal();
}
function fillDialog(f, title, keepId) {
resetDialog(title);
document.getElementById('filter-id').value = keepId ? f.id : '';
document.getElementById('filter-name').value = keepId ? f.name : `${f.name} (Kopie)`;
document.getElementById('filter-priority').value = keepId ? f.priority : nextPriority();
document.getElementById('filter-source-folder').value = f.source_folder;
document.getElementById('filter-enabled').checked = keepId ? f.enabled : true;
document.getElementById('filter-stop-processing').checked = f.stop_processing;
for (const c of f.conditions) addCondition(c);
for (const a of f.actions) addAction(a);
refreshWarnings();
document.getElementById('filter-dialog').showModal();
}
function editFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (f) fillDialog(f, 'Regel bearbeiten', true);
}
function duplicateFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (f) fillDialog(f, 'Neue Regel (Kopie)', false);
}
function closeDialog() {
document.getElementById('filter-dialog').close();
}
function collectConditions() {
const conditions = [];
for (const row of document.querySelectorAll('#conditions-list .cond-row')) {
const from = row.querySelector('.cond-value-from');
const to = row.querySelector('.cond-value-to');
const valInput = row.querySelector('.cond-value');
const value = (from && to) ? `${from.value},${to.value}` : (valInput ? valInput.value.trim() : '');
conditions.push({
field: row.querySelector('.cond-field').value,
match_type: row.querySelector('.cond-match').value,
value,
negate: row.querySelector('.cond-negate').checked,
});
}
return conditions;
}
function collectActions() {
const actions = [];
for (const row of document.querySelectorAll('#actions-list .act-row')) {
const input = row.querySelector('.act-param');
actions.push({action_type: row.querySelector('.act-type').value, parameter: input.value.trim() || null});
}
return actions;
}
function collectFormData() {
const prioRaw = document.getElementById('filter-priority').value.trim();
return {
account_id: parseInt(document.getElementById('account-select').value),
name: document.getElementById('filter-name').value.trim(),
priority: prioRaw === '' ? null : parseInt(prioRaw),
source_folder: document.getElementById('filter-source-folder').value,
stop_processing: document.getElementById('filter-stop-processing').checked,
enabled: document.getElementById('filter-enabled').checked,
conditions: collectConditions(),
actions: collectActions(),
};
}
function refreshWarnings() {
const data = collectFormData();
document.getElementById('pdf-preview-btn').hidden = !data.actions.some(a => a.action_type === 'pdf');
const w = ruleWarnings(data);
// Erkenntnisse aus dem letzten Test ergänzen
if (lastTest) {
const wantsAttachment = data.conditions.some(c => c.field === 'has_attachment' && c.value === 'true' && !c.negate);
if (wantsAttachment && lastTest.checked > 0 && lastTest.attachments_seen === 0) {
w.push(`Test: keine der ${lastTest.checked} geprüften Mails hatte einen Anhang. Kommen die Rechnungen evtl. als Link? Dann „Hat Anhang“ entfernen.`);
}
if (lastTest.checked > 0 && lastTest.matches.length === 0) {
w.push('Test: keine der geprüften Mails erfüllt alle Bedingungen.');
}
}
document.getElementById('dialog-warnings').innerHTML = w.length
? `<div class="warn-box"><strong>Hinweise</strong><ul>${w.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>`
: '';
}
async function saveFilter() {
const data = collectFormData();
if (!data.name) { alert('Bitte einen Namen eingeben.'); return; }
const blocking = ruleWarnings(data).filter(x => x.startsWith('Keine Bedingungen') || x.startsWith('Verschieben ohne'));
if (blocking.length && !confirm(blocking.join('\n') + '\n\nTrotzdem speichern?')) return;
const filterId = document.getElementById('filter-id').value;
const btn = document.getElementById('save-btn');
btn.setAttribute('aria-busy', 'true');
try {
const resp = await fetch(filterId ? `/api/filters/${filterId}` : '/api/filters/', {
method: filterId ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
if (!resp.ok) {
const d = await readJson(resp);
alert('Fehler: ' + (typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail)));
return;
}
closeDialog();
loadFilters();
} finally {
btn.removeAttribute('aria-busy');
}
}
// ---------- Test ----------
function hasPdfAction() {
return collectActions().some(a => a.action_type === 'pdf');
}
async function runPdfPreview() {
const data = collectFormData();
const out = document.getElementById('test-result');
const btn = document.getElementById('pdf-preview-btn');
if (!data.conditions.length) { out.innerHTML = '<p class="muted">Erst mindestens eine Bedingung anlegen.</p>'; return; }
// Fenster sofort öffnen, sonst blockt der Browser es als Popup
const win = window.open('', '_blank');
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
out.innerHTML = '<p class="muted small-text">PDF wird erzeugt (Bilder werden geladen)…</p>';
try {
const resp = await fetch('/api/filters/pdf-preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: data.account_id, source_folder: data.source_folder, conditions: data.conditions, limit: 40}),
});
if (!resp.ok) {
const d = await readJson(resp);
if (win) win.close();
out.innerHTML = `<p class="reason">${esc(typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail))}</p>`;
return;
}
const url = URL.createObjectURL(await resp.blob());
if (win) win.location = url; else window.open(url, '_blank');
out.innerHTML = `<p class="muted small-text">PDF-Vorschau geöffnet · Bilder: ${esc(resp.headers.get('X-Preview-Images') || '')}</p>`;
} catch (e) {
if (win) win.close();
out.innerHTML = `<p class="reason">Netzwerk-Fehler: ${esc(e.message)}</p>`;
} finally {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
async function runTest() {
const data = collectFormData();
const out = document.getElementById('test-result');
const btn = document.getElementById('test-btn');
if (!data.conditions.length) { out.innerHTML = '<p class="muted">Erst mindestens eine Bedingung anlegen.</p>'; return; }
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
out.innerHTML = '<p class="muted small-text">Postfach wird durchsucht…</p>';
try {
const resp = await fetch('/api/filters/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: data.account_id, source_folder: data.source_folder, conditions: data.conditions, limit: 40}),
});
const r = await readJson(resp);
if (!resp.ok) { out.innerHTML = `<p class="reason">Fehler: ${esc(typeof r.detail === 'string' ? r.detail : JSON.stringify(r.detail))}</p>`; return; }
lastTest = r;
const failed = Object.entries(r.failed_on || {}).sort((a, b) => b[1] - a[1])
.map(([k, n]) => `${esc(k === 'Datum' ? 'Datum' : fieldLabel(k))}: ${n}×`).join(', ');
let html = `<div class="test-summary">
<strong>${r.matches.length}</strong> von ${r.checked} geprüften Mails würden verschoben
<span class="muted">(${r.candidates} Kandidaten gefunden${r.candidates > r.checked ? ', neueste ' + r.checked + ' geprüft' : ''})</span>
${failed ? `<br><span class="muted">Gescheitert an — ${failed}</span>` : ''}
</div>`;
const items = [
...r.matches.map(m => ({...m, ok: true})),
...r.misses.map(m => ({...m, ok: false})),
];
if (items.length) {
html += '<div class="test-list">' + items.map(m => `
<div class="test-item${m.ok ? ' ok' : ''}">
${m.ok ? '✅' : '✖'} <strong>${esc(m.subject || '(kein Betreff)')}</strong>
<div class="meta">${esc(m.from)} · ${m.date ? fmtDate(m.date) : 'ohne Datum'}${m.has_attachment ? ' · 📎' : ''}</div>
${m.ok ? '' : `<div class="reason">${esc(m.reason || '')}</div>`}
</div>`).join('') + '</div>';
} else {
html += '<p class="muted small-text">Keine Mails gefunden, die zur ersten Bedingung passen. Stimmt der Absender/Betreff?</p>';
}
out.innerHTML = html;
refreshWarnings();
} catch (e) {
out.innerHTML = `<p class="reason">Netzwerk-Fehler: ${esc(e.message)}</p>`;
} finally {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
// ---------- Löschen / Zurücksetzen ----------
async function deleteFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (!confirm(`Regel „${f ? f.name : id}“ wirklich löschen?`)) return;
await fetch(`/api/filters/${id}`, {method: 'DELETE'});
loadFilters();
}
async function resetAllProcessed() {
if (!confirm('Alle Mails werden beim nächsten Poll erneut von allen Regeln geprüft. Fortfahren?')) return;
try {
const resp = await fetch('/api/filters/reset-all-processed', {method: 'POST'});
const d = await readJson(resp);
if (!resp.ok) { alert('Fehler: ' + (d.detail || resp.status)); return; }
alert(`${d.reset ?? 0} Einträge zurückgesetzt. Beim nächsten Poll wird alles neu geprüft.`);
} catch (e) {
alert('Netzwerk-Fehler: ' + e.message);
}
}
// --- Ordner-Browser (Baumansicht) ---
let folderTargetInputName = null;
let folderSeparator = '.';
async function openFolderBrowser(targetInputName) {
const accountId = document.getElementById('account-select').value;
if (!accountId) {
alert('Bitte zuerst ein Konto auswählen');
return;
}
folderTargetInputName = targetInputName;
document.getElementById('folder-status').style.display = 'none';
document.getElementById('folder-browser').showModal();
await refreshFolderList(accountId);
}
async function refreshFolderList(accountId) {
const container = document.getElementById('folder-tree');
container.innerHTML = '<p aria-busy="true">Ordner werden geladen...</p>';
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`);
const data = await resp.json();
// Detect separator: if any folder contains ".", use "."; otherwise "/"
folderSeparator = data.folders.some(f => f.includes('.')) ? '.' : '/';
renderFolderTree(data.folders);
} catch(e) {
container.innerHTML = `<p>Fehler beim Laden: ${e.message}</p>`;
}
}
function buildTree(folders) {
const root = {children: {}, folders: []};
for (const path of folders) {
const parts = path.split(folderSeparator);
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!node.children[part]) {
node.children[part] = {
name: part,
fullPath: parts.slice(0, i + 1).join(folderSeparator),
children: {},
depth: i,
};
}
node = node.children[part];
}
}
return root;
}
function renderFolderTree(folders) {
const container = document.getElementById('folder-tree');
if (!folders.length) {
container.innerHTML = '<p>Keine Ordner gefunden.</p>';
return;
}
const tree = buildTree(folders);
container.innerHTML = renderNode(tree, -1);
}
function renderNode(node, depth) {
let html = '';
const sortedKeys = Object.keys(node.children).sort();
for (const key of sortedKeys) {
const child = node.children[key];
const indent = (depth + 1) * 1.2;
const esc = child.fullPath.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const hasChildren = Object.keys(child.children).length > 0;
html += `<div class="folder-row" style="padding-left:${indent + 0.5}rem;" onclick="selectFolder('${esc}')">
<span class="folder-icon">${hasChildren ? '📂' : '📁'}</span>
<span class="folder-label">${child.name}</span>
${depth >= 0 ? `<span class="folder-path">${child.fullPath}</span>` : ''}
<button class="folder-add-btn" title="Unterordner erstellen" onclick="event.stopPropagation(); showNewSubfolder('${esc}', this)">📁+</button>
</div>`;
if (hasChildren) {
html += renderNode(child, depth + 1);
}
}
return html;
}
function selectFolder(folderName) {
const target = document.querySelector(`[name="${folderTargetInputName}"]`)
|| document.getElementById(folderTargetInputName);
if (target) target.value = folderName;
if (document.getElementById("filter-dialog").open) refreshWarnings();
document.getElementById('folder-browser').close();
}
function showNewSubfolder(parentPath, btn) {
// Remove any existing new-folder input
document.querySelectorAll('.folder-new-input').forEach(el => el.remove());
const row = btn.closest('.folder-row');
const inputRow = document.createElement('div');
inputRow.className = 'folder-new-input';
inputRow.style.paddingLeft = row.style.paddingLeft;
inputRow.style.marginLeft = '1.8rem';
inputRow.innerHTML = `
<span style="margin-right:0.3rem;">📁</span>
<input type="text" placeholder="Neuer Ordnername" autofocus>
<button type="button" class="outline small" onclick="doCreateSubfolder('${parentPath.replace(/'/g, "\\'")}', this)">Erstellen</button>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
`;
row.after(inputRow);
inputRow.querySelector('input').focus();
inputRow.querySelector('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
doCreateSubfolder(parentPath, inputRow.querySelector('.outline.small'));
}
if (e.key === 'Escape') inputRow.remove();
});
}
async function doCreateSubfolder(parentPath, btn) {
const input = btn.parentElement.querySelector('input');
const name = input.value.trim();
if (!name) return;
const fullPath = parentPath + folderSeparator + name;
const accountId = document.getElementById('account-select').value;
const status = document.getElementById('folder-status');
btn.setAttribute('aria-busy', 'true');
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({folder_name: fullPath}),
});
const data = await resp.json();
if (resp.ok) {
status.style.display = 'block';
status.textContent = `Ordner "${fullPath}" erstellt`;
setTimeout(() => status.style.display = 'none', 3000);
await refreshFolderList(accountId);
} else {
status.style.display = 'block';
status.textContent = 'Fehler: ' + (data.detail || JSON.stringify(data));
}
} catch(e) {
status.style.display = 'block';
status.textContent = 'Fehler: ' + e.message;
}
btn.removeAttribute('aria-busy');
}
// ---------- Init ----------
(function init() {
const sel = document.getElementById('account-select');
if (!sel.value) {
let saved = null;
try { saved = localStorage.getItem(ACCOUNT_KEY); } catch {}
if (saved && [...sel.options].some(o => o.value === saved)) sel.value = saved;
else if (sel.options.length === 2) sel.value = sel.options[1].value; // nur ein Konto
}
if (sel.value) loadFilters();
})();
</script>
{% endblock %}