docker.dateiverwaltung/Source/frontend/static/js/app.js

2546 lines
95 KiB
JavaScript
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* Dateiverwaltung Frontend
* Zwei getrennte Bereiche: Mail-Abruf und Datei-Sortierung
*/
// ============ API ============
async function api(endpoint, options = {}) {
const response = await fetch(`/api${endpoint}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
// Besseres Fehler-Handling für Pydantic Validation Errors
let errorMsg = 'API Fehler';
if (error.detail) {
if (Array.isArray(error.detail)) {
// Pydantic Validation Error Format
errorMsg = error.detail.map(e => `${e.loc?.join('.')}: ${e.msg}`).join('\n');
} else if (typeof error.detail === 'object') {
errorMsg = JSON.stringify(error.detail);
} else {
errorMsg = error.detail;
}
}
throw new Error(errorMsg);
}
return response.json();
}
// ============ Loading Overlay ============
function zeigeLoading(text = 'Wird geladen...') {
document.getElementById('loading-text').textContent = text;
document.getElementById('loading-overlay').classList.remove('hidden');
}
function versteckeLoading() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// ============ Dialog System (ersetzt alert/confirm) ============
let dialogResolve = null;
const DIALOG_ICONS = {
success: '✅',
error: '❌',
warning: '⚠️',
info: '',
question: '❓'
};
/**
* Zeigt einen Dialog an (ersetzt alert/confirm)
* @param {Object} options - Dialog-Optionen
* @param {string} options.title - Dialog-Titel
* @param {string} options.message - Nachricht
* @param {string} options.type - Typ: success, error, warning, info, question
* @param {boolean} options.showCancel - Abbrechen-Button anzeigen (für Confirm)
* @param {string} options.okText - Text für OK-Button
* @param {string} options.cancelText - Text für Abbrechen-Button
* @returns {Promise<boolean>} - true wenn OK, false wenn Abbrechen
*/
function showDialog(options = {}) {
const {
title = 'Hinweis',
message = '',
type = 'info',
showCancel = false,
okText = 'OK',
cancelText = 'Abbrechen'
} = options;
return new Promise((resolve) => {
dialogResolve = resolve;
document.getElementById('dialog-title').textContent = title;
document.getElementById('dialog-message').textContent = message;
const iconEl = document.getElementById('dialog-icon');
iconEl.textContent = DIALOG_ICONS[type] || DIALOG_ICONS.info;
iconEl.className = 'dialog-icon ' + type;
document.getElementById('dialog-ok-btn').textContent = okText;
document.getElementById('dialog-cancel-btn').textContent = cancelText;
document.getElementById('dialog-cancel-btn').style.display = showCancel ? '' : 'none';
document.getElementById('dialog-modal').classList.remove('hidden');
});
}
/**
* Schließt den Dialog
* @param {boolean} result - Ergebnis (true = OK, false = Abbrechen)
*/
function dialogSchliessen(result) {
document.getElementById('dialog-modal').classList.add('hidden');
if (dialogResolve) {
dialogResolve(result);
dialogResolve = null;
}
}
/**
* Zeigt eine Benachrichtigung an (ersetzt alert)
*/
function showAlert(message, type = 'info', title = null) {
const titles = {
success: 'Erfolg',
error: 'Fehler',
warning: 'Warnung',
info: 'Hinweis'
};
return showDialog({
title: title || titles[type] || 'Hinweis',
message: message,
type: type,
showCancel: false,
okText: 'OK'
});
}
/**
* Zeigt eine Bestätigung an (ersetzt confirm)
*/
function showConfirm(message, title = 'Bestätigung') {
return showDialog({
title: title,
message: message,
type: 'question',
showCancel: true,
okText: 'Ja',
cancelText: 'Nein'
});
}
// ============ File Browser ============
let browserTargetInput = null;
let browserCurrentPath = '/';
function oeffneBrowser(inputId) {
browserTargetInput = inputId;
const currentValue = document.getElementById(inputId).value;
// Entferne trailing slash für die Pfadnavigation
browserCurrentPath = (currentValue || '/').replace(/\/+$/, '') || '/';
ladeBrowserInhalt(browserCurrentPath);
document.getElementById('browser-modal').classList.remove('hidden');
}
// Navigiert zum Pfad aus dem Eingabefeld
function navigiereToPfad() {
const input = document.getElementById('browser-path-input');
const path = input.value.trim() || '/';
ladeBrowserInhalt(path);
}
// Findet den nächsten existierenden Elternordner
function getParentPath(path) {
if (!path || path === '/') return null;
const parts = path.replace(/\/+$/, '').split('/');
parts.pop();
return parts.length === 0 ? '/' : parts.join('/') || '/';
}
async function ladeBrowserInhalt(path, versuchtesPfade = []) {
try {
const data = await api(`/browse?path=${encodeURIComponent(path)}`);
if (data.error) {
// Versuche Elternordner wenn dieser Pfad nicht existiert
const parentPath = getParentPath(path);
// Verhindere Endlosschleifen
if (parentPath && !versuchtesPfade.includes(parentPath)) {
versuchtesPfade.push(path);
console.log(`Pfad "${path}" existiert nicht, versuche "${parentPath}"`);
return ladeBrowserInhalt(parentPath, versuchtesPfade);
}
// Fallback zu Root wenn nichts funktioniert
if (path !== '/') {
console.log(`Fallback zu Root-Verzeichnis`);
return ladeBrowserInhalt('/', []);
}
// Nur anzeigen wenn wirklich nichts geht
document.getElementById('browser-list').innerHTML =
`<li class="file-browser-item" style="color: var(--danger);">${data.error}</li>`;
return;
}
browserCurrentPath = data.current;
document.getElementById('browser-path-input').value = data.current;
let html = '';
// Parent directory
if (data.parent) {
html += `<li class="file-browser-item" onclick="ladeBrowserInhalt('${data.parent}')">
<span class="file-icon">📁</span> ..
</li>`;
}
// Directories
for (const entry of data.entries) {
html += `<li class="file-browser-item" ondblclick="ladeBrowserInhalt('${entry.path}')" onclick="browserSelect(this, '${entry.path}')">
<span class="file-icon">📁</span> ${entry.name}
</li>`;
}
if (data.entries.length === 0 && !data.parent) {
html = '<li class="file-browser-item">Keine Unterordner</li>';
}
document.getElementById('browser-list').innerHTML = html;
} catch (error) {
// Bei Netzwerk-/API-Fehler auch Elternordner versuchen
const parentPath = getParentPath(path);
if (parentPath && !versuchtesPfade.includes(parentPath) && path !== '/') {
versuchtesPfade.push(path);
console.log(`API-Fehler bei "${path}", versuche "${parentPath}"`);
return ladeBrowserInhalt(parentPath, versuchtesPfade);
}
document.getElementById('browser-list').innerHTML =
`<li class="file-browser-item" style="color: var(--danger);">Fehler: ${error.message}</li>`;
}
}
function browserSelect(element, path) {
document.querySelectorAll('.file-browser-item.selected').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
browserCurrentPath = path;
}
function browserAuswahl() {
if (browserTargetInput && browserCurrentPath) {
document.getElementById(browserTargetInput).value = browserCurrentPath + '/';
}
schliesseModal('browser-modal');
}
// ============ Checkbox Helpers ============
function getCheckedTypes(groupId) {
const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]:checked`);
return Array.from(checkboxes).map(cb => cb.value);
}
function setCheckedTypes(groupId, types) {
const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]`);
checkboxes.forEach(cb => {
cb.checked = types.includes(cb.value);
});
}
// ============ Theme System ============
function ladeGespeichertesTheme() {
const gespeichertesTheme = localStorage.getItem('dateiverwaltung-theme') || 'dark';
setzeTheme(gespeichertesTheme, false);
}
function setzeTheme(theme, speichern = true) {
document.documentElement.setAttribute('data-theme', theme);
// Aktiven Button markieren
document.querySelectorAll('.theme-option').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
if (speichern) {
localStorage.setItem('dateiverwaltung-theme', theme);
}
}
function zeigeEinstellungenModal() {
// Aktuelles Theme markieren
const aktuellesTheme = localStorage.getItem('dateiverwaltung-theme') || 'dark';
document.querySelectorAll('.theme-option').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === aktuellesTheme);
});
document.getElementById('einstellungen-modal').classList.remove('hidden');
}
// ============ Debug Log ============
function zeigeLogModal() {
document.getElementById('log-modal').classList.remove('hidden');
ladeLog();
}
async function ladeLog() {
const container = document.getElementById('log-container');
const filter = document.getElementById('log-filter').value;
try {
const logs = await api(`/logs${filter ? '?level=' + filter : ''}`);
if (!logs || logs.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Log-Einträge</p>';
return;
}
container.innerHTML = logs.map(log => {
const levelClass = log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : 'info';
return `<div class="log-entry ${levelClass}">
<span class="log-time">${log.zeit}</span>
<span class="log-level">${log.level}</span>
<span class="log-msg">${escapeHtml(log.nachricht)}</span>
</div>`;
}).join('');
// Nach unten scrollen
container.scrollTop = container.scrollHeight;
} catch (error) {
container.innerHTML = `<p class="empty-state">Fehler: ${error.message}</p>`;
}
}
async function leereLog() {
if (!await showConfirm('Log wirklich leeren?')) return;
try {
await api('/logs', { method: 'DELETE' });
ladeLog();
} catch (error) {
showAlert(error.message, 'error');
}
}
// ============ Init ============
document.addEventListener('DOMContentLoaded', () => {
ladeGespeichertesTheme();
ladePostfaecher();
ladeOrdner();
ladeRegeln();
ladeZeitplaene();
ladeStatus();
// Event-Listener für Dateityp-Checkboxen im Postfach-Modal
const pfTypenGruppe = document.getElementById('pf-typen-gruppe');
if (pfTypenGruppe) {
pfTypenGruppe.addEventListener('change', () => {
const container = document.getElementById('pf-groessen-filter');
if (container.classList.contains('visible')) {
updateGroessenFilterTable();
}
});
}
});
// ============ BEREICH 1: Mail-Abruf ============
async function ladePostfaecher() {
try {
const postfaecher = await api('/postfaecher');
renderPostfaecher(postfaecher);
} catch (error) {
console.error('Fehler:', error);
}
}
let bearbeitetesPostfachId = null;
function renderPostfaecher(postfaecher) {
const container = document.getElementById('postfaecher-liste');
if (!postfaecher || postfaecher.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Postfächer konfiguriert</p>';
return;
}
container.innerHTML = postfaecher.map(p => {
const letzterAbruf = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie';
return `
<div class="config-item">
<div class="config-item-info">
<h4>${escapeHtml(p.name)}</h4>
<small>${escapeHtml(p.email)}${truncatePath(p.ziel_ordner)}</small>
<small style="display:block;">Letzter Abruf: ${letzterAbruf} (${p.letzte_anzahl || 0} Dateien)</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm" onclick="postfachAbrufen(${p.id})">Abrufen</button>
<button class="btn btn-sm" onclick="postfachBearbeiten(${p.id})">Bearbeiten</button>
<button class="btn btn-sm" onclick="postfachTesten(${p.id})">Testen</button>
<button class="btn btn-sm btn-danger" onclick="postfachLoeschen(${p.id})">×</button>
</div>
</div>
`}).join('');
}
function zeigePostfachModal(postfach = null) {
bearbeitetesPostfachId = postfach?.id || null;
document.getElementById('pf-name').value = postfach?.name || '';
document.getElementById('pf-server').value = postfach?.imap_server || '';
document.getElementById('pf-port').value = postfach?.imap_port || '993';
document.getElementById('pf-email').value = postfach?.email || '';
document.getElementById('pf-passwort').value = ''; // Passwort nicht vorausfüllen
document.getElementById('pf-ordner').value = postfach?.ordner || 'INBOX';
document.getElementById('pf-alle-ordner').value = postfach?.alle_ordner ? 'true' : 'false';
document.getElementById('pf-ziel').value = postfach?.ziel_ordner || '/srv/http/dateiverwaltung/data/inbox/';
setCheckedTypes('pf-typen-gruppe', postfach?.erlaubte_typen || ['.pdf']);
document.getElementById('pf-max-groesse').value = postfach?.max_groesse_mb || '25';
document.getElementById('pf-min-groesse').value = postfach?.min_groesse_kb || '10';
// ab_datum: ISO-String in date-Input-Format (YYYY-MM-DD)
const abDatum = postfach?.ab_datum ? postfach.ab_datum.split('T')[0] : '';
document.getElementById('pf-ab-datum').value = abDatum;
// Größenfilter pro Dateityp initialisieren
renderGroessenFilter(postfach?.groessen_filter || {});
document.getElementById('postfach-modal').classList.remove('hidden');
}
// ============ Größenfilter pro Dateityp ============
function toggleGroessenFilter() {
const container = document.getElementById('pf-groessen-filter');
container.classList.toggle('visible');
if (container.classList.contains('visible')) {
// Tabelle aktualisieren basierend auf ausgewählten Dateitypen
updateGroessenFilterTable();
}
}
function updateGroessenFilterTable() {
const selectedTypes = getCheckedTypes('pf-typen-gruppe');
const container = document.getElementById('pf-groessen-filter');
const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10;
const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25;
// Bestehende Werte sammeln
const existingValues = {};
container.querySelectorAll('.groessen-filter-row').forEach(row => {
const typ = row.dataset.typ;
const minInput = row.querySelector('.groessen-min');
const maxInput = row.querySelector('.groessen-max');
if (minInput && maxInput) {
existingValues[typ] = {
min_kb: parseInt(minInput.value) || defaultMin,
max_mb: parseInt(maxInput.value) || defaultMax
};
}
});
if (selectedTypes.length === 0) {
container.innerHTML = '<p style="color: var(--text-secondary); font-size: 0.8rem;">Zuerst Dateitypen auswählen</p>';
return;
}
let html = `
<table class="groessen-filter-table">
<thead>
<tr>
<th>Dateityp</th>
<th>Min (KB)</th>
<th>Max (MB)</th>
</tr>
</thead>
<tbody>
`;
for (const typ of selectedTypes) {
const existing = existingValues[typ] || { min_kb: defaultMin, max_mb: defaultMax };
html += `
<tr class="groessen-filter-row" data-typ="${typ}">
<td class="groessen-filter-type">${typ}</td>
<td><input type="number" class="groessen-min" value="${existing.min_kb}" placeholder="${defaultMin}"></td>
<td><input type="number" class="groessen-max" value="${existing.max_mb}" placeholder="${defaultMax}"></td>
</tr>
`;
}
html += '</tbody></table>';
container.innerHTML = html;
}
function renderGroessenFilter(groessenFilter) {
const container = document.getElementById('pf-groessen-filter');
container.classList.remove('visible');
// Wenn Werte vorhanden, Tabelle aufbauen
if (groessenFilter && Object.keys(groessenFilter).length > 0) {
const selectedTypes = getCheckedTypes('pf-typen-gruppe');
const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10;
const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25;
let html = `
<table class="groessen-filter-table">
<thead>
<tr>
<th>Dateityp</th>
<th>Min (KB)</th>
<th>Max (MB)</th>
</tr>
</thead>
<tbody>
`;
for (const typ of selectedTypes) {
const filter = groessenFilter[typ] || { min_kb: defaultMin, max_mb: defaultMax };
html += `
<tr class="groessen-filter-row" data-typ="${typ}">
<td class="groessen-filter-type">${typ}</td>
<td><input type="number" class="groessen-min" value="${filter.min_kb || defaultMin}"></td>
<td><input type="number" class="groessen-max" value="${filter.max_mb || defaultMax}"></td>
</tr>
`;
}
html += '</tbody></table>';
container.innerHTML = html;
} else {
container.innerHTML = '';
}
}
function getGroessenFilter() {
const container = document.getElementById('pf-groessen-filter');
const rows = container.querySelectorAll('.groessen-filter-row');
const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10;
const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25;
const filter = {};
rows.forEach(row => {
const typ = row.dataset.typ;
const minKb = parseInt(row.querySelector('.groessen-min')?.value);
const maxMb = parseInt(row.querySelector('.groessen-max')?.value);
// Nur speichern wenn unterschiedlich vom Default
if ((minKb && minKb !== defaultMin) || (maxMb && maxMb !== defaultMax)) {
filter[typ] = {};
if (minKb && minKb !== defaultMin) filter[typ].min_kb = minKb;
if (maxMb && maxMb !== defaultMax) filter[typ].max_mb = maxMb;
}
});
return Object.keys(filter).length > 0 ? filter : null;
}
async function postfachBearbeiten(id) {
try {
const postfaecher = await api('/postfaecher');
const postfach = postfaecher.find(p => p.id === id);
if (postfach) {
zeigePostfachModal(postfach);
}
} catch (error) {
showAlert(error.message, 'error');
}
}
async function speicherePostfach() {
const erlaubteTypen = getCheckedTypes('pf-typen-gruppe');
if (erlaubteTypen.length === 0) {
showAlert('Bitte mindestens einen Dateityp auswählen', 'warning');
return;
}
const abDatumValue = document.getElementById('pf-ab-datum').value;
const data = {
name: document.getElementById('pf-name').value.trim(),
imap_server: document.getElementById('pf-server').value.trim(),
imap_port: parseInt(document.getElementById('pf-port').value),
email: document.getElementById('pf-email').value.trim(),
passwort: document.getElementById('pf-passwort').value,
ordner: document.getElementById('pf-ordner').value.trim(),
alle_ordner: document.getElementById('pf-alle-ordner').value === 'true',
ziel_ordner: document.getElementById('pf-ziel').value.trim(),
erlaubte_typen: erlaubteTypen,
max_groesse_mb: parseInt(document.getElementById('pf-max-groesse').value),
min_groesse_kb: parseInt(document.getElementById('pf-min-groesse').value),
ab_datum: abDatumValue ? abDatumValue + 'T00:00:00' : null,
groessen_filter: getGroessenFilter()
};
if (!data.name || !data.imap_server || !data.email || !data.ziel_ordner) {
showAlert('Bitte alle Pflichtfelder ausfüllen', 'warning');
return;
}
// Bei Bearbeitung: Passwort nur senden wenn eingegeben
if (bearbeitetesPostfachId && !data.passwort) {
delete data.passwort;
} else if (!data.passwort) {
showAlert('Passwort ist erforderlich', 'warning');
return;
}
try {
if (bearbeitetesPostfachId) {
await api(`/postfaecher/${bearbeitetesPostfachId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/postfaecher', { method: 'POST', body: JSON.stringify(data) });
}
schliesseModal('postfach-modal');
ladePostfaecher();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function postfachTesten(id) {
try {
const result = await api(`/postfaecher/${id}/test`, { method: 'POST' });
showAlert(result.erfolg ? 'Verbindung erfolgreich!' : result.nachricht, result.erfolg ? 'success' : 'error');
} catch (error) {
showAlert(error.message, 'error');
}
}
async function postfachAbrufen(id) {
const logContainer = document.getElementById('abruf-log');
logContainer.innerHTML = '<div class="log-entry info"><span>Verbinde...</span></div>';
// EventSource für Server-Sent Events
const eventSource = new EventSource(`/api/postfaecher/${id}/abrufen/stream`);
let dateiCount = 0;
let currentOrdner = '';
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'start':
logContainer.innerHTML = `<div class="log-entry info">
<span>Starte Abruf: ${escapeHtml(data.postfach)}</span>
<small>${data.bereits_verarbeitet} bereits verarbeitet</small>
</div>`;
break;
case 'info':
logContainer.innerHTML += `<div class="log-entry info">
<span>${escapeHtml(data.nachricht)}</span>
</div>`;
break;
case 'ordner':
currentOrdner = data.name;
logContainer.innerHTML += `<div class="log-entry info" id="ordner-status">
<span>📁 ${escapeHtml(data.name)}</span>
</div>`;
break;
case 'mails':
const ordnerStatus = document.getElementById('ordner-status');
if (ordnerStatus) {
ordnerStatus.innerHTML = `<span>📁 ${escapeHtml(data.ordner)}: ${data.anzahl} Mails</span>`;
ordnerStatus.id = ''; // ID entfernen für nächsten Ordner
}
break;
case 'datei':
dateiCount++;
logContainer.innerHTML += `<div class="log-entry success">
<span>✓ ${escapeHtml(data.original_name)}</span>
<small>${formatBytes(data.groesse)}</small>
</div>`;
// Scroll nach unten
logContainer.scrollTop = logContainer.scrollHeight;
break;
case 'skip':
logContainer.innerHTML += `<div class="log-entry" style="opacity:0.6;">
<span>⊘ ${escapeHtml(data.datei)}: ${data.grund}</span>
</div>`;
break;
case 'fehler':
logContainer.innerHTML += `<div class="log-entry error">
<span>✗ ${escapeHtml(data.nachricht)}</span>
</div>`;
break;
case 'fertig':
logContainer.innerHTML += `<div class="log-entry success" style="font-weight:bold;">
<span>✓ Fertig: ${data.anzahl} Dateien gespeichert</span>
</div>`;
eventSource.close();
ladePostfaecher();
break;
}
};
eventSource.onerror = (error) => {
logContainer.innerHTML += `<div class="log-entry error">
<span>✗ Verbindung unterbrochen</span>
</div>`;
eventSource.close();
};
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
async function allePostfaecherAbrufen() {
const container = document.getElementById('abruf-log');
container.innerHTML = '<div class="log-entry info">Starte Abruf...</div>';
try {
const response = await fetch('/api/postfaecher/abrufen-alle/stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let currentPostfach = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
renderStreamEvent(container, event);
} catch (e) {}
}
}
}
ladePostfaecher();
ladeStatus();
} catch (error) {
container.innerHTML += `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
}
}
function renderStreamEvent(container, event) {
let html = '';
switch (event.type) {
case 'init':
html = `<div class="log-entry info">📧 ${event.anzahl_postfaecher} Postfächer werden abgerufen...</div>`;
break;
case 'postfach_start':
html = `<div class="log-entry info"><strong>📬 ${escapeHtml(event.name)}</strong> (${event.bereits_verarbeitet} bereits verarbeitet)</div>`;
break;
case 'ordner':
html = `<div class="log-entry" style="padding-left: 1rem;">📁 Ordner: ${escapeHtml(event.name)}</div>`;
break;
case 'mails':
html = `<div class="log-entry" style="padding-left: 1.5rem;">${event.anzahl} Mails gefunden</div>`;
break;
case 'datei':
html = `<div class="log-entry success" style="padding-left: 1.5rem;">✓ ${escapeHtml(event.datei)} (${formatBytes(event.groesse)})</div>`;
break;
case 'skip':
html = `<div class="log-entry" style="padding-left: 1.5rem; opacity: 0.6;">→ ${escapeHtml(event.datei)} - ${event.grund}</div>`;
break;
case 'postfach_done':
html = `<div class="log-entry success"><strong>✓ ${escapeHtml(event.name)}: ${event.anzahl} Dateien</strong></div>`;
break;
case 'postfach_error':
html = `<div class="log-entry error">✗ ${escapeHtml(event.name)}: ${escapeHtml(event.fehler)}</div>`;
break;
case 'done':
html = `<div class="log-entry info" style="margin-top: 0.5rem;"><strong>Fertig!</strong></div>`;
break;
}
if (html) {
container.innerHTML += html;
container.scrollTop = container.scrollHeight;
}
}
async function postfachLoeschen(id) {
if (!await showConfirm('Postfach wirklich löschen?')) return;
try {
await api(`/postfaecher/${id}`, { method: 'DELETE' });
ladePostfaecher();
} catch (error) {
showAlert(error.message, 'error');
}
}
function zeigeAbrufLog(result) {
const container = document.getElementById('abruf-log');
if (!result.ergebnisse || result.ergebnisse.length === 0) {
container.innerHTML = '<p class="empty-state">Keine neuen Attachments gefunden</p>';
return;
}
let html = '';
for (const r of result.ergebnisse) {
const status = r.fehler ? 'error' : 'success';
const icon = r.fehler ? '✗' : '✓';
html += `<div class="log-entry ${status}">
<span>${icon} ${escapeHtml(r.postfach)}: ${r.anzahl || 0} Dateien</span>
${r.fehler ? `<small>${escapeHtml(r.fehler)}</small>` : ''}
</div>`;
if (r.dateien) {
for (const d of r.dateien) {
html += `<div class="log-entry info">
<span style="padding-left: 1rem;">→ ${escapeHtml(d)}</span>
</div>`;
}
}
}
container.innerHTML = html;
}
// ============ BEREICH 2: Datei-Sortierung ============
async function ladeOrdner() {
try {
const ordner = await api('/ordner');
renderOrdner(ordner);
} catch (error) {
console.error('Fehler:', error);
}
}
function renderOrdner(ordner) {
const container = document.getElementById('ordner-liste');
if (!ordner || ordner.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Ordner konfiguriert</p>';
return;
}
container.innerHTML = ordner.map(o => {
const aktivClass = o.aktiv ? '' : 'opacity: 0.5;';
const aktivBadge = o.aktiv ? '<span class="badge badge-success">Aktiv</span>' : '<span class="badge badge-danger">Inaktiv</span>';
const letzte = o.letzte_verarbeitung ? formatDatum(o.letzte_verarbeitung) : 'Nie';
return `
<div class="config-item" style="${aktivClass}">
<div class="config-item-info">
<h4>${escapeHtml(o.name)} ${aktivBadge} ${o.rekursiv ? '<span class="badge badge-info">rekursiv</span>' : ''}</h4>
<small>${truncatePath(o.pfad)}${truncatePath(o.ziel_ordner)}</small>
<small style="display:block;">${(o.dateitypen || []).join(', ')} | Letzte: ${letzte} (${o.letzte_anzahl || 0} Dateien)</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm" onclick="ordnerVorschau(${o.id})">Vorschau</button>
<button class="btn btn-sm btn-primary" onclick="ordnerVerarbeiten(${o.id})">Verarbeiten</button>
<button class="btn btn-sm" onclick="ordnerBearbeiten(${o.id})" title="Bearbeiten">✎</button>
<button class="btn btn-sm" onclick="ordnerAktivieren(${o.id})" title="${o.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${o.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm" onclick="kopiereOrdner(${o.id})" title="Ordner kopieren">📋</button>
<button class="btn btn-sm btn-danger" onclick="ordnerLoeschen(${o.id})" title="Löschen">×</button>
</div>
</div>
`}).join('');
}
let bearbeitetesOrdnerId = null;
function zeigeOrdnerModal(ordner = null) {
bearbeitetesOrdnerId = ordner?.id || null;
document.getElementById('ordner-modal-title').textContent = ordner ? 'Grobsortierung bearbeiten' : 'Grobsortierung hinzufügen';
document.getElementById('ord-name').value = ordner?.name || '';
document.getElementById('ord-pfad').value = ordner?.pfad || '/mnt/user/';
document.getElementById('ord-ziel').value = ordner?.ziel_ordner || '/mnt/user/';
setCheckedTypes('ord-typen-gruppe', ordner?.dateitypen || ['.pdf', '.jpg', '.jpeg', '.png', '.tiff']);
document.getElementById('ord-rekursiv').value = ordner?.rekursiv !== false ? 'true' : 'false';
document.getElementById('ord-zugferd-sep').checked = ordner?.zugferd_behandlung === 'separieren' || !ordner;
document.getElementById('ord-signiert-sep').checked = ordner?.signiert_behandlung === 'separieren';
document.getElementById('ord-ocr').checked = ordner?.ocr_aktivieren !== false;
document.getElementById('ord-original-sichern').value = ordner?.original_sichern || '';
// Sortier-Modus
const modus = ordner?.direkt_verschieben ? 'direkt' : 'regeln';
document.querySelector(`input[name="ord-modus"][value="${modus}"]`).checked = true;
document.getElementById('ordner-modal').classList.remove('hidden');
}
async function ordnerBearbeiten(id) {
try {
const ordnerListe = await api('/ordner');
const ordner = ordnerListe.find(o => o.id === id);
if (ordner) {
zeigeOrdnerModal(ordner);
}
} catch (error) {
showAlert(error.message, 'error');
}
}
async function speichereOrdner() {
const dateitypen = getCheckedTypes('ord-typen-gruppe');
if (dateitypen.length === 0) {
showAlert('Bitte mindestens einen Dateityp auswählen', 'warning');
return;
}
// NEU: Sortier-Modus auslesen
const modusRadio = document.querySelector('input[name="ord-modus"]:checked');
const direktVerschieben = modusRadio?.value === 'direkt';
const data = {
name: document.getElementById('ord-name').value.trim(),
pfad: document.getElementById('ord-pfad').value.trim(),
ziel_ordner: document.getElementById('ord-ziel').value.trim(),
rekursiv: document.getElementById('ord-rekursiv').value === 'true',
dateitypen: dateitypen,
zugferd_behandlung: document.getElementById('ord-zugferd-sep').checked ? 'separieren' : 'normal',
signiert_behandlung: document.getElementById('ord-signiert-sep').checked ? 'separieren' : 'normal',
direkt_verschieben: direktVerschieben,
ocr_aktivieren: document.getElementById('ord-ocr').checked,
original_sichern: document.getElementById('ord-original-sichern').value.trim() || null
};
if (!data.name || !data.pfad || !data.ziel_ordner) {
showAlert('Bitte alle Felder ausfüllen', 'warning');
return;
}
try {
zeigeLoading('Speichere Ordner...');
if (bearbeitetesOrdnerId) {
await api(`/ordner/${bearbeitetesOrdnerId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/ordner', { method: 'POST', body: JSON.stringify(data) });
}
schliesseModal('ordner-modal');
ladeOrdner();
} catch (error) {
showAlert(error.message, 'error');
} finally {
versteckeLoading();
}
}
async function ordnerAktivieren(id) {
try {
await api(`/ordner/${id}/aktivieren`, { method: 'POST' });
ladeOrdner();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function ordnerLoeschen(id) {
if (!await showConfirm('Ordner wirklich löschen?')) return;
try {
await api(`/ordner/${id}`, { method: 'DELETE' });
ladeOrdner();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function kopiereOrdner(id) {
if (!await showConfirm('Grobsortierung kopieren?')) return;
try {
const result = await api(`/ordner/${id}/kopieren`, { method: 'POST' });
showAlert(`Grobsortierung kopiert: "${result.name}"`, 'success');
ladeOrdner();
} catch (error) {
showAlert('Fehler beim Kopieren: ' + error.message, 'error');
}
}
async function ordnerVorschau(id) {
try {
const result = await api(`/ordner/${id}/scannen`);
let msg = `${result.anzahl} Dateien gefunden`;
if (result.dateien && result.dateien.length > 0) {
msg += `:\n\n${result.dateien.slice(0, 10).join('\n')}`;
if (result.anzahl > 10) {
msg += `\n... und ${result.anzahl - 10} weitere`;
}
}
showAlert(msg, 'info', 'Ordner-Vorschau');
} catch (error) {
showAlert(error.message, 'error');
}
}
async function ordnerVerarbeiten(id) {
if (!await showConfirm('Dateien jetzt verarbeiten und sortieren?')) return;
try {
zeigeLoading('Verarbeite Dateien...');
const result = await api(`/ordner/${id}/verarbeiten`, { method: 'POST' });
let msg = `Verarbeitung abgeschlossen:\n\n`;
msg += `• Gesamt: ${result.gesamt}\n`;
msg += `• Sortiert: ${result.sortiert}\n`;
msg += `• ZUGFeRD: ${result.zugferd}\n`;
msg += `• Keine Regel: ${result.keine_regel || 0}\n`;
msg += `• Fehler: ${result.fehler}`;
if (result.fehler && result.fehler > 0) {
showAlert(msg, 'warning', 'Verarbeitung mit Warnungen');
} else {
showAlert(msg, 'success', 'Verarbeitung abgeschlossen');
}
} catch (error) {
showAlert(error.message, 'error');
} finally {
versteckeLoading();
}
}
// ============ Regeln ============
let editierteRegelId = null;
async function ladeRegeln() {
try {
const regeln = await api('/regeln');
renderRegeln(regeln);
} catch (error) {
console.error('Fehler:', error);
}
}
function renderRegeln(regeln) {
const container = document.getElementById('regeln-liste');
if (!regeln || regeln.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Regeln definiert</p>';
return;
}
container.innerHTML = regeln.map(r => {
const aktivClass = r.aktiv ? '' : 'opacity: 0.5;';
const aktivBadge = r.aktiv ? '<span class="badge badge-success">Aktiv</span>' : '<span class="badge badge-danger">Inaktiv</span>';
return `
<div class="config-item" style="${aktivClass}">
<div class="config-item-info">
<h4>${escapeHtml(r.name)} ${aktivBadge} <span class="badge badge-info">Prio ${r.prioritaet}</span></h4>
<small>${escapeHtml(r.schema)}</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm" onclick="bearbeiteRegel(${r.id})" title="Bearbeiten">✎</button>
<button class="btn btn-sm" onclick="regelAktivieren(${r.id})" title="${r.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${r.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm" onclick="kopiereRegel(${r.id})" title="Regel kopieren">📋</button>
<button class="btn btn-sm btn-danger" onclick="regelLoeschen(${r.id})" title="Löschen">×</button>
</div>
</div>
`}).join('');
}
async function kopiereRegel(id) {
if (!await showConfirm('Regel kopieren?')) return;
try {
const result = await api(`/regeln/${id}/kopieren`, { method: 'POST' });
showAlert(`Regel kopiert: "${result.name}"`, 'success');
ladeRegeln();
} catch (error) {
showAlert('Fehler beim Kopieren: ' + error.message, 'error');
}
}
// ============ Regel-Modal (NEU) ============
let alleOrdner = []; // Cache für Ordner-Liste
// Toggle Ziel-Ordner Feld basierend auf "Nur umbenennen" Checkbox
function toggleZielOrdnerGruppe() {
const nurUmbenennen = document.getElementById('regel-nur-umbenennen').checked;
const zielGruppe = document.getElementById('ziel-ordner-gruppe');
if (zielGruppe) {
zielGruppe.style.display = nurUmbenennen ? 'none' : 'block';
}
}
async function zeigeRegelModal(regel = null) {
editierteRegelId = regel?.id || null;
document.getElementById('regel-modal-title').textContent = regel ? 'Regel bearbeiten' : 'Regel hinzufügen';
// Grundeinstellungen
document.getElementById('regel-name').value = regel?.name || '';
document.getElementById('regel-prioritaet').value = regel?.prioritaet || 100;
document.getElementById('regel-ist-fallback').checked = regel?.ist_fallback || false;
document.getElementById('regel-nur-umbenennen').checked = regel?.nur_umbenennen || false;
document.getElementById('regel-ziel-ordner').value = regel?.ziel_ordner || '';
toggleZielOrdnerGruppe(); // Ziel-Ordner Feld ein/ausblenden
document.getElementById('regel-schema').value = regel?.schema || '{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf';
document.getElementById('regel-unterordner').value = regel?.unterordner || '';
// Erkennungsmuster aus JSON extrahieren
const muster = regel?.muster || {};
document.getElementById('regel-keywords').value = muster.keywords || '';
document.getElementById('regel-keywords-nicht').value = muster.keywords_nicht || '';
document.getElementById('regel-text-regex').value = muster.text_regex || '';
// Extraktion-Tabelle befüllen
const extraktion = regel?.extraktion || {};
befuelleExtraktionTabelle(extraktion);
// Ordner-Checkboxen laden
await ladeOrdnerCheckboxen(editierteRegelId);
// Test-Bereich zurücksetzen
document.getElementById('regel-test-text').value = '';
document.getElementById('regel-test-ergebnis').classList.add('hidden');
document.getElementById('test-datei-name').textContent = '';
const displayEl = document.getElementById('regel-test-text-display');
if (displayEl) {
displayEl.innerHTML = '<p style="color: var(--text-secondary); text-align: center; padding: 2rem;">PDF hochladen um Text anzuzeigen</p>';
}
document.getElementById('regel-modal').classList.remove('hidden');
}
function befuelleExtraktionTabelle(extraktion) {
const tbody = document.getElementById('extraktion-tbody');
tbody.innerHTML = '';
// Standard-Felder mit Beispiel-Regex
const standardFelder = [
{name: 'datum', beispiel: 'Datum[:\\s]*(\\d{1,2}\\.\\d{1,2}\\.\\d{4})'},
{name: 'nummer', beispiel: 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)\nBeleg-?Nr[:\\s]*(\\S+)'},
{name: 'betrag', beispiel: 'Gesamt[:\\s]*([\\d.,]+)\\s*€'},
{name: 'firma', beispiel: 'Auto = aus Absender/Text'}
];
const vorhandeneFelder = new Set(standardFelder.map(f => f.name));
// Zuerst Standard-Felder mit Beispielen als Placeholder
for (const {name, beispiel} of standardFelder) {
const config = extraktion[name];
fuegeExtraktionsZeileHinzuMitBeispiel(name, config, false, beispiel);
}
// Dann zusätzliche Felder
for (const [feld, config] of Object.entries(extraktion)) {
if (!vorhandeneFelder.has(feld)) {
fuegeExtraktionsZeileHinzu(feld, config, true);
}
}
}
function fuegeExtraktionsZeileHinzuMitBeispiel(feld, config, removable, beispielPlaceholder) {
const tbody = document.getElementById('extraktion-tbody');
const row = document.createElement('tr');
row.className = 'extraktion-row';
const istWert = config && 'wert' in config;
const istRegex = config && 'regex' in config;
let wert = '';
if (istWert) {
wert = config.wert;
} else if (istRegex) {
wert = Array.isArray(config.regex) ? config.regex.join('\n') : config.regex;
}
// Auswahl-Modus (max/min/first/last)
const auswahl = config?.auswahl || 'first';
// Placeholder: Beispiel oder Standard
let placeholder = beispielPlaceholder || 'Regex-Muster mit (Gruppe)';
if (istWert) {
placeholder = 'Fester Wert eingeben';
}
row.innerHTML = `
<td>
<input type="text" class="ext-feld" value="${escapeHtml(feld)}" placeholder="feldname" ${!removable ? 'readonly' : ''}>
</td>
<td>
<select class="ext-typ" onchange="updateExtPlaceholder(this)">
<option value="auto" ${!istWert && !istRegex ? 'selected' : ''}>Auto</option>
<option value="regex" ${istRegex ? 'selected' : ''}>Regex</option>
<option value="wert" ${istWert ? 'selected' : ''}>Wert</option>
</select>
</td>
<td>
<textarea class="ext-wert" rows="2" placeholder="${escapeHtml(placeholder)}">${escapeHtml(wert)}</textarea>
</td>
<td>
<select class="ext-auswahl" title="Bei mehreren Treffern: welchen wählen?">
<option value="first" ${auswahl === 'first' ? 'selected' : ''}>Erster</option>
<option value="last" ${auswahl === 'last' ? 'selected' : ''}>Letzter</option>
<option value="max" ${auswahl === 'max' ? 'selected' : ''}>Max</option>
<option value="min" ${auswahl === 'min' ? 'selected' : ''}>Min</option>
</select>
</td>
<td>
${removable ? '<button type="button" class="btn btn-sm btn-danger" onclick="this.closest(\'tr\').remove()">×</button>' : ''}
</td>
`;
tbody.appendChild(row);
}
// Beispiel-Placeholders für verschiedene Feldtypen
const REGEX_BEISPIELE = {
'datum': 'Datum[:\\s]*(\\d{1,2}\\.\\d{1,2}\\.\\d{4})',
'nummer': 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)',
'betrag': 'Gesamt[:\\s]*([\\d.,]+)\\s*€',
'firma': 'z.B. Sonepar|ACME GmbH',
'default': 'Muster[:\\s]*(\\S+)'
};
function fuegeExtraktionsZeileHinzu(feld = '', config = null, removable = true, istBeispiel = false) {
const tbody = document.getElementById('extraktion-tbody');
const row = document.createElement('tr');
row.className = 'extraktion-row' + (istBeispiel ? ' beispiel' : '');
const istWert = config && 'wert' in config;
const istRegex = config && 'regex' in config;
// Bei mehreren Regex-Patterns: Array zu Zeilenumbrüchen
let wert = '';
if (istWert) {
wert = config.wert;
} else if (istRegex) {
wert = Array.isArray(config.regex) ? config.regex.join('\n') : config.regex;
}
// Auswahl-Modus (max/min/first/last)
const auswahl = config?.auswahl || 'first';
// Placeholder basierend auf Feldname
let placeholder = 'Regex-Muster mit (Gruppe)';
if (feld) {
placeholder = REGEX_BEISPIELE[feld.toLowerCase()] || REGEX_BEISPIELE['default'];
}
if (istWert) {
placeholder = 'Fester Wert eingeben';
}
row.innerHTML = `
<td>
<input type="text" class="ext-feld" value="${escapeHtml(feld)}" placeholder="z.B. nummer" ${!removable ? 'readonly' : ''}>
</td>
<td>
<select class="ext-typ" onchange="updateExtPlaceholder(this)">
<option value="auto" ${!istWert && !istRegex ? 'selected' : ''}>Auto</option>
<option value="regex" ${istRegex ? 'selected' : ''}>Regex</option>
<option value="wert" ${istWert ? 'selected' : ''}>Wert</option>
</select>
</td>
<td>
<textarea class="ext-wert" rows="2" placeholder="${escapeHtml(placeholder)}">${escapeHtml(wert)}</textarea>
</td>
<td>
<select class="ext-auswahl" title="Bei mehreren Treffern: welchen wählen?">
<option value="first" ${auswahl === 'first' ? 'selected' : ''}>Erster</option>
<option value="last" ${auswahl === 'last' ? 'selected' : ''}>Letzter</option>
<option value="max" ${auswahl === 'max' ? 'selected' : ''}>Max</option>
<option value="min" ${auswahl === 'min' ? 'selected' : ''}>Min</option>
</select>
</td>
<td>
${removable ? '<button type="button" class="btn btn-sm btn-danger" onclick="this.closest(\'tr\').remove()">×</button>' : ''}
</td>
`;
tbody.appendChild(row);
}
function updateExtPlaceholder(selectEl) {
const row = selectEl.closest('tr');
const textarea = row.querySelector('.ext-wert');
const typ = selectEl.value;
if (typ === 'wert') {
textarea.placeholder = 'Fester Wert eingeben';
} else if (typ === 'regex') {
textarea.placeholder = 'Regex-Muster mit (Gruppe)\nZeile 2 = Alternative';
} else {
textarea.placeholder = 'Leer = globale Extraktoren';
}
}
function fuegeExtraktionsFeldHinzu() {
fuegeExtraktionsZeileHinzu('', null, true);
}
function fuegeBeispieleHinzu() {
// Beispiele für neue Regeln
const tbody = document.getElementById('extraktion-tbody');
if (tbody.children.length === 0) {
// Beispiel 1: Einfache Regex
fuegeExtraktionsZeileHinzu('nummer', {regex: 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)'}, true, true);
// Beispiel 2: Mehrere Regex-Alternativen
fuegeExtraktionsZeileHinzu('betrag', {regex: ['Gesamt[:\\s]*([\\d.,]+)\\s*€', 'Summe[:\\s]*([\\d.,]+)']}, true, true);
}
}
function sammleExtraktionAusTabelle() {
const extraktion = {};
const rows = document.querySelectorAll('#extraktion-tbody .extraktion-row');
for (const row of rows) {
const feld = row.querySelector('.ext-feld').value.trim();
const typ = row.querySelector('.ext-typ').value;
const wertElement = row.querySelector('.ext-wert');
const wert = wertElement ? wertElement.value.trim() : '';
const auswahlElement = row.querySelector('.ext-auswahl');
const auswahl = auswahlElement ? auswahlElement.value : 'first';
if (!feld) continue;
if (typ === 'wert' && wert) {
extraktion[feld] = { wert: wert };
} else if (typ === 'regex' && wert) {
// Mehrere Zeilen = Mehrere Regex-Alternativen
const zeilen = wert.split('\n').map(z => z.trim()).filter(z => z);
if (zeilen.length === 1) {
extraktion[feld] = { regex: zeilen[0] };
} else if (zeilen.length > 1) {
extraktion[feld] = { regex: zeilen };
}
// Auswahl hinzufügen wenn nicht "first" (Standard)
if (auswahl && auswahl !== 'first' && extraktion[feld]) {
extraktion[feld].auswahl = auswahl;
}
}
// 'auto' = nichts eintragen, globale Extraktoren werden genutzt
}
return extraktion;
}
// Cache für freie Ordner der aktuellen Regel
let aktuelleFreieOrdner = [];
async function ladeOrdnerCheckboxen(regelId) {
const container = document.getElementById('regel-ordner-liste');
const freieOrdnerContainer = document.getElementById('regel-freie-ordner');
try {
// Alle Ordner laden
alleOrdner = await api('/ordner');
// Zugewiesene Ordner und freie Ordner laden (wenn Regel existiert)
let zugewieseneIds = [];
aktuelleFreieOrdner = [];
if (regelId) {
const result = await api(`/regeln/${regelId}/ordner`);
zugewieseneIds = result.ordner_ids || [];
aktuelleFreieOrdner = result.freie_ordner || [];
}
// Ziel-Ordner aus Grobsortierung (dedupliziert)
const zielOrdnerMap = new Map();
for (const ordner of alleOrdner) {
if (!zielOrdnerMap.has(ordner.ziel_ordner)) {
zielOrdnerMap.set(ordner.ziel_ordner, {
id: ordner.id,
name: ordner.name,
ziel_ordner: ordner.ziel_ordner
});
}
}
if (zielOrdnerMap.size === 0) {
container.innerHTML = '<p style="color: var(--text-secondary);">Keine Grobsortierung vorhanden. Bitte zuerst Ordner anlegen.</p>';
} else {
container.innerHTML = Array.from(zielOrdnerMap.values()).map(ordner => `
<label class="checkbox-item ordner-checkbox">
<input type="checkbox" value="${ordner.id}" data-ziel="${escapeHtml(ordner.ziel_ordner)}" ${zugewieseneIds.includes(ordner.id) ? 'checked' : ''}>
<span>${escapeHtml(ordner.name)}</span>
<small style="color: var(--text-secondary);">${escapeHtml(ordner.ziel_ordner)}</small>
</label>
`).join('');
}
// Freie Ordner anzeigen
renderFreieOrdner();
} catch (error) {
container.innerHTML = `<p style="color: var(--danger);">Fehler: ${error.message}</p>`;
}
}
function renderFreieOrdner() {
const container = document.getElementById('regel-freie-ordner');
if (!container) return;
if (aktuelleFreieOrdner.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = aktuelleFreieOrdner.map((pfad, index) => `
<div class="freier-ordner-item" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; padding: 0.25rem 0.5rem; background: var(--bg-tertiary); border-radius: var(--radius);">
<span style="flex: 1; font-size: 0.85rem;">${escapeHtml(pfad)}</span>
<button class="btn btn-sm btn-danger" type="button" onclick="entferneFreienOrdner(${index})" style="padding: 0.1rem 0.4rem;">×</button>
</div>
`).join('');
}
function fuegeFreienOrdnerHinzu() {
const input = document.getElementById('regel-neuer-ordner');
const pfad = input.value.trim();
if (!pfad) {
showAlert('Bitte einen Ordner-Pfad eingeben oder per Filebrowser wählen', 'warning');
return;
}
// Prüfen ob bereits vorhanden
if (aktuelleFreieOrdner.includes(pfad)) {
showAlert('Dieser Ordner ist bereits hinzugefügt', 'warning');
return;
}
aktuelleFreieOrdner.push(pfad);
input.value = '';
renderFreieOrdner();
}
function entferneFreienOrdner(index) {
aktuelleFreieOrdner.splice(index, 1);
renderFreieOrdner();
}
function sammleZugewieseneOrdner() {
const checkboxen = document.querySelectorAll('#regel-ordner-liste input[type="checkbox"]:checked');
return Array.from(checkboxen).map(cb => parseInt(cb.value));
}
async function bearbeiteRegel(id) {
try {
const regeln = await api('/regeln');
const regel = regeln.find(r => r.id === id);
if (regel) zeigeRegelModal(regel);
} catch (error) {
showAlert(error.message, 'error');
}
}
async function speichereRegel() {
// Muster aus UI-Feldern zusammenbauen
const muster = {};
const keywords = document.getElementById('regel-keywords').value.trim();
const keywordsNicht = document.getElementById('regel-keywords-nicht').value.trim();
const textRegex = document.getElementById('regel-text-regex').value.trim();
if (keywords) muster.keywords = keywords;
if (keywordsNicht) muster.keywords_nicht = keywordsNicht;
if (textRegex) muster.text_regex = textRegex;
// Extraktion aus Tabelle sammeln
const extraktion = sammleExtraktionAusTabelle();
// Versteckte Felder für Kompatibilität aktualisieren
document.getElementById('regel-muster').value = JSON.stringify(muster);
document.getElementById('regel-extraktion').value = JSON.stringify(extraktion);
const data = {
name: document.getElementById('regel-name').value.trim(),
prioritaet: parseInt(document.getElementById('regel-prioritaet').value),
muster,
extraktion,
nur_umbenennen: document.getElementById('regel-nur-umbenennen').checked,
ziel_ordner: document.getElementById('regel-ziel-ordner').value.trim() || null,
schema: document.getElementById('regel-schema').value.trim(),
unterordner: document.getElementById('regel-unterordner').value.trim() || null,
ist_fallback: document.getElementById('regel-ist-fallback').checked
};
if (!data.name) {
showAlert('Bitte einen Namen eingeben', 'warning');
return;
}
try {
let regelId = editierteRegelId;
if (regelId) {
await api(`/regeln/${regelId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
const result = await api('/regeln', { method: 'POST', body: JSON.stringify(data) });
regelId = result.id;
}
// Ordner-Zuweisungen speichern (inkl. freie Ordner)
const zugewieseneOrdner = sammleZugewieseneOrdner();
await api(`/regeln/${regelId}/ordner`, {
method: 'PUT',
body: JSON.stringify({
ordner_ids: zugewieseneOrdner,
freie_ordner: aktuelleFreieOrdner
})
});
schliesseModal('regel-modal');
ladeRegeln();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function regelAktivieren(id) {
try {
await api(`/regeln/${id}/aktivieren`, { method: 'POST' });
ladeRegeln();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function regelLoeschen(id) {
if (!await showConfirm('Regel wirklich löschen?')) return;
try {
await api(`/regeln/${id}`, { method: 'DELETE' });
ladeRegeln();
} catch (error) {
showAlert(error.message, 'error');
}
}
// PDF für Regel-Test hochladen
let testPdfDatei = null;
async function ladeTestPDF() {
const input = document.getElementById('regel-test-datei');
if (!input.files || !input.files[0]) return;
testPdfDatei = input.files[0];
document.getElementById('test-datei-name').textContent = `📄 ${testPdfDatei.name}`;
// PDF-Text extrahieren
const formData = new FormData();
formData.append('datei', testPdfDatei);
try {
zeigeLoading('Extrahiere PDF-Text...');
const response = await fetch('/api/pdf/extrahieren', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Fehler beim Extrahieren');
}
const result = await response.json();
// Text in beide Elemente (verstecktes textarea + sichtbares div)
document.getElementById('regel-test-text').value = result.text;
// Text im Display-Bereich anzeigen (mit kompakter Info-Zeile)
const displayEl = document.getElementById('regel-test-text-display');
let badges = [];
if (result.seiten) badges.push(`${result.seiten}S`);
if (result.ist_zugferd) badges.push('ZUGFeRD');
if (result.ist_signiert) badges.push('Signiert');
if (result.ocr_durchgefuehrt) badges.push('OCR');
const infoHtml = badges.length > 0
? `<span style="display:inline-block;background:var(--bg-tertiary);padding:0.15rem 0.4rem;border-radius:3px;font-size:0.7rem;margin-bottom:0.25rem;color:var(--text-secondary);">${badges.join(' · ')}</span><br>`
: '';
displayEl.innerHTML = infoHtml + `<div id="pdf-text-content">${escapeHtml(result.text)}</div>`;
// Automatisch testen
testeRegelLive();
} catch (error) {
showAlert(error.message, 'error');
} finally {
versteckeLoading();
}
}
async function testeRegelLive() {
console.log('testeRegelLive() gestartet');
const textElement = document.getElementById('regel-test-text');
const text = textElement ? textElement.value : '';
if (!text) {
// Kein Alert - einfach nichts tun wenn kein Text
return;
}
// Muster aus UI-Feldern sammeln
const muster = {};
const keywordsStr = document.getElementById('regel-keywords')?.value.trim();
const keywordsNicht = document.getElementById('regel-keywords-nicht')?.value.trim();
const textRegex = document.getElementById('regel-text-regex')?.value.trim();
if (keywordsStr) muster.keywords = keywordsStr;
if (keywordsNicht) muster.keywords_nicht = keywordsNicht;
if (textRegex) muster.text_regex = textRegex;
// Extraktion aus Tabelle sammeln
const extraktion = sammleExtraktionAusTabelle();
const regel = {
name: 'Test',
muster,
extraktion,
schema: document.getElementById('regel-schema').value.trim() || '{datum} - Dokument.pdf'
};
try {
const result = await api('/regeln/test', {
method: 'POST',
body: JSON.stringify({ regel, text })
});
const container = document.getElementById('regel-test-ergebnis');
container.classList.remove('hidden');
// Status Box mit Keyword-Info
const statusDiv = document.getElementById('test-status');
let statusHtml = '';
if (result.passt) {
statusDiv.className = 'test-status-box success';
statusHtml = '✓ Regel passt!';
} else {
statusDiv.className = 'test-status-box error';
statusHtml = '✗ Regel passt nicht';
}
// Keyword-Matching anzeigen
if (keywordsStr) {
const keywords = keywordsStr.split(',').map(k => k.trim()).filter(k => k);
const gefunden = keywords.filter(kw => text.toLowerCase().includes(kw.toLowerCase()));
const nichtGefunden = keywords.filter(kw => !text.toLowerCase().includes(kw.toLowerCase()));
if (gefunden.length > 0 || nichtGefunden.length > 0) {
statusHtml += '<div style="font-size: 0.75rem; margin-top: 0.25rem;">';
if (gefunden.length > 0) {
statusHtml += `<span style="color: var(--success);">✓ ${gefunden.join(', ')}</span>`;
}
if (nichtGefunden.length > 0) {
statusHtml += ` <span style="color: var(--danger);">✗ ${nichtGefunden.join(', ')}</span>`;
}
statusHtml += '</div>';
}
}
statusDiv.innerHTML = statusHtml;
// Extrahierte Felder anzeigen
const extrahiertDiv = document.getElementById('test-extrahiert');
if (result.extrahiert && Object.keys(result.extrahiert).length > 0) {
let html = '';
for (const [key, value] of Object.entries(result.extrahiert)) {
html += `<div class="feld-item">
<span class="feld-name">{${key}}</span>
<span class="feld-wert">${escapeHtml(String(value))}</span>
</div>`;
}
extrahiertDiv.innerHTML = html;
} else {
extrahiertDiv.innerHTML = '<span style="color: var(--text-secondary);">Keine Felder extrahiert</span>';
}
// Vorgeschlagener Dateiname
const dateinameDiv = document.getElementById('test-dateiname');
if (result.dateiname) {
dateinameDiv.innerHTML = `📁 ${escapeHtml(result.dateiname)}`;
dateinameDiv.style.display = 'block';
} else {
dateinameDiv.style.display = 'none';
}
// Text-Highlighting im PDF-Display
highlightPdfText(text, muster, result.extrahiert || {});
} catch (error) {
console.error('testeRegelLive Fehler:', error);
}
}
// Text-Highlighting für Keywords und extrahierte Werte
function highlightPdfText(text, muster, extrahiert) {
const contentEl = document.getElementById('pdf-text-content');
if (!contentEl) return;
let html = escapeHtml(text);
// Keywords highlighten (grün)
if (muster.keywords) {
const keywords = typeof muster.keywords === 'string'
? muster.keywords.split(',').map(k => k.trim())
: muster.keywords;
keywords.forEach(kw => {
if (kw) {
const regex = new RegExp(`(${escapeRegex(kw)})`, 'gi');
html = html.replace(regex, '<span class="highlight-keyword">$1</span>');
}
});
}
// Extrahierte Werte highlighten (orange)
if (extrahiert) {
Object.values(extrahiert).forEach(wert => {
if (wert && typeof wert === 'string' && wert.length > 2) {
const regex = new RegExp(`(${escapeRegex(wert)})`, 'g');
html = html.replace(regex, '<span class="highlight-extracted">$1</span>');
}
});
}
contentEl.innerHTML = html;
}
// Hilfsfunktion: Regex-Sonderzeichen escapen
function escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ============ Regex-Helfer: Aus Markierung Regex erstellen ============
function regexAusMarkierung() {
// Markierten Text aus dem PDF-Content-Bereich holen
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (!selectedText) {
showAlert('Bitte zuerst einen Text im PDF-Inhalt markieren', 'warning');
return;
}
// Kontext vor und nach der Markierung holen (wenn möglich)
const pdfContent = document.getElementById('pdf-text-content');
if (!pdfContent) {
showAlert('Bitte zuerst eine PDF laden', 'warning');
return;
}
const fullText = pdfContent.textContent || '';
const pos = fullText.indexOf(selectedText);
let regexVorschlag = '';
let beschreibung = '';
// Prüfen ob es ein Muster ist (Datum, Betrag, Nummer, Label+Nummer)
if (/^\d{1,2}[.\/]\d{1,2}[.\/]\d{2,4}$/.test(selectedText)) {
// Datum erkannt
regexVorschlag = erstelleDatumRegex(fullText, selectedText, pos);
beschreibung = 'Datum erkannt';
} else if (/^[\d.,]+\s*(€|EUR)?$/.test(selectedText)) {
// Betrag erkannt
regexVorschlag = erstelleBetragRegex(fullText, selectedText, pos);
beschreibung = 'Betrag erkannt';
} else if (/^[A-Z0-9][\w\-\/]+$/i.test(selectedText)) {
// Nummer/ID erkannt (nur Buchstaben/Zahlen ohne Leerzeichen)
regexVorschlag = erstelleNummerRegex(fullText, selectedText, pos);
beschreibung = 'Nummer/ID erkannt';
} else if (/^[A-Za-zäöüÄÖÜß]+[:\s.\-#]+\d+$/i.test(selectedText)) {
// Label + Nummer erkannt (z.B. "Rechnung 2493150", "Invoice: 12345")
regexVorschlag = erstelleLabelNummerRegex(selectedText);
beschreibung = 'Label + Nummer erkannt';
} else if (/^[A-Za-zäöüÄÖÜß]+[:\s.\-#]+[\w\-\/]+$/i.test(selectedText)) {
// Label + ID erkannt (z.B. "Beleg RE-2024-001")
regexVorschlag = erstelleLabelIdRegex(selectedText);
beschreibung = 'Label + ID erkannt';
} else {
// Allgemeiner Text - Kontext-basiertes Regex
regexVorschlag = erstelleKontextRegex(fullText, selectedText, pos);
beschreibung = 'Text mit Kontext';
}
// Ergebnis anzeigen
zeigeRegexHelferErgebnis(selectedText, regexVorschlag, beschreibung);
}
function erstelleDatumRegex(fullText, selected, pos) {
// Kontext vor dem Datum finden (z.B. "Rechnungsdatum:", "Datum:")
const kontextVorher = fullText.substring(Math.max(0, pos - 30), pos);
const labelMatch = kontextVorher.match(/(\w+[-\s]?\w*)[:\s]*$/);
if (labelMatch) {
const label = labelMatch[1].trim();
return `${escapeRegex(label)}[:\\s]*(\\d{1,2}[./]\\d{1,2}[./]\\d{2,4})`;
}
return '(\\d{1,2}[./]\\d{1,2}[./]\\d{2,4})';
}
function erstelleBetragRegex(fullText, selected, pos) {
const kontextVorher = fullText.substring(Math.max(0, pos - 30), pos);
const labelMatch = kontextVorher.match(/(\w+[-\s]?\w*)[:\s]*$/);
if (labelMatch) {
const label = labelMatch[1].trim();
return `${escapeRegex(label)}[:\\s]*([\\d.,]+)\\s*€?`;
}
return '([\\d.,]+)\\s*€';
}
function erstelleNummerRegex(fullText, selected, pos) {
const kontextVorher = fullText.substring(Math.max(0, pos - 40), pos);
const labelMatch = kontextVorher.match(/([A-Za-zäöüÄÖÜß]+(?:[-\s]?[A-Za-zäöüÄÖÜß]+)?)[:\s#]*$/);
if (labelMatch) {
const label = labelMatch[1].trim();
return `${escapeRegex(label)}[:\\s#]*([A-Z0-9][\\w\\-/]+)`;
}
return '([A-Z0-9][\\w\\-/]+)';
}
function erstelleLabelNummerRegex(selected) {
// Für Texte wie "Rechnung 2493150", "Invoice: 12345", "Beleg-Nr. 789"
// Splittet in Label und Nummer, erstellt Regex das die Nummer captured
const match = selected.match(/^([A-Za-zäöüÄÖÜß]+)[:\s.\-#]+(\d+)$/i);
if (match) {
const label = match[1];
return `${escapeRegex(label)}[:\\s.\\-#]*(\\d+)`;
}
// Fallback: ganzer Text escaped mit Gruppe
return `(${escapeRegex(selected)})`;
}
function erstelleLabelIdRegex(selected) {
// Für Texte wie "Beleg RE-2024-001", "Order ABC123"
// Splittet in Label und ID, erstellt Regex das die ID captured
const match = selected.match(/^([A-Za-zäöüÄÖÜß]+)[:\s.\-#]+([\w\-\/]+)$/i);
if (match) {
const label = match[1];
return `${escapeRegex(label)}[:\\s.\\-#]*([\\w\\-/]+)`;
}
// Fallback: ganzer Text escaped mit Gruppe
return `(${escapeRegex(selected)})`;
}
function erstelleKontextRegex(fullText, selected, pos) {
// Kontext vor dem Text holen (max 50 Zeichen)
const kontextVorher = fullText.substring(Math.max(0, pos - 50), pos);
// Versuche ein Label zu finden (z.B. "Rechnungsnummer:", "Rechnung Nr.", "Beleg:")
// Suche nach Wort(en) gefolgt von : oder Leerzeichen direkt vor dem markierten Text
const labelMatch = kontextVorher.match(/([A-Za-zäöüÄÖÜß]+(?:[-\s]?[A-Za-zäöüÄÖÜß]+)?)\s*[:\s#.]*\s*$/);
if (labelMatch) {
const label = labelMatch[1].trim();
if (label.length >= 2) {
// Label gefunden - baue Regex: Label gefolgt von : oder Whitespace, dann der Wert in einer Gruppe
return `${escapeRegex(label)}[:\\s#.]*\\s*(${escapeRegex(selected)})`;
}
}
// Kein brauchbares Label gefunden - nimm die letzten 1-2 Wörter als Kontext
const worte = kontextVorher.trim().split(/\s+/).filter(w => w.length > 0);
if (worte.length >= 1) {
// Nimm das letzte Wort als Kontext (escapen, dann \\s+ anhängen)
const letztesWort = worte[worte.length - 1];
if (letztesWort.length >= 2 && /[A-Za-zäöüÄÖÜß]/.test(letztesWort)) {
return `${escapeRegex(letztesWort)}\\s+(${escapeRegex(selected)})`;
}
}
// Fallback: nur den markierten Text escapen
return `(${escapeRegex(selected)})`;
}
async function zeigeRegexHelferErgebnis(originalText, regex, beschreibung) {
// Modal oder Inline-Anzeige
const container = document.getElementById('regex-helfer-ergebnis');
if (container) {
container.classList.remove('hidden');
container.innerHTML = `
<div style="padding: 0.75rem; background: var(--bg-tertiary); border-radius: var(--radius); margin-top: 0.5rem;">
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem;">${beschreibung}</div>
<div style="font-size: 0.8rem; margin-bottom: 0.5rem;">
<strong>Markierter Text:</strong> <code style="color: var(--success);">${escapeHtml(originalText)}</code>
</div>
<div style="font-size: 0.8rem; margin-bottom: 0.5rem;">
<strong>Regex-Vorschlag:</strong>
</div>
<input type="text" id="regex-vorschlag-input" value="${escapeHtml(regex)}"
style="width: 100%; font-family: monospace; font-size: 0.7rem; padding: 0.2rem; margin-bottom: 0.3rem;"
onclick="this.select()">
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-sm btn-primary" onclick="kopieRegelRegex()">📋 Kopieren</button>
<button class="btn btn-sm" onclick="document.getElementById('regex-helfer-ergebnis').classList.add('hidden')">Schließen</button>
</div>
</div>
`;
} else {
// Fallback: Dialog + Clipboard
if (await showConfirm(`${beschreibung}\n\nRegex-Vorschlag:\n${regex}\n\nIn Zwischenablage kopieren?`, 'Regex-Vorschlag')) {
navigator.clipboard.writeText(regex).then(() => {
showAlert('Regex in Zwischenablage kopiert!', 'success');
});
}
}
}
function kopieRegelRegex() {
const input = document.getElementById('regex-vorschlag-input');
if (input) {
navigator.clipboard.writeText(input.value).then(() => {
// Kurzes Feedback
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '✓ Kopiert!';
setTimeout(() => btn.textContent = originalText, 1500);
});
}
}
// Alte Funktion für Kompatibilität
async function testeRegel() {
console.log('testeRegel() aufgerufen -> testeRegelLive()');
testeRegelLive();
}
// ============ Regel-Assistent ============
const REGEX_VORLAGEN = {
datum: {
auto: null, // Nutzt globale Extraktoren
rechnungsdatum: "Rechnungsdatum[:\\s]*(\\d{2}[./]\\d{2}[./]\\d{4})",
datum: "Datum[:\\s]*(\\d{2}[./]\\d{2}[./]\\d{4})",
beliebig: "(\\d{2}[./]\\d{2}[./]\\d{4})"
},
betrag: {
auto: null,
gesamtbetrag: "Gesamtbetrag[:\\s]*([\\d.,]+)",
summe: "Summe[:\\s]*([\\d.,]+)",
brutto: "Brutto[:\\s]*([\\d.,]+)"
},
nummer: {
auto: null,
rechnungsnummer: "Rechnungsnummer[:\\s#]*([A-Z0-9][\\w\\-/]+)",
belegnr: "Beleg-?Nr\\.?[:\\s#]*([A-Z0-9][\\w\\-/]+)",
invoice: "Invoice[:\\s#]*([A-Z0-9][\\w\\-/]+)"
}
};
function zeigeRegelAssistent() {
// Modal öffnen
document.getElementById('assistent-modal').classList.remove('hidden');
// Event-Listener für Live-Vorschau
['ass-keywords', 'ass-firma', 'ass-datum-typ', 'ass-betrag-typ', 'ass-nummer-typ', 'ass-schema', 'ass-unterordner'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', aktualisiereAssistentVorschau);
if (el) el.addEventListener('change', aktualisiereAssistentVorschau);
});
['ass-datum-aktiv', 'ass-betrag-aktiv', 'ass-nummer-aktiv'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', aktualisiereAssistentVorschau);
});
aktualisiereAssistentVorschau();
}
function aktualisiereAssistentVorschau() {
const vorschauDiv = document.getElementById('ass-vorschau');
const regel = baueRegelAusAssistent();
let html = '';
// Keywords
const keywords = document.getElementById('ass-keywords').value.trim();
if (keywords) {
html += `<div style="margin-bottom: 0.5rem;"><strong>Erkennung:</strong> Dokument muss "${keywords}" enthalten</div>`;
} else {
html += `<div style="margin-bottom: 0.5rem; color: var(--warning);"><strong>Erkennung:</strong> Passt auf ALLE Dateien (keine Keywords)</div>`;
}
// Firma
const firma = document.getElementById('ass-firma').value.trim();
if (firma) {
html += `<div style="margin-bottom: 0.5rem;"><strong>Firma:</strong> ${escapeHtml(firma)}</div>`;
}
// Felder
html += `<div style="margin-bottom: 0.5rem;"><strong>Extrahiere:</strong> `;
const felder = [];
if (document.getElementById('ass-datum-aktiv').checked) felder.push('📅 Datum');
if (document.getElementById('ass-betrag-aktiv').checked) felder.push('💰 Betrag');
if (document.getElementById('ass-nummer-aktiv').checked) felder.push('🔢 Nummer');
html += felder.join(', ') || '<em>nichts</em>';
html += '</div>';
// Beispiel-Dateiname
const schema = document.getElementById('ass-schema').value;
let beispiel = schema
.replace('{datum}', '2024-01-15')
.replace('{firma}', firma || 'Firma')
.replace('{nummer}', 'RE-12345')
.replace('{betrag}', '199,99');
html += `<div style="margin-top: 0.5rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: var(--radius);"><strong>Beispiel-Dateiname:</strong><br><code style="color: var(--success);">${escapeHtml(beispiel)}</code></div>`;
vorschauDiv.innerHTML = html;
}
function baueRegelAusAssistent() {
const keywords = document.getElementById('ass-keywords').value.trim();
const firma = document.getElementById('ass-firma').value.trim();
const schema = document.getElementById('ass-schema').value;
const unterordner = document.getElementById('ass-unterordner').value.trim();
// Muster
const muster = {};
if (keywords) {
muster.keywords = keywords;
}
// Extraktion
const extraktion = {};
// Firma
if (firma) {
extraktion.firma = { wert: firma };
}
// Datum
if (document.getElementById('ass-datum-aktiv').checked) {
const datumTyp = document.getElementById('ass-datum-typ').value;
if (datumTyp !== 'auto' && REGEX_VORLAGEN.datum[datumTyp]) {
extraktion.datum = { regex: REGEX_VORLAGEN.datum[datumTyp] };
}
// Bei "auto" wird nichts eingetragen - globale Extraktoren werden genutzt
}
// Betrag
if (document.getElementById('ass-betrag-aktiv').checked) {
const betragTyp = document.getElementById('ass-betrag-typ').value;
if (betragTyp !== 'auto' && REGEX_VORLAGEN.betrag[betragTyp]) {
extraktion.betrag = { regex: REGEX_VORLAGEN.betrag[betragTyp], typ: "betrag" };
}
}
// Nummer
if (document.getElementById('ass-nummer-aktiv').checked) {
const nummerTyp = document.getElementById('ass-nummer-typ').value;
if (nummerTyp !== 'auto' && REGEX_VORLAGEN.nummer[nummerTyp]) {
extraktion.nummer = { regex: REGEX_VORLAGEN.nummer[nummerTyp] };
}
}
return { muster, extraktion, schema, unterordner };
}
function assistentUebernehmen() {
const regel = baueRegelAusAssistent();
// In die Regel-Felder übernehmen
document.getElementById('regel-muster').value = JSON.stringify(regel.muster, null, 2);
document.getElementById('regel-extraktion').value = JSON.stringify(regel.extraktion, null, 2);
document.getElementById('regel-schema').value = regel.schema;
document.getElementById('regel-unterordner').value = regel.unterordner || '';
// Regelname vorschlagen wenn leer
const nameField = document.getElementById('regel-name');
if (!nameField.value.trim()) {
const firma = document.getElementById('ass-firma').value.trim();
const keywords = document.getElementById('ass-keywords').value.trim();
if (firma) {
nameField.value = firma + ' Rechnung';
} else if (keywords) {
nameField.value = keywords.split(',')[0].trim() + ' Rechnung';
}
}
schliesseModal('assistent-modal');
// Wenn PDF-Text vorhanden, automatisch testen
if (document.getElementById('regel-test-text').value.trim()) {
setTimeout(() => testeRegelLive(), 300);
}
}
// ============ Auto-Regex Generator ============
let letzteAutoRegexErgebnis = null;
async function autoRegexGenerieren() {
const input = document.getElementById('regel-test-datei');
if (!input.files || !input.files[0]) {
showAlert('Bitte zuerst eine PDF-Datei hochladen', 'warning');
return;
}
const formData = new FormData();
formData.append('datei', input.files[0]);
try {
zeigeLoading('Analysiere PDF...');
const response = await fetch('/api/pdf/auto-regex', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Fehler bei der Analyse');
}
const result = await response.json();
letzteAutoRegexErgebnis = result;
// Ergebnis anzeigen
const container = document.getElementById('auto-regex-ergebnis');
const felderDiv = document.getElementById('auto-regex-felder');
const keywordsDiv = document.getElementById('auto-regex-keywords');
container.classList.remove('hidden');
// Erkannte Felder anzeigen
if (result.erkannte_felder && result.erkannte_felder.length > 0) {
let html = '<table style="width: 100%; font-size: 0.85rem;">';
html += '<tr style="color: var(--text-secondary);"><th style="text-align: left;">Feld</th><th style="text-align: left;">Wert</th><th style="text-align: left;">Kontext</th></tr>';
for (const feld of result.erkannte_felder) {
const feldName = {
'datum': '📅 Datum',
'betrag': '💰 Betrag',
'nummer': '🔢 Nummer',
'firma': '🏢 Firma'
}[feld.feld] || feld.feld;
html += `<tr>
<td><strong>${feldName}</strong></td>
<td style="color: var(--success);">${escapeHtml(feld.extrahiert || feld.wert)}</td>
<td style="color: var(--text-secondary); font-size: 0.8rem;">${escapeHtml(feld.kontext)}</td>
</tr>`;
}
html += '</table>';
felderDiv.innerHTML = html;
} else {
felderDiv.innerHTML = '<p style="color: var(--text-secondary);">Keine Felder automatisch erkannt</p>';
}
// Keywords anzeigen
if (result.gefundene_keywords && result.gefundene_keywords.length > 0) {
keywordsDiv.innerHTML = `<div style="margin-top: 0.5rem;">
<strong>🏷️ Keywords:</strong>
<span style="color: var(--primary);">${result.gefundene_keywords.join(', ')}</span>
</div>`;
} else {
keywordsDiv.innerHTML = '';
}
// Text auch anzeigen
if (result.text_vorschau) {
document.getElementById('regel-test-text').value = result.text_vorschau;
}
// DIREKT die Regel-Felder ausfüllen (nicht nur anzeigen)
wendeAutoRegexAn();
} catch (error) {
showAlert(error.message, 'error');
} finally {
versteckeLoading();
}
}
function wendeAutoRegexAn() {
if (!letzteAutoRegexErgebnis || !letzteAutoRegexErgebnis.regel_vorschlag) {
// Stille Rückkehr wenn keine Ergebnisse (wird automatisch aufgerufen)
return;
}
const vorschlag = letzteAutoRegexErgebnis.regel_vorschlag;
const erkannteFelder = letzteAutoRegexErgebnis.erkannte_felder || [];
// Muster immer übernehmen (auch wenn leer = passt auf alles)
const muster = vorschlag.muster || {};
document.getElementById('regel-muster').value = JSON.stringify(muster, null, 2);
// Extraktion immer übernehmen
const extraktion = vorschlag.extraktion || {};
document.getElementById('regel-extraktion').value = JSON.stringify(extraktion, null, 2);
// Schema übernehmen
if (vorschlag.schema) {
document.getElementById('regel-schema').value = vorschlag.schema;
}
// Auch Assistenten-Felder vorausfüllen (für spätere Nutzung)
if (letzteAutoRegexErgebnis.gefundene_keywords) {
document.getElementById('ass-keywords').value = letzteAutoRegexErgebnis.gefundene_keywords.join(', ');
}
// Firma aus erkannten Feldern
const firmaFeld = erkannteFelder.find(f => f.feld === 'firma');
if (firmaFeld) {
document.getElementById('ass-firma').value = firmaFeld.wert;
}
// Regelname vorschlagen
const nameField = document.getElementById('regel-name');
if (!nameField.value.trim() && firmaFeld) {
nameField.value = firmaFeld.wert + ' Rechnung';
}
// Hinweis anzeigen
const container = document.getElementById('auto-regex-ergebnis');
if (container) {
container.classList.remove('hidden');
container.innerHTML = `<div style="color: var(--success); font-weight: bold;">
✓ Erkannte Muster wurden übernommen!<br>
<small style="font-weight: normal;">Die Felder wurden automatisch ausgefüllt.</small>
</div>`;
}
// Auto-Test starten
setTimeout(() => testeRegelLive(), 500);
}
// ============ Sortierung starten ============
async function sortierungStarten() {
const container = document.getElementById('sortierung-log');
container.innerHTML = '<p class="empty-state">Sortierung läuft...</p>';
try {
const result = await api('/sortierung/starten', { method: 'POST' });
zeigeSortierungLog(result);
} catch (error) {
container.innerHTML = `<div class="log-entry error"><span>✗ Fehler: ${escapeHtml(error.message)}</span></div>`;
}
}
function zeigeSortierungLog(result) {
const container = document.getElementById('sortierung-log');
if (!result.verarbeitet || result.verarbeitet.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Dateien verarbeitet</p>';
return;
}
let html = `<div class="log-entry info">
<span>Gesamt: ${result.gesamt} | Sortiert: ${result.sortiert} | ZUGFeRD: ${result.zugferd} | Fehler: ${result.fehler}</span>
</div>`;
for (const d of result.verarbeitet) {
const status = d.fehler ? 'error' : (d.zugferd ? 'info' : 'success');
const icon = d.fehler ? '✗' : (d.zugferd ? '🧾' : '✓');
html += `<div class="log-entry ${status}">
<span>${icon} ${escapeHtml(d.neuer_name || d.original)}</span>
${d.fehler ? `<small>${escapeHtml(d.fehler)}</small>` : ''}
</div>`;
}
container.innerHTML = html;
}
// ============ BEREICH 3: Zeitpläne ============
let editierterZeitplanId = null;
async function ladeZeitplaene() {
try {
const result = await api('/zeitplaene');
renderZeitplaene(result.zeitplaene || []);
} catch (error) {
console.error('Fehler:', error);
}
}
function renderZeitplaene(zeitplaene) {
const container = document.getElementById('zeitplaene-liste');
if (!zeitplaene || zeitplaene.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Zeitpläne konfiguriert</p>';
return;
}
container.innerHTML = zeitplaene.map(zp => {
const statusClass = zp.letzter_status === 'erfolg' ? 'success' : (zp.letzter_status === 'fehler' ? 'error' : '');
const aktivClass = zp.aktiv ? '' : 'opacity: 0.5;';
const typIcon = zp.typ === 'mail_abruf' ? '📧' : (zp.typ === 'grobsortierung' ? '📁' : '📋');
const naechste = zp.naechste_ausfuehrung ? formatDatum(zp.naechste_ausfuehrung) : '-';
const letzte = zp.letzte_ausfuehrung ? formatDatum(zp.letzte_ausfuehrung) : 'Noch nie';
return `
<div class="config-item" style="${aktivClass}">
<div class="config-item-info">
<h4>${typIcon} ${escapeHtml(zp.name)}
<span class="badge ${zp.aktiv ? 'badge-success' : 'badge-danger'}">${zp.aktiv ? 'Aktiv' : 'Inaktiv'}</span>
<span class="badge badge-info">${zp.intervall}</span>
</h4>
<small>Nächste: ${naechste} | Letzte: ${letzte}</small>
${zp.letzter_status ? `<small style="display:block;" class="${statusClass}">Status: ${zp.letzter_status}${zp.letzte_meldung ? ' - ' + escapeHtml(zp.letzte_meldung.substring(0, 80)) : ''}</small>` : ''}
</div>
<div class="config-item-actions">
<button class="btn btn-sm btn-success" onclick="zeitplanAusfuehren(${zp.id})" title="Jetzt ausführen">▶</button>
<button class="btn btn-sm" onclick="zeitplanBearbeiten(${zp.id})" title="Bearbeiten">✎</button>
<button class="btn btn-sm" onclick="zeitplanAktivieren(${zp.id})" title="${zp.aktiv ? 'Deaktivieren' : 'Aktivieren'}">${zp.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm btn-danger" onclick="zeitplanLoeschen(${zp.id})" title="Löschen">×</button>
</div>
</div>
`}).join('');
}
async function ladeStatus() {
try {
const result = await api('/status/uebersicht');
renderStatusUebersicht(result);
} catch (error) {
console.error('Fehler:', error);
}
}
function renderStatusUebersicht(status) {
const container = document.getElementById('status-uebersicht');
let html = '<div class="status-grid">';
// Postfächer
html += '<div class="status-section"><h4>📧 Postfächer</h4>';
if (status.postfaecher && status.postfaecher.length > 0) {
for (const p of status.postfaecher) {
const aktiv = p.aktiv ? '🟢' : '⚪';
const letzte = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie';
html += `<div class="status-item">${aktiv} ${escapeHtml(p.name)}: ${letzte} (${p.letzte_anzahl || 0} Dateien)</div>`;
}
} else {
html += '<div class="status-item">Keine Postfächer</div>';
}
html += '</div>';
// Grobsortierung
html += '<div class="status-section"><h4>📁 Grobsortierung</h4>';
if (status.quell_ordner && status.quell_ordner.length > 0) {
for (const o of status.quell_ordner) {
const aktiv = o.aktiv ? '🟢' : '⚪';
html += `<div class="status-item">${aktiv} ${escapeHtml(o.name)}</div>`;
}
} else {
html += '<div class="status-item">Keine Ordner</div>';
}
html += '</div>';
// Scheduler
html += '<div class="status-section"><h4>⏰ Scheduler</h4>';
const schedulerStatus = status.scheduler?.scheduler_laeuft ? '🟢 Läuft' : '🔴 Gestoppt';
html += `<div class="status-item">${schedulerStatus}</div>`;
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
function formatDatum(isoString) {
if (!isoString) return '-';
const d = new Date(isoString);
// Explizite Formatierung mit führenden Nullen
const tag = String(d.getDate()).padStart(2, '0');
const monat = String(d.getMonth() + 1).padStart(2, '0');
const jahr = d.getFullYear();
const stunde = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
return `${tag}.${monat}.${jahr} ${stunde}:${minute}`;
}
async function zeigeZeitplanModal(zeitplan = null) {
editierterZeitplanId = zeitplan?.id || null;
document.getElementById('zeitplan-modal-title').textContent = zeitplan ? 'Zeitplan bearbeiten' : 'Zeitplan hinzufügen';
document.getElementById('zp-name').value = zeitplan?.name || '';
document.getElementById('zp-typ').value = zeitplan?.typ || 'mail_abruf';
document.getElementById('zp-intervall').value = zeitplan?.intervall || 'täglich';
document.getElementById('zp-stunde').value = zeitplan?.stunde ?? 6;
document.getElementById('zp-minute').value = zeitplan?.minute ?? 0;
document.getElementById('zp-wochentag').value = zeitplan?.wochentag ?? 0;
document.getElementById('zp-monatstag').value = zeitplan?.monatstag ?? 1;
// Postfächer, Ordner und Regeln laden für Dropdowns
await ladeZeitplanOptionen();
document.getElementById('zp-postfach').value = zeitplan?.postfach_id || '';
document.getElementById('zp-ordner').value = zeitplan?.quell_ordner_id || '';
document.getElementById('zp-regel').value = zeitplan?.regel_id || '';
zeitplanTypChanged();
zeitplanIntervallChanged();
document.getElementById('zeitplan-modal').classList.remove('hidden');
}
async function ladeZeitplanOptionen() {
try {
// Postfächer laden
const postfaecher = await api('/postfaecher');
const pfSelect = document.getElementById('zp-postfach');
pfSelect.innerHTML = '<option value="">Alle aktiven Postfächer</option>' +
postfaecher.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
// Ordner laden
const ordner = await api('/ordner');
const ordSelect = document.getElementById('zp-ordner');
ordSelect.innerHTML = '<option value="">Alle aktiven Ordner</option>' +
ordner.map(o => `<option value="${o.id}">${escapeHtml(o.name)}</option>`).join('');
// Regeln laden
const regeln = await api('/regeln');
const regelSelect = document.getElementById('zp-regel');
regelSelect.innerHTML = '<option value="">Alle aktiven Regeln</option>' +
regeln.map(r => `<option value="${r.id}">${escapeHtml(r.name)}</option>`).join('');
} catch (error) {
console.error('Fehler:', error);
}
}
function zeitplanTypChanged() {
const typ = document.getElementById('zp-typ').value;
document.getElementById('zp-postfach-gruppe').classList.toggle('hidden', typ !== 'mail_abruf');
document.getElementById('zp-ordner-gruppe').classList.toggle('hidden', typ !== 'grobsortierung');
document.getElementById('zp-regel-gruppe').classList.toggle('hidden', typ !== 'sortierregeln');
}
function zeitplanIntervallChanged() {
const intervall = document.getElementById('zp-intervall').value;
document.getElementById('zp-zeit-gruppe').classList.toggle('hidden', intervall === 'stündlich');
document.getElementById('zp-wochentag-gruppe').classList.toggle('hidden', intervall !== 'wöchentlich');
document.getElementById('zp-monatstag-gruppe').classList.toggle('hidden', intervall !== 'monatlich');
// Info-Text aktualisieren
const info = document.getElementById('zp-intervall-info');
if (info) {
const infos = {
'stündlich': 'Wird jede Stunde zur angegebenen Minute ausgeführt',
'täglich': 'Wird jeden Tag einmal zur angegebenen Uhrzeit ausgeführt',
'wöchentlich': 'Wird einmal pro Woche am angegebenen Tag und Uhrzeit ausgeführt',
'monatlich': 'Wird einmal pro Monat am angegebenen Tag und Uhrzeit ausgeführt'
};
info.textContent = infos[intervall] || '';
}
}
async function speichereZeitplan() {
const data = {
name: document.getElementById('zp-name').value.trim(),
typ: document.getElementById('zp-typ').value,
intervall: document.getElementById('zp-intervall').value,
stunde: parseInt(document.getElementById('zp-stunde').value) || 0,
minute: parseInt(document.getElementById('zp-minute').value) || 0,
wochentag: document.getElementById('zp-intervall').value === 'wöchentlich' ?
parseInt(document.getElementById('zp-wochentag').value) : null,
monatstag: document.getElementById('zp-intervall').value === 'monatlich' ?
parseInt(document.getElementById('zp-monatstag').value) : null
};
// Optionale IDs
const postfachId = document.getElementById('zp-postfach').value;
const ordnerId = document.getElementById('zp-ordner').value;
const regelId = document.getElementById('zp-regel').value;
if (data.typ === 'mail_abruf' && postfachId) {
data.postfach_id = parseInt(postfachId);
}
if (data.typ === 'grobsortierung' && ordnerId) {
data.quell_ordner_id = parseInt(ordnerId);
}
if (data.typ === 'sortierregeln' && regelId) {
data.regel_id = parseInt(regelId);
}
if (!data.name) {
showAlert('Bitte einen Namen eingeben', 'warning');
return;
}
try {
if (editierterZeitplanId) {
await api(`/zeitplaene/${editierterZeitplanId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/zeitplaene', { method: 'POST', body: JSON.stringify(data) });
}
schliesseModal('zeitplan-modal');
ladeZeitplaene();
ladeStatus();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function zeitplanAktivieren(id) {
try {
await api(`/zeitplaene/${id}/aktivieren`, { method: 'POST' });
ladeZeitplaene();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function zeitplanAusfuehren(id) {
try {
zeigeLoading('Führe Zeitplan aus...');
const result = await api(`/zeitplaene/${id}/ausfuehren`, { method: 'POST' });
showAlert(result.meldung || 'Ausgeführt', 'success', 'Zeitplan ausgeführt');
ladeZeitplaene();
ladeStatus();
} catch (error) {
showAlert(error.message, 'error');
} finally {
versteckeLoading();
}
}
async function zeitplanBearbeiten(id) {
try {
const zeitplan = await api(`/zeitplaene/${id}`);
zeigeZeitplanModal(zeitplan);
} catch (error) {
showAlert('Fehler beim Laden: ' + error.message, 'error');
}
}
async function zeitplanLoeschen(id) {
if (!await showConfirm('Zeitplan wirklich löschen?')) return;
try {
await api(`/zeitplaene/${id}`, { method: 'DELETE' });
ladeZeitplaene();
} catch (error) {
showAlert(error.message, 'error');
}
}
// ============ Utilities ============
function schliesseModal(id) {
document.getElementById(id).classList.add('hidden');
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function truncatePath(path, maxLength = 35) {
if (!path) return '';
const escaped = escapeHtml(path);
if (path.length <= maxLength) return escaped;
// Pfad kürzen: Anfang ... Ende
const start = path.substring(0, 15);
const end = path.substring(path.length - 17);
return `<span title="${escaped}" style="cursor:help;">${escapeHtml(start)}...${escapeHtml(end)}</span>`;
}
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
e.target.classList.add('hidden');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden'));
}
});