addes folder backup attachments logging
This commit is contained in:
@@ -34,6 +34,8 @@
|
||||
<input type="checkbox" name="use_ssl" {{ 'checked' if (not account or account.use_ssl) else '' }}>
|
||||
SSL verwenden
|
||||
</label>
|
||||
<button type="button" class="outline" onclick="testConnection()" id="test-btn">Verbindung testen</button>
|
||||
<div id="test-result" style="display:none; margin-bottom:1rem; padding:0.75rem; border-radius:4px;"></div>
|
||||
<label>
|
||||
Polling-Intervall (Sekunden)
|
||||
<input type="number" name="poll_interval_seconds" value="{{ account.poll_interval_seconds if account else 120 }}" min="30">
|
||||
@@ -77,6 +79,81 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function testConnection() {
|
||||
const form = document.getElementById('account-form');
|
||||
const host = form.imap_host.value;
|
||||
const port = parseInt(form.imap_port.value);
|
||||
const username = form.username.value;
|
||||
const password = form.password.value;
|
||||
const use_ssl = form.use_ssl.checked;
|
||||
const resultDiv = document.getElementById('test-result');
|
||||
const btn = document.getElementById('test-btn');
|
||||
|
||||
if (!host || !username) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = '#fff3cd';
|
||||
resultDiv.style.color = '#856404';
|
||||
resultDiv.textContent = 'Bitte Server und Benutzername ausfüllen.';
|
||||
return;
|
||||
}
|
||||
|
||||
{% if account %}
|
||||
// Bestehendes Konto: wenn kein neues Passwort eingegeben, über Account-ID testen
|
||||
if (!password) {
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.textContent = 'Teste...';
|
||||
resultDiv.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/accounts/{{ account.id }}/test', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = data.success ? '#d4edda' : '#f8d7da';
|
||||
resultDiv.style.color = data.success ? '#155724' : '#721c24';
|
||||
resultDiv.textContent = data.success ? 'Verbindung erfolgreich!' : 'Verbindung fehlgeschlagen.';
|
||||
} catch(e) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = '#f8d7da';
|
||||
resultDiv.style.color = '#721c24';
|
||||
resultDiv.textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
btn.removeAttribute('aria-busy');
|
||||
btn.textContent = 'Verbindung testen';
|
||||
return;
|
||||
}
|
||||
{% else %}
|
||||
if (!password) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = '#fff3cd';
|
||||
resultDiv.style.color = '#856404';
|
||||
resultDiv.textContent = 'Bitte Passwort eingeben.';
|
||||
return;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.textContent = 'Teste...';
|
||||
resultDiv.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/accounts/test-connection', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({imap_host: host, imap_port: port, username, password, use_ssl}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = data.success ? '#d4edda' : '#f8d7da';
|
||||
resultDiv.style.color = data.success ? '#155724' : '#721c24';
|
||||
resultDiv.textContent = data.success ? 'Verbindung erfolgreich!' : 'Verbindung fehlgeschlagen.';
|
||||
} catch(e) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = '#f8d7da';
|
||||
resultDiv.style.color = '#721c24';
|
||||
resultDiv.textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
btn.removeAttribute('aria-busy');
|
||||
btn.textContent = 'Verbindung testen';
|
||||
}
|
||||
|
||||
document.getElementById('account-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/accounts">Konten</a></li>
|
||||
<li><a href="/filters">Filter</a></li>
|
||||
<li><a href="/logs">Log</a></li>
|
||||
<li><a href="/yaml">YAML</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
<p><strong>Polling:</strong> alle {{ acc.poll_interval_seconds }}s</p>
|
||||
<p><strong>Letzter Poll:</strong> {{ acc.last_poll_at or "Noch nie" }}</p>
|
||||
<p><strong>Filterregeln:</strong> {{ acc.filter_rule_count }}</p>
|
||||
<p><strong>Verarbeitet:</strong> <span id="processed-{{ acc.id }}">...</span> Mails</p>
|
||||
<footer>
|
||||
<div role="group">
|
||||
<button class="outline" onclick="pollNow({{ acc.id }})">Jetzt prüfen</button>
|
||||
<button class="outline" onclick="testConnection({{ acc.id }})">Verbindungstest</button>
|
||||
<button class="outline contrast" onclick="resetProcessed({{ acc.id }}, '{{ acc.name }}')">Zurücksetzen</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
@@ -71,5 +73,33 @@ async function testConnection(accountId) {
|
||||
msg.textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetProcessed(accountId, name) {
|
||||
if (!confirm(`Verarbeitung für "${name}" zurücksetzen?\n\nAlle Mails werden beim nächsten Poll erneut gegen die Filterregeln geprüft.`)) return;
|
||||
const msg = document.getElementById('status-message');
|
||||
msg.style.display = 'block';
|
||||
try {
|
||||
const resp = await fetch(`/api/accounts/${accountId}/processed`, {method: 'DELETE'});
|
||||
const data = await resp.json();
|
||||
msg.textContent = data.message;
|
||||
loadProcessedCounts();
|
||||
} catch(e) {
|
||||
msg.textContent = 'Fehler: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProcessedCounts() {
|
||||
{% for acc in accounts %}
|
||||
fetch(`/api/accounts/{{ acc.id }}/processed`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const el = document.getElementById('processed-{{ acc.id }}');
|
||||
if (el) el.textContent = d.processed_count;
|
||||
})
|
||||
.catch(() => {});
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
loadProcessedCounts();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+394
-42
@@ -34,7 +34,10 @@
|
||||
</label>
|
||||
<label>
|
||||
Quellordner
|
||||
<input type="text" id="filter-source-folder" value="INBOX">
|
||||
<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>
|
||||
<label>
|
||||
@@ -59,27 +62,121 @@
|
||||
</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>
|
||||
.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;
|
||||
transition: opacity 0.15s;
|
||||
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;
|
||||
margin-bottom: 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;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const FIELDS = [
|
||||
{value: 'from', label: 'Von'},
|
||||
{value: 'to', label: 'An'},
|
||||
{value: 'subject', label: 'Betreff'},
|
||||
{value: 'body', label: 'Text'},
|
||||
{value: 'has_attachment', label: 'Hat Anhang'},
|
||||
{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 MATCH_TYPES = [
|
||||
const TEXT_MATCH_TYPES = [
|
||||
{value: 'contains', label: 'enthält'},
|
||||
{value: 'regex', label: 'Regex'},
|
||||
{value: 'exact', label: 'exakt'},
|
||||
];
|
||||
const DATE_MATCH_TYPES = [
|
||||
{value: 'on_date', label: 'am (exakt)', inputType: 'date', placeholder: ''},
|
||||
{value: 'before', label: 'vor', inputType: 'date', placeholder: ''},
|
||||
{value: 'after', label: 'ab / nach', inputType: 'date', placeholder: ''},
|
||||
{value: 'date_range', label: 'Zeitraum (von–bis)', inputType: 'daterange', placeholder: ''},
|
||||
{value: 'year', label: 'im Jahr', inputType: 'number', placeholder: 'z.B. 2025'},
|
||||
{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'},
|
||||
];
|
||||
// Combined for display in filter list
|
||||
const ALL_MATCH_TYPES = [...TEXT_MATCH_TYPES, ...DATE_MATCH_TYPES];
|
||||
const ACTION_TYPES = [
|
||||
{value: 'move', label: 'Verschieben in Ordner', needsParam: true, paramLabel: 'Zielordner'},
|
||||
{value: 'forward', label: 'Weiterleiten an', needsParam: true, paramLabel: 'E-Mail-Adresse'},
|
||||
{value: 'delete', label: 'Löschen', needsParam: false},
|
||||
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false},
|
||||
{value: 'move', label: 'Verschieben in Ordner', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'},
|
||||
{value: 'forward', label: 'Weiterleiten an', needsParam: true, needsFolder: false, paramLabel: 'E-Mail-Adresse'},
|
||||
{value: 'delete', label: 'Löschen', needsParam: false, needsFolder: false},
|
||||
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false, needsFolder: false},
|
||||
];
|
||||
|
||||
async function loadFilters() {
|
||||
@@ -102,7 +199,7 @@ async function loadFilters() {
|
||||
for (const f of filters) {
|
||||
const conds = f.conditions.map(c => {
|
||||
const fieldLabel = FIELDS.find(x => x.value === c.field)?.label || c.field;
|
||||
const matchLabel = MATCH_TYPES.find(x => x.value === c.match_type)?.label || c.match_type;
|
||||
const matchLabel = ALL_MATCH_TYPES.find(x => x.value === c.match_type)?.label || c.match_type;
|
||||
return `${c.negate ? 'NICHT ' : ''}${fieldLabel} ${matchLabel} "${c.value}"`;
|
||||
}).join('<br>');
|
||||
const acts = f.actions.map(a => {
|
||||
@@ -129,51 +226,143 @@ async function loadFilters() {
|
||||
|
||||
function addCondition(data = null) {
|
||||
const list = document.getElementById('conditions-list');
|
||||
const idx = list.children.length;
|
||||
const uid = Date.now() + '_' + list.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'grid';
|
||||
div.className = 'cond-row';
|
||||
div.style.marginBottom = '0.5rem';
|
||||
div.dataset.uid = uid;
|
||||
|
||||
const selectedField = FIELDS.find(f => f.value === (data?.field || 'from')) || FIELDS[0];
|
||||
|
||||
div.innerHTML = `
|
||||
<select name="cond_field_${idx}">
|
||||
${FIELDS.map(f => `<option value="${f.value}" ${data?.field === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
|
||||
</select>
|
||||
<select name="cond_match_${idx}">
|
||||
${MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" name="cond_value_${idx}" value="${data?.value || ''}" placeholder="Wert" required>
|
||||
<label style="white-space:nowrap"><input type="checkbox" name="cond_negate_${idx}" ${data?.negate ? 'checked' : ''}> NOT</label>
|
||||
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
|
||||
<div style="display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap;">
|
||||
<select class="cond-field" style="flex:0 0 auto; width:auto; margin-bottom:0;" onchange="onCondFieldChange(this)">
|
||||
${FIELDS.map(f => `<option value="${f.value}" data-type="${f.type}" ${data?.field === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
|
||||
</select>
|
||||
<div class="cond-match-container" style="flex:0 0 auto;"></div>
|
||||
<div class="cond-value-container" style="flex:1; min-width:150px;"></div>
|
||||
<label style="white-space:nowrap; margin-bottom:0;"><input type="checkbox" class="cond-negate" ${data?.negate ? 'checked' : ''} style="margin-bottom:0;"> NOT</label>
|
||||
<button type="button" class="outline small contrast" onclick="this.closest('.cond-row').remove()" style="margin-bottom:0;">X</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(div);
|
||||
updateConditionInputs(div, selectedField.type, data);
|
||||
}
|
||||
|
||||
function onCondFieldChange(select) {
|
||||
const row = select.closest('.cond-row');
|
||||
const opt = select.options[select.selectedIndex];
|
||||
const fieldType = opt.dataset.type;
|
||||
updateConditionInputs(row, fieldType, null);
|
||||
}
|
||||
|
||||
function updateConditionInputs(row, fieldType, data) {
|
||||
const matchContainer = row.querySelector('.cond-match-container');
|
||||
const valueContainer = row.querySelector('.cond-value-container');
|
||||
|
||||
if (fieldType === 'date') {
|
||||
// Date field: show date match types
|
||||
matchContainer.innerHTML = `
|
||||
<select class="cond-match" style="margin-bottom:0; width:auto;" onchange="onDateMatchChange(this)">
|
||||
${DATE_MATCH_TYPES.map(m => `<option value="${m.value}" data-input-type="${m.inputType}" data-placeholder="${m.placeholder}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
const matchSelect = matchContainer.querySelector('select');
|
||||
const selectedMatch = DATE_MATCH_TYPES.find(m => m.value === (data?.match_type || 'on_date')) || DATE_MATCH_TYPES[0];
|
||||
renderDateValueInput(valueContainer, selectedMatch, data?.value || '');
|
||||
} else if (fieldType === 'bool') {
|
||||
// Boolean (has_attachment): just true/false
|
||||
matchContainer.innerHTML = `
|
||||
<select class="cond-match" style="margin-bottom:0; width:auto;">
|
||||
<option value="exact" selected>ist</option>
|
||||
</select>
|
||||
`;
|
||||
valueContainer.innerHTML = `
|
||||
<select class="cond-value" style="margin-bottom:0;">
|
||||
<option value="true" ${data?.value === 'true' ? 'selected' : ''}>Ja</option>
|
||||
<option value="false" ${data?.value === 'false' ? 'selected' : ''}>Nein</option>
|
||||
</select>
|
||||
`;
|
||||
} else {
|
||||
// Text fields
|
||||
matchContainer.innerHTML = `
|
||||
<select class="cond-match" style="margin-bottom:0; width:auto;">
|
||||
${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="${data?.value || ''}" placeholder="Wert" required style="margin-bottom:0;">
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function onDateMatchChange(select) {
|
||||
const row = select.closest('.cond-row');
|
||||
const opt = select.options[select.selectedIndex];
|
||||
const inputType = opt.dataset.inputType;
|
||||
const placeholder = opt.dataset.placeholder;
|
||||
const matchInfo = DATE_MATCH_TYPES.find(m => m.value === select.value);
|
||||
const valueContainer = row.querySelector('.cond-value-container');
|
||||
renderDateValueInput(valueContainer, matchInfo, '');
|
||||
}
|
||||
|
||||
function renderDateValueInput(container, matchInfo, currentValue) {
|
||||
if (matchInfo.inputType === 'daterange') {
|
||||
// Two date inputs for range
|
||||
const parts = currentValue ? currentValue.split(',') : ['', ''];
|
||||
container.innerHTML = `
|
||||
<div style="display:flex; gap:0.3rem; align-items:center;">
|
||||
<input type="date" class="cond-value-from" value="${parts[0] || ''}" style="margin-bottom:0; flex:1;">
|
||||
<span>–</span>
|
||||
<input type="date" class="cond-value-to" value="${parts[1] || ''}" style="margin-bottom:0; flex:1;">
|
||||
</div>
|
||||
`;
|
||||
} else if (matchInfo.inputType === 'date') {
|
||||
container.innerHTML = `<input type="date" class="cond-value" value="${currentValue || ''}" style="margin-bottom:0;">`;
|
||||
} else if (matchInfo.inputType === 'number') {
|
||||
container.innerHTML = `<input type="number" class="cond-value" value="${currentValue || ''}" placeholder="${matchInfo.placeholder}" min="1" style="margin-bottom:0;">`;
|
||||
}
|
||||
}
|
||||
|
||||
function addAction(data = null) {
|
||||
const list = document.getElementById('actions-list');
|
||||
const idx = list.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'grid';
|
||||
div.style.marginBottom = '0.5rem';
|
||||
const selectedType = ACTION_TYPES.find(a => a.value === data?.action_type);
|
||||
const selectedType = ACTION_TYPES.find(a => a.value === (data?.action_type || 'move'));
|
||||
const hideParam = selectedType && !selectedType.needsParam && !data?.parameter;
|
||||
const inputId = `act_param_${Date.now()}_${idx}`;
|
||||
|
||||
div.innerHTML = `
|
||||
<select name="act_type_${idx}" onchange="toggleActionParam(this)">
|
||||
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-param-label="${a.paramLabel || ''}" ${data?.action_type === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" name="act_param_${idx}" value="${data?.parameter || ''}" placeholder="${selectedType?.paramLabel || 'Parameter'}" ${selectedType?.needsParam === false && !data?.parameter ? 'style=display:none' : ''}>
|
||||
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center;">
|
||||
<select name="act_type_${idx}" style="flex:0 0 auto; width:auto;" 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 || ''}" ${data?.action_type === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
|
||||
</select>
|
||||
<div class="act-param-group" style="flex:1; display:${hideParam ? 'none' : 'flex'}; gap:0.5rem; align-items:center;">
|
||||
<input type="text" name="${inputId}" value="${data?.parameter || ''}" placeholder="${selectedType?.paramLabel || 'Parameter'}" style="margin-bottom:0; flex:1;" ${selectedType?.needsFolder ? 'readonly' : ''}>
|
||||
<button type="button" class="outline act-browse-btn" onclick="openFolderBrowser('${inputId}')" style="${selectedType?.needsFolder ? '' : 'display:none;'} margin-bottom:0; white-space:nowrap;">Durchsuchen</button>
|
||||
</div>
|
||||
<button type="button" class="outline small contrast" onclick="this.closest('[style*=margin-bottom]').remove()" style="margin-bottom:0;">X</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
|
||||
function toggleActionParam(select) {
|
||||
const opt = select.options[select.selectedIndex];
|
||||
const input = select.parentElement.querySelector('input[type=text]');
|
||||
const row = select.closest('div');
|
||||
const group = row.querySelector('.act-param-group');
|
||||
const browseBtn = group.querySelector('.act-browse-btn');
|
||||
const input = group.querySelector('input[type=text]');
|
||||
if (opt.dataset.needsParam === 'true') {
|
||||
input.style.display = '';
|
||||
group.style.display = 'flex';
|
||||
input.placeholder = opt.dataset.paramLabel;
|
||||
input.readOnly = opt.dataset.needsFolder === 'true';
|
||||
} else {
|
||||
input.style.display = 'none';
|
||||
group.style.display = 'none';
|
||||
input.value = '';
|
||||
}
|
||||
browseBtn.style.display = opt.dataset.needsFolder === 'true' ? '' : 'none';
|
||||
}
|
||||
|
||||
function openNewFilter() {
|
||||
@@ -210,14 +399,26 @@ function collectFormData() {
|
||||
const condRows = document.getElementById('conditions-list').children;
|
||||
for (let i = 0; i < condRows.length; i++) {
|
||||
const row = condRows[i];
|
||||
const selects = row.querySelectorAll('select');
|
||||
const input = row.querySelector('input[type=text]');
|
||||
const checkbox = row.querySelector('input[type=checkbox]');
|
||||
const fieldSelect = row.querySelector('.cond-field');
|
||||
const matchSelect = row.querySelector('.cond-match');
|
||||
const negate = row.querySelector('.cond-negate');
|
||||
|
||||
// Get value based on input type
|
||||
let value = '';
|
||||
const rangeFrom = row.querySelector('.cond-value-from');
|
||||
const rangeTo = row.querySelector('.cond-value-to');
|
||||
if (rangeFrom && rangeTo) {
|
||||
value = rangeFrom.value + ',' + rangeTo.value;
|
||||
} else {
|
||||
const valInput = row.querySelector('.cond-value');
|
||||
value = valInput ? valInput.value : '';
|
||||
}
|
||||
|
||||
conditions.push({
|
||||
field: selects[0].value,
|
||||
match_type: selects[1].value,
|
||||
value: input.value,
|
||||
negate: checkbox.checked,
|
||||
field: fieldSelect.value,
|
||||
match_type: matchSelect.value,
|
||||
value: value,
|
||||
negate: negate ? negate.checked : false,
|
||||
});
|
||||
}
|
||||
const actions = [];
|
||||
@@ -225,10 +426,10 @@ function collectFormData() {
|
||||
for (let i = 0; i < actRows.length; i++) {
|
||||
const row = actRows[i];
|
||||
const select = row.querySelector('select');
|
||||
const input = row.querySelector('input[type=text]');
|
||||
const input = row.querySelector('.act-param-group input[type=text]');
|
||||
actions.push({
|
||||
action_type: select.value,
|
||||
parameter: input.value || null,
|
||||
parameter: input ? (input.value || null) : null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -277,6 +478,157 @@ async function deleteFilter(id) {
|
||||
loadFilters();
|
||||
}
|
||||
|
||||
// --- 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, '"');
|
||||
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;
|
||||
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');
|
||||
}
|
||||
|
||||
// Auto-load if account is pre-selected
|
||||
if (document.getElementById('account-select').value) loadFilters();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Verarbeitungslog — IMAP Mail Filter{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Verarbeitungslog</h1>
|
||||
|
||||
<div style="display:flex; gap:1rem; align-items:end; flex-wrap:wrap; margin-bottom:1rem;">
|
||||
<label style="margin-bottom:0;">
|
||||
Konto
|
||||
<select id="log-account" onchange="loadLogs()" style="margin-bottom:0;">
|
||||
<option value="">Alle Konten</option>
|
||||
{% for acc in accounts %}
|
||||
<option value="{{ acc.id }}">{{ acc.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label style="margin-bottom:0;">
|
||||
Level
|
||||
<select id="log-level" onchange="loadLogs()" style="margin-bottom:0;">
|
||||
<option value="">Alle</option>
|
||||
<option value="success">Erfolg</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warnung</option>
|
||||
<option value="error">Fehler</option>
|
||||
</select>
|
||||
</label>
|
||||
<div role="group" style="margin-bottom:0;">
|
||||
<button class="outline small" onclick="loadLogs()">Aktualisieren</button>
|
||||
<button class="outline small contrast" onclick="clearLogs()">Log leeren</button>
|
||||
</div>
|
||||
<label style="margin-bottom:0; margin-left:auto;">
|
||||
<input type="checkbox" id="auto-refresh" onchange="toggleAutoRefresh()" style="margin-bottom:0;">
|
||||
Auto-Refresh (5s)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="log-stats" style="margin-bottom:1rem;"></div>
|
||||
|
||||
<div id="log-container">
|
||||
<p aria-busy="true">Logs werden geladen...</p>
|
||||
</div>
|
||||
|
||||
<div id="log-paging" style="display:flex; justify-content:center; gap:1rem; margin-top:1rem;"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
.log-table {
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.log-table th, .log-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
.log-row { border-left: 3px solid transparent; }
|
||||
.log-row[data-level="success"] { border-left-color: #28a745; }
|
||||
.log-row[data-level="info"] { border-left-color: #17a2b8; }
|
||||
.log-row[data-level="warning"] { border-left-color: #ffc107; }
|
||||
.log-row[data-level="error"] { border-left-color: #dc3545; }
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.log-level-success { background: #d4edda; color: #155724; }
|
||||
.log-level-info { background: #d1ecf1; color: #0c5460; }
|
||||
.log-level-warning { background: #fff3cd; color: #856404; }
|
||||
.log-level-error { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.log-detail { font-size: 0.8rem; opacity: 0.7; }
|
||||
.log-mail-info { font-size: 0.8rem; color: var(--pico-muted-color); }
|
||||
</style>
|
||||
<script>
|
||||
let currentOffset = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
let refreshTimer = null;
|
||||
|
||||
async function loadLogs(offset = 0) {
|
||||
currentOffset = offset;
|
||||
const accountId = document.getElementById('log-account').value;
|
||||
const level = document.getElementById('log-level').value;
|
||||
const container = document.getElementById('log-container');
|
||||
|
||||
let url = `/api/logs/?limit=${PAGE_SIZE}&offset=${offset}`;
|
||||
if (accountId) url += `&account_id=${accountId}`;
|
||||
if (level) url += `&level=${level}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
renderStats(data.total);
|
||||
renderLogs(data.logs);
|
||||
renderPaging(data.total, offset);
|
||||
} catch(e) {
|
||||
container.innerHTML = `<p>Fehler beim Laden: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(total) {
|
||||
document.getElementById('log-stats').innerHTML = `<small>${total} Einträge gesamt</small>`;
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const container = document.getElementById('log-container');
|
||||
if (!logs.length) {
|
||||
container.innerHTML = '<article><p>Keine Log-Einträge vorhanden.</p></article>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<table class="log-table">
|
||||
<thead><tr>
|
||||
<th style="width:140px;">Zeit</th>
|
||||
<th style="width:60px;">Level</th>
|
||||
<th style="width:120px;">Konto</th>
|
||||
<th>Nachricht</th>
|
||||
<th style="width:120px;">Regel</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const log of logs) {
|
||||
const time = log.created_at ? new Date(log.created_at).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}) : '';
|
||||
|
||||
const levelClass = `log-level-${log.level}`;
|
||||
const levelLabel = {success: 'OK', info: 'Info', warning: 'Warn', error: 'Fehler'}[log.level] || log.level;
|
||||
|
||||
let message = escapeHtml(log.message);
|
||||
if (log.mail_subject || log.mail_from) {
|
||||
message += `<div class="log-mail-info">`;
|
||||
if (log.mail_from) message += `Von: ${escapeHtml(log.mail_from)}<br>`;
|
||||
if (log.mail_subject) message += `Betreff: ${escapeHtml(log.mail_subject)}`;
|
||||
message += `</div>`;
|
||||
}
|
||||
if (log.details) {
|
||||
message += `<div class="log-detail">${escapeHtml(log.details)}</div>`;
|
||||
}
|
||||
|
||||
html += `<tr class="log-row" data-level="${log.level}">
|
||||
<td><small>${time}</small></td>
|
||||
<td><span class="log-level ${levelClass}">${levelLabel}</span></td>
|
||||
<td><small>${escapeHtml(log.account_name || '')}</small></td>
|
||||
<td>${message}</td>
|
||||
<td><small>${escapeHtml(log.rule_name || '')}</small></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPaging(total, offset) {
|
||||
const paging = document.getElementById('log-paging');
|
||||
if (total <= PAGE_SIZE) {
|
||||
paging.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const page = Math.floor(offset / PAGE_SIZE) + 1;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
let html = '';
|
||||
if (offset > 0) {
|
||||
html += `<button class="outline small" onclick="loadLogs(${offset - PAGE_SIZE})">Neuere</button>`;
|
||||
}
|
||||
html += `<small>Seite ${page} / ${totalPages}</small>`;
|
||||
if (offset + PAGE_SIZE < total) {
|
||||
html += `<button class="outline small" onclick="loadLogs(${offset + PAGE_SIZE})">Ältere</button>`;
|
||||
}
|
||||
paging.innerHTML = html;
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
if (!confirm('Log wirklich leeren?')) return;
|
||||
const accountId = document.getElementById('log-account').value;
|
||||
let url = '/api/logs/';
|
||||
if (accountId) url += `?account_id=${accountId}`;
|
||||
await fetch(url, {method: 'DELETE'});
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (document.getElementById('auto-refresh').checked) {
|
||||
refreshTimer = setInterval(() => loadLogs(currentOffset), 5000);
|
||||
} else {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadLogs();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+59
-13
@@ -1,30 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}YAML — IMAP Mail Filter{% endblock %}
|
||||
{% block title %}Import / Export — IMAP Mail Filter{% endblock %}
|
||||
{% block content %}
|
||||
<h1>YAML Import / Export</h1>
|
||||
<h1>Import / Export</h1>
|
||||
|
||||
<h2>Komplett-Backup</h2>
|
||||
<p>Enthält alles: Konten (inkl. Passwörter), Filterregeln und Verarbeitungsstatus. Der Encryption-Key muss beim Import identisch sein.</p>
|
||||
<div class="grid">
|
||||
<article>
|
||||
<header><h3>Export</h3></header>
|
||||
<p>Aktuelle Konfiguration als YAML-Datei herunterladen.</p>
|
||||
<button onclick="exportYaml()">YAML exportieren</button>
|
||||
<pre id="yaml-preview" style="max-height: 400px; overflow-y: auto; display: none;"></pre>
|
||||
<header><h3>Backup erstellen</h3></header>
|
||||
<p>Gesamte Konfiguration als JSON-Datei herunterladen.</p>
|
||||
<button onclick="exportBackup()">Backup herunterladen</button>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<header><h3>Import</h3></header>
|
||||
<p>YAML-Datei hochladen um Konten und Filterregeln zu importieren.</p>
|
||||
<form id="import-form">
|
||||
<header><h3>Backup wiederherstellen</h3></header>
|
||||
<p>Backup-Datei hochladen. Bestehende Konten werden aktualisiert, neue angelegt.</p>
|
||||
<form id="backup-import-form">
|
||||
<input type="file" name="file" accept=".json" required>
|
||||
<button type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
<div id="backup-result" style="display:none"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>YAML Filterregeln</h2>
|
||||
<p>Nur Konten und Filterregeln — ohne Passwörter (können als Umgebungsvariablen referenziert werden).</p>
|
||||
<div class="grid">
|
||||
<article>
|
||||
<header><h3>YAML Export</h3></header>
|
||||
<button onclick="exportYaml()">YAML exportieren</button>
|
||||
<pre id="yaml-preview" style="max-height: 300px; overflow-y: auto; display: none; margin-top: 1rem;"></pre>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<header><h3>YAML Import</h3></header>
|
||||
<form id="yaml-import-form">
|
||||
<input type="file" name="file" accept=".yaml,.yml" required>
|
||||
<button type="submit">Importieren</button>
|
||||
</form>
|
||||
<div id="import-result" style="display:none"></div>
|
||||
<div id="yaml-result" style="display:none"></div>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function exportBackup() {
|
||||
window.location.href = '/api/yaml/backup';
|
||||
}
|
||||
|
||||
document.getElementById('backup-import-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!confirm('Backup wiederherstellen?\n\nBestehende Konten mit gleichem Benutzernamen/Server werden überschrieben.')) return;
|
||||
const formData = new FormData(e.target);
|
||||
const resp = await fetch('/api/yaml/backup', {method: 'POST', body: formData});
|
||||
const result = await resp.json();
|
||||
const div = document.getElementById('backup-result');
|
||||
div.style.display = 'block';
|
||||
if (result.error) {
|
||||
div.innerHTML = `<article role="alert">Fehler: ${result.error}</article>`;
|
||||
} else {
|
||||
div.innerHTML = `<article>
|
||||
Backup wiederhergestellt!<br>
|
||||
Konten erstellt: ${result.accounts_created}<br>
|
||||
Konten aktualisiert: ${result.accounts_updated}<br>
|
||||
Regeln erstellt: ${result.rules_created}<br>
|
||||
Verarbeitungsstatus: ${result.processed_restored} Einträge
|
||||
</article>`;
|
||||
}
|
||||
});
|
||||
|
||||
async function exportYaml() {
|
||||
const resp = await fetch('/api/yaml/export');
|
||||
const text = await resp.text();
|
||||
@@ -32,7 +79,6 @@ async function exportYaml() {
|
||||
pre.textContent = text;
|
||||
pre.style.display = 'block';
|
||||
|
||||
// Also trigger download
|
||||
const blob = new Blob([text], {type: 'text/yaml'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -42,12 +88,12 @@ async function exportYaml() {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('import-form').addEventListener('submit', async (e) => {
|
||||
document.getElementById('yaml-import-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const resp = await fetch('/api/yaml/import', {method: 'POST', body: formData});
|
||||
const result = await resp.json();
|
||||
const div = document.getElementById('import-result');
|
||||
const div = document.getElementById('yaml-result');
|
||||
div.style.display = 'block';
|
||||
if (result.error) {
|
||||
div.innerHTML = `<article role="alert">Fehler: ${result.error}</article>`;
|
||||
|
||||
Reference in New Issue
Block a user