V 2.0 - Feinsortierung Live-Streaming, Import/Export, PDF-Rotation

Neue Features:
- Feinsortierung mit Live-Streaming (SSE) - zeigt Fortschritt in Echtzeit
- Import/Export für Sortierregeln (JSON)
- Sortierregeln-Liste mit Sortieroptionen (Name A-Z/Z-A, Priorität)
- Checkbox "Auch Dateinamen prüfen" für Keyword-Matching
- Automatische PDF-Seitenrotation bei OCR (90°, 180°, 270°)
- Tab-Persistenz über Page-Reload (localStorage)
- Modals schließen nur noch über X-Button

Bugfixes:
- Keywords nutzen jetzt Wortgrenzen (\b) - "rechnung" matched nicht mehr "Berechnung"
- Keyword-Prüfung standardmäßig nur auf PDF-Text, nicht Dateinamen
- Natürliche Sortierung für Regelnamen (1, 2, 10 statt 1, 10, 2)

Technisch:
- Async SSE-Generator mit asyncio.sleep(0) für sofortiges Streaming
- ocrmypdf mit --rotate-pages Flag
- Timeout für OCR auf 3 Minuten erhöht

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-10 13:42:12 +01:00
parent 6e85481f52
commit c5ee82e1c2
12 changed files with 2382 additions and 100 deletions

View file

@ -1,15 +1,19 @@
# Dateiverwaltung Docker Image # Dateiverwaltung Docker Image
FROM python:3.11-slim FROM python:3.11-slim
# System-Abhängigkeiten für OCR und PDF # System-Abhängigkeiten für OCR, PDF und DB-Backup
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \ tesseract-ocr \
tesseract-ocr-deu \ tesseract-ocr-deu \
ocrmypdf \ ocrmypdf \
unpaper \
poppler-utils \ poppler-utils \
ghostscript \ ghostscript \
libmagic1 \ libmagic1 \
curl \ curl \
mariadb-client \
postgresql-client \
gzip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Arbeitsverzeichnis # Arbeitsverzeichnis

View file

@ -152,10 +152,11 @@ class Zeitplan(Base):
aktiv = Column(Boolean, default=True) aktiv = Column(Boolean, default=True)
# Was wird ausgeführt # Was wird ausgeführt
typ = Column(String(50), nullable=False) # "mail_abruf", "grobsortierung", "sortierregeln" typ = Column(String(50), nullable=False) # "mail_abruf", "grobsortierung", "sortierregeln", "db_backup"
postfach_id = Column(Integer) # Optional: spezifisches Postfach postfach_id = Column(Integer) # Optional: spezifisches Postfach
quell_ordner_id = Column(Integer) # Optional: spezifischer Quellordner quell_ordner_id = Column(Integer) # Optional: spezifischer Quellordner
regel_id = Column(Integer) # Optional: spezifische Regel (für sortierregeln) regel_id = Column(Integer) # Optional: spezifische Regel (für sortierregeln)
datenbank_id = Column(Integer) # Optional: spezifische Datenbank (für db_backup)
# Zeitplan-Intervall # Zeitplan-Intervall
intervall = Column(String(20), nullable=False) # "stündlich", "täglich", "wöchentlich", "monatlich" intervall = Column(String(20), nullable=False) # "stündlich", "täglich", "wöchentlich", "monatlich"
@ -207,6 +208,53 @@ class VerarbeiteteDatei(Base):
verarbeitet_am = Column(DateTime, default=datetime.utcnow) verarbeitet_am = Column(DateTime, default=datetime.utcnow)
# ============ BEREICH 3: Datenbank-Backup ============
class DbServer(Base):
"""Datenbank-Server Konfiguration"""
__tablename__ = "db_server"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
typ = Column(String(20), nullable=False) # "mariadb", "mysql", "postgresql"
host = Column(String(255), nullable=False)
port = Column(Integer, default=3306)
user = Column(String(100), nullable=False)
passwort = Column(String(255), nullable=False)
aktiv = Column(Boolean, default=True)
erstellt_am = Column(DateTime, default=datetime.utcnow)
class Datenbank(Base):
"""Datenbank-Konfiguration für Backup"""
__tablename__ = "datenbanken"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
server_id = Column(Integer, ForeignKey("db_server.id", ondelete="CASCADE"), nullable=False)
database = Column(String(100), nullable=False) # Datenbank-Name
backup_pfad = Column(String(500), nullable=False) # Zielordner für Backups
aufbewahrung = Column(Integer, default=7) # Anzahl Backups die behalten werden
format = Column(String(20), default="sql") # "sql", "sql.gz", "zip"
aktiv = Column(Boolean, default=True)
letztes_backup = Column(DateTime)
letzte_groesse_mb = Column(Integer)
erstellt_am = Column(DateTime, default=datetime.utcnow)
class BackupLog(Base):
"""Log durchgeführter Backups"""
__tablename__ = "backup_log"
id = Column(Integer, primary_key=True)
datenbank_id = Column(Integer, ForeignKey("datenbanken.id", ondelete="CASCADE"), nullable=False)
dateiname = Column(String(500), nullable=False)
groesse_bytes = Column(Integer)
status = Column(String(50)) # "erfolg", "fehler"
fehler = Column(Text)
erstellt_am = Column(DateTime, default=datetime.utcnow)
def migrate_db(): def migrate_db():
"""Fügt fehlende Spalten hinzu ohne Daten zu löschen""" """Fügt fehlende Spalten hinzu ohne Daten zu löschen"""
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
@ -240,7 +288,8 @@ def migrate_db():
"nur_umbenennen": "BOOLEAN DEFAULT 0" "nur_umbenennen": "BOOLEAN DEFAULT 0"
}, },
"zeitplaene": { "zeitplaene": {
"regel_id": "INTEGER" "regel_id": "INTEGER",
"datenbank_id": "INTEGER"
} }
} }

View file

@ -444,15 +444,15 @@ class PDFProcessor:
"ocrmypdf", "ocrmypdf",
"--language", self.ocr_language, "--language", self.ocr_language,
"--deskew", # Schräge Scans korrigieren "--deskew", # Schräge Scans korrigieren
"--rotate-pages", # Automatische Seitenrotation (90°, 180°, 270°)
"--clean", # Bild verbessern "--clean", # Bild verbessern
"--skip-text", # Seiten mit Text überspringen "--skip-text", # Seiten mit Text überspringen
"--force-ocr", # OCR erzwingen falls nötig
str(pfad), str(pfad),
str(temp_pfad) str(temp_pfad)
], ],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120 # 2 Minuten Timeout timeout=180 # 3 Minuten Timeout (Rotation braucht etwas länger)
) )
if result.returncode == 0 and temp_pfad.exists(): if result.returncode == 0 and temp_pfad.exists():

View file

@ -52,15 +52,28 @@ class Sorter:
original_name = dokument_info.get("original_name", "").lower() original_name = dokument_info.get("original_name", "").lower()
absender = dokument_info.get("absender", "").lower() absender = dokument_info.get("absender", "").lower()
# Option: Auch Dateinamen prüfen (Standard: nur Text)
auch_dateiname = muster.get("auch_dateiname", False)
# keywords (einfache Komma-getrennte Liste - für UI) # keywords (einfache Komma-getrennte Liste - für UI)
# Verwendet Wortgrenzen (\b) um Teilstring-Matches zu vermeiden
# z.B. "rechnung" matched nicht "Versicherungsnummer" oder "Berechnung"
if "keywords" in muster: if "keywords" in muster:
keywords = muster["keywords"] keywords = muster["keywords"]
if isinstance(keywords, str): if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(",")] keywords = [k.strip() for k in keywords.split(",")]
# Alle Keywords müssen vorkommen # Alle Keywords müssen vorkommen (als ganzes Wort)
for keyword in keywords: for keyword in keywords:
keyword = keyword.lower().strip() keyword = keyword.lower().strip()
if keyword and keyword not in text and keyword not in original_name: if keyword:
# Regex mit Wortgrenzen für exaktes Wort-Matching
pattern = r'\b' + re.escape(keyword) + r'\b'
# Standard: nur im Text suchen, mit Option auch im Dateinamen
if auch_dateiname:
if not re.search(pattern, text) and not re.search(pattern, original_name):
return False
else:
if not re.search(pattern, text):
return False return False
# absender_contains # absender_contains
@ -105,14 +118,22 @@ class Sorter:
# ============ NEGATIVE MUSTER (dürfen NICHT vorkommen) ============ # ============ NEGATIVE MUSTER (dürfen NICHT vorkommen) ============
# keywords_nicht (keines darf vorkommen) # keywords_nicht (keines darf vorkommen - als ganzes Wort)
if "keywords_nicht" in muster: if "keywords_nicht" in muster:
keywords = muster["keywords_nicht"] keywords = muster["keywords_nicht"]
if isinstance(keywords, str): if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(",")] keywords = [k.strip() for k in keywords.split(",")]
for keyword in keywords: for keyword in keywords:
keyword = keyword.lower().strip() keyword = keyword.lower().strip()
if keyword and (keyword in text or keyword in original_name): if keyword:
# Regex mit Wortgrenzen für exaktes Wort-Matching
pattern = r'\b' + re.escape(keyword) + r'\b'
# Standard: nur im Text prüfen, mit Option auch im Dateinamen
if auch_dateiname:
if re.search(pattern, text) or re.search(pattern, original_name):
return False # Verbotenes Keyword gefunden
else:
if re.search(pattern, text):
return False # Verbotenes Keyword gefunden return False # Verbotenes Keyword gefunden
# text_not_match (keines darf enthalten sein) # text_not_match (keines darf enthalten sein)
@ -217,7 +238,17 @@ class Sorter:
# Datum formatieren # Datum formatieren
if "format" in config: if "format" in config:
try: try:
datum = datetime.strptime(wert.strip(), config["format"]) datum_str = wert.strip()
# Deutsche Monatsnamen zu englischen konvertieren
de_monate = {
'Januar': 'January', 'Februar': 'February', 'März': 'March',
'April': 'April', 'Mai': 'May', 'Juni': 'June',
'Juli': 'July', 'August': 'August', 'September': 'September',
'Oktober': 'October', 'November': 'November', 'Dezember': 'December'
}
for de, en in de_monate.items():
datum_str = datum_str.replace(de, en)
datum = datetime.strptime(datum_str, config["format"])
return datum.strftime("%Y-%m-%d") return datum.strftime("%Y-%m-%d")
except: except:
pass pass

View file

@ -13,7 +13,7 @@ import asyncio
import tempfile import tempfile
import re import re
from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail, Zeitplan, OrdnerRegel from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail, Zeitplan, OrdnerRegel, DbServer, Datenbank, BackupLog
from ..modules.mail_fetcher import MailFetcher from ..modules.mail_fetcher import MailFetcher
from ..modules.pdf_processor import PDFProcessor from ..modules.pdf_processor import PDFProcessor
from ..modules.sorter import Sorter from ..modules.sorter import Sorter
@ -716,7 +716,11 @@ def verarbeite_ordner(id: int, db: Session = Depends(get_db)):
return {"fehler": "Ordner existiert nicht", "verarbeitet": []} return {"fehler": "Ordner existiert nicht", "verarbeitet": []}
regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all()
if not regeln:
# Prüfen ob direkt_verschieben aktiv ist
hat_direkt_verschieben = getattr(quell_ordner, 'direkt_verschieben', False)
if not regeln and not hat_direkt_verschieben:
return {"fehler": "Keine Regeln definiert", "verarbeitet": []} return {"fehler": "Keine Regeln definiert", "verarbeitet": []}
# Regeln in Dict-Format # Regeln in Dict-Format
@ -833,6 +837,49 @@ def verarbeite_ordner(id: int, db: Session = Depends(get_db)):
ergebnis["verarbeitet"].append(datei_info) ergebnis["verarbeitet"].append(datei_info)
continue continue
# Direkt verschieben ohne Regel?
if hat_direkt_verschieben:
# Einfach in Zielordner verschieben ohne Umbenennung
ziel_basis.mkdir(parents=True, exist_ok=True)
# Original sichern falls konfiguriert
original_sichern = getattr(quell_ordner, 'original_sichern', None)
if original_sichern:
import shutil
backup_dir = Path(original_sichern)
backup_dir.mkdir(parents=True, exist_ok=True)
backup_pfad = backup_dir / datei.name
counter = 1
while backup_pfad.exists():
backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}"
counter += 1
shutil.copy2(str(datei), str(backup_pfad))
datei_info["original_gesichert"] = str(backup_pfad)
neuer_pfad = ziel_basis / datei.name
counter = 1
while neuer_pfad.exists():
neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}"
counter += 1
datei.rename(neuer_pfad)
ergebnis["sortiert"] += 1
datei_info["neuer_name"] = neuer_pfad.name
datei_info["status"] = "direkt_verschoben"
db.add(VerarbeiteteDatei(
original_pfad=str(datei),
original_name=datei.name,
neuer_pfad=str(neuer_pfad),
neuer_name=neuer_pfad.name,
ist_zugferd=ist_zugferd,
ocr_durchgefuehrt=ocr_gemacht,
status="direkt_verschoben"
))
ergebnis["verarbeitet"].append(datei_info)
continue
# Regel finden # Regel finden
doc_info = { doc_info = {
"text": text, "text": text,
@ -1007,6 +1054,95 @@ def teste_regel(data: RegelTestRequest):
return {"passt": False} return {"passt": False}
@router.get("/regeln/export")
def export_regeln(db: Session = Depends(get_db)):
"""Exportiert alle Regeln als JSON"""
regeln = db.query(SortierRegel).order_by(SortierRegel.prioritaet).all()
export_data = []
for r in regeln:
export_data.append({
"name": r.name,
"prioritaet": r.prioritaet,
"muster": r.muster,
"extraktion": r.extraktion,
"schema": r.schema,
"unterordner": r.unterordner,
"ziel_ordner": r.ziel_ordner,
"nur_umbenennen": r.nur_umbenennen,
"ist_fallback": r.ist_fallback,
"aktiv": r.aktiv
})
return {"regeln": export_data, "anzahl": len(export_data)}
class RegelnImportRequest(BaseModel):
regeln: List[dict]
modus: str = "hinzufuegen" # "hinzufuegen", "ersetzen", "aktualisieren"
@router.post("/regeln/import")
def import_regeln(data: RegelnImportRequest, db: Session = Depends(get_db)):
"""Importiert Regeln aus JSON"""
importiert = 0
aktualisiert = 0
uebersprungen = 0
if data.modus == "ersetzen":
# Alle bestehenden Regeln löschen
db.query(OrdnerRegel).delete()
db.query(SortierRegel).delete()
for regel_data in data.regeln:
name = regel_data.get("name")
if not name:
uebersprungen += 1
continue
# Prüfen ob Regel mit diesem Namen existiert
existiert = db.query(SortierRegel).filter(SortierRegel.name == name).first()
if existiert and data.modus == "hinzufuegen":
uebersprungen += 1
continue
if existiert and data.modus == "aktualisieren":
# Bestehende Regel aktualisieren
existiert.prioritaet = regel_data.get("prioritaet", 100)
existiert.muster = regel_data.get("muster", {})
existiert.extraktion = regel_data.get("extraktion", {})
existiert.schema = regel_data.get("schema", "{datum} - Dokument.pdf")
existiert.unterordner = regel_data.get("unterordner")
existiert.ziel_ordner = regel_data.get("ziel_ordner")
existiert.nur_umbenennen = regel_data.get("nur_umbenennen", False)
existiert.ist_fallback = regel_data.get("ist_fallback", False)
existiert.aktiv = regel_data.get("aktiv", True)
aktualisiert += 1
else:
# Neue Regel erstellen
neue_regel = SortierRegel(
name=name,
prioritaet=regel_data.get("prioritaet", 100),
muster=regel_data.get("muster", {}),
extraktion=regel_data.get("extraktion", {}),
schema=regel_data.get("schema", "{datum} - Dokument.pdf"),
unterordner=regel_data.get("unterordner"),
ziel_ordner=regel_data.get("ziel_ordner"),
nur_umbenennen=regel_data.get("nur_umbenennen", False),
ist_fallback=regel_data.get("ist_fallback", False),
aktiv=regel_data.get("aktiv", True)
)
db.add(neue_regel)
importiert += 1
db.commit()
return {
"importiert": importiert,
"aktualisiert": aktualisiert,
"uebersprungen": uebersprungen,
"gesamt": len(data.regeln)
}
# ============ Ordner-Regel-Zuweisungen ============ # ============ Ordner-Regel-Zuweisungen ============
@router.get("/ordner/{ordner_id}/regeln") @router.get("/ordner/{ordner_id}/regeln")
@ -1507,6 +1643,207 @@ def starte_sortierung(db: Session = Depends(get_db)):
return ergebnis return ergebnis
@router.get("/sortierung/starten/stream")
async def starte_sortierung_stream(db: Session = Depends(get_db)):
"""Streaming-Endpoint für Feinsortierung mit Live-Updates"""
from ..models.database import SessionLocal
# Daten vorab laden (Session ist im Generator nicht verfügbar)
ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all()
ordner_daten = [{
"id": o.id, "name": o.name, "ziel_ordner": o.ziel_ordner,
"dateitypen": o.dateitypen, "ocr_aktivieren": getattr(o, 'ocr_aktivieren', True),
"original_sichern": getattr(o, 'original_sichern', None),
"signiert_behandlung": getattr(o, 'signiert_behandlung', 'normal')
} for o in ordner_liste]
# Fallback-Regeln laden
fallback_regeln = db.query(SortierRegel).filter(
SortierRegel.aktiv == True,
SortierRegel.ist_fallback == True
).order_by(SortierRegel.prioritaet).all()
fallback_dicts = [{
"id": r.id, "name": r.name, "prioritaet": r.prioritaet,
"muster": r.muster, "extraktion": r.extraktion,
"schema": r.schema, "unterordner": r.unterordner,
"ziel_ordner": getattr(r, 'ziel_ordner', None),
"nur_umbenennen": getattr(r, 'nur_umbenennen', False)
} for r in fallback_regeln]
# Ordner-Regeln vorladen
ordner_regeln_map = {}
for o in ordner_daten:
zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == o["id"]).all()
zugewiesene_ids = [z.regel_id for z in zuweisungen]
if zugewiesene_ids:
regeln = db.query(SortierRegel).filter(
SortierRegel.id.in_(zugewiesene_ids),
SortierRegel.aktiv == True,
SortierRegel.ist_fallback == False
).order_by(SortierRegel.prioritaet).all()
ordner_regeln_map[o["id"]] = [{
"id": r.id, "name": r.name, "prioritaet": r.prioritaet,
"muster": r.muster, "extraktion": r.extraktion,
"schema": r.schema, "unterordner": r.unterordner,
"ziel_ordner": getattr(r, 'ziel_ordner', None),
"nur_umbenennen": getattr(r, 'nur_umbenennen', False)
} for r in regeln]
else:
ordner_regeln_map[o["id"]] = []
# Dateien vorab zählen für Gesamtübersicht
gesamt_dateien = 0
for o in ordner_daten:
pfad = Path(o["ziel_ordner"])
if pfad.exists():
dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True)
gesamt_dateien += len(dateien)
async def event_generator():
def send_event(data):
return f"data: {json.dumps(data)}\n\n"
if not ordner_daten:
yield send_event({"type": "fehler", "nachricht": "Keine Quell-Ordner konfiguriert"})
return
pdf_processor = PDFProcessor()
ergebnis = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0}
yield send_event({"type": "start", "ordner_count": len(ordner_daten), "gesamt": gesamt_dateien})
await asyncio.sleep(0) # Sofort senden
session = SessionLocal()
try:
for quell_ordner in ordner_daten:
pfad = Path(quell_ordner["ziel_ordner"])
if not pfad.exists():
continue
dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True)
regeln_dicts = ordner_regeln_map.get(quell_ordner["id"], [])
yield send_event({
"type": "ordner",
"ordner": quell_ordner["name"],
"dateien": len(dateien),
"regeln": len(regeln_dicts)
})
await asyncio.sleep(0) # Sofort senden
sorter = Sorter(regeln_dicts) if regeln_dicts else None
fallback_sorter = Sorter(fallback_dicts) if fallback_dicts else None
for datei in dateien:
ergebnis["gesamt"] += 1
datei_info = {"original": datei.name, "ordner": quell_ordner["name"]}
yield send_event({"type": "datei_start", "datei": datei.name})
await asyncio.sleep(0) # Sofort senden
try:
ist_pdf = datei.suffix.lower() == ".pdf"
text = ""
ocr_gemacht = False
if ist_pdf:
pdf_result = pdf_processor.verarbeite(
str(datei),
ocr_erlaubt=quell_ordner.get("ocr_aktivieren", True),
original_backup_pfad=quell_ordner.get("original_sichern")
)
if pdf_result.get("fehler"):
raise Exception(pdf_result["fehler"])
text = pdf_result.get("text", "")
ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False)
doc_info = {
"text": text,
"original_name": datei.name,
"absender": "",
"dateityp": datei.suffix.lower()
}
# Passende Regel finden
regel = None
if sorter:
regel = sorter.finde_passende_regel(doc_info)
if not regel and fallback_sorter:
regel = fallback_sorter.finde_passende_regel(doc_info)
if regel:
datei_info["fallback"] = True
if not regel:
datei_info["fehler"] = "Keine passende Regel"
ergebnis["fehler"] += 1
yield send_event({"type": "datei_fehler", **datei_info})
await asyncio.sleep(0)
continue
# Felder extrahieren und umbenennen
extrahiert = (sorter or fallback_sorter).extrahiere_felder(regel, doc_info)
schema = regel.get("schema", "{datum} - Dokument.pdf")
if schema.endswith(".pdf"):
schema = schema[:-4] + datei.suffix
neuer_name = (sorter or fallback_sorter).generiere_dateinamen(
{"schema": schema, **regel}, extrahiert
)
# Zielordner
if regel.get("nur_umbenennen"):
ziel = datei.parent
elif regel.get("ziel_ordner"):
ziel = Path(regel["ziel_ordner"])
if regel.get("unterordner"):
ziel = ziel / regel["unterordner"]
else:
ziel = pfad
if regel.get("unterordner"):
ziel = ziel / regel["unterordner"]
ziel.mkdir(parents=True, exist_ok=True)
neuer_pfad = (sorter or fallback_sorter).verschiebe_datei(str(datei), str(ziel), neuer_name)
ergebnis["sortiert"] += 1
datei_info["neuer_name"] = neuer_name
datei_info["regel"] = regel.get("name", "Unbekannt")
session.add(VerarbeiteteDatei(
original_pfad=str(datei),
original_name=datei.name,
neuer_pfad=neuer_pfad,
neuer_name=neuer_name,
ocr_durchgefuehrt=ocr_gemacht,
status="sortiert",
extrahierte_daten=extrahiert
))
yield send_event({"type": "datei_fertig", **datei_info})
await asyncio.sleep(0) # Sofort senden
except Exception as e:
ergebnis["fehler"] += 1
datei_info["fehler"] = str(e)
yield send_event({"type": "datei_fehler", **datei_info})
await asyncio.sleep(0)
session.commit()
finally:
session.close()
yield send_event({"type": "fertig", **ergebnis})
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
# ============ PDF Test / Regel-Vorschau ============ # ============ PDF Test / Regel-Vorschau ============
@router.post("/pdf/extrahieren") @router.post("/pdf/extrahieren")
@ -2172,3 +2509,276 @@ def status_uebersicht(db: Session = Depends(get_db)):
"quell_ordner": ordner_status, "quell_ordner": ordner_status,
"scheduler": scheduler_status "scheduler": scheduler_status
} }
# ============ DB-Server API ============
class DbServerCreate(BaseModel):
name: str
typ: str # mariadb, mysql, postgresql
host: str
port: int = 3306
user: str
password: str
class DbServerResponse(BaseModel):
id: int
name: str
typ: str
host: str
port: int
user: str
aktiv: bool
class Config:
from_attributes = True
@router.get("/dbserver")
def liste_dbserver(db: Session = Depends(get_db)):
"""Liste aller DB-Server"""
return db.query(DbServer).all()
@router.get("/dbserver/{id}")
def hole_dbserver(id: int, db: Session = Depends(get_db)):
"""Einzelnen DB-Server abrufen"""
server = db.query(DbServer).filter(DbServer.id == id).first()
if not server:
raise HTTPException(status_code=404, detail="Nicht gefunden")
return {
"id": server.id,
"name": server.name,
"typ": server.typ,
"host": server.host,
"port": server.port,
"user": server.user,
"aktiv": server.aktiv
}
@router.post("/dbserver")
def erstelle_dbserver(data: DbServerCreate, db: Session = Depends(get_db)):
"""Neuen DB-Server erstellen"""
server = DbServer(
name=data.name,
typ=data.typ,
host=data.host,
port=data.port,
user=data.user,
passwort=data.password
)
db.add(server)
db.commit()
db.refresh(server)
return {"id": server.id, "message": "Erstellt"}
@router.put("/dbserver/{id}")
def aktualisiere_dbserver(id: int, data: DbServerCreate, db: Session = Depends(get_db)):
"""DB-Server aktualisieren"""
server = db.query(DbServer).filter(DbServer.id == id).first()
if not server:
raise HTTPException(status_code=404, detail="Nicht gefunden")
server.name = data.name
server.typ = data.typ
server.host = data.host
server.port = data.port
server.user = data.user
if data.password:
server.passwort = data.password
db.commit()
return {"message": "Aktualisiert"}
@router.delete("/dbserver/{id}")
def loesche_dbserver(id: int, db: Session = Depends(get_db)):
"""DB-Server löschen"""
server = db.query(DbServer).filter(DbServer.id == id).first()
if not server:
raise HTTPException(status_code=404, detail="Nicht gefunden")
db.delete(server)
db.commit()
return {"message": "Gelöscht"}
@router.post("/dbserver/{id}/aktivieren")
def aktiviere_dbserver(id: int, db: Session = Depends(get_db)):
"""DB-Server aktivieren/deaktivieren"""
server = db.query(DbServer).filter(DbServer.id == id).first()
if not server:
raise HTTPException(status_code=404, detail="Nicht gefunden")
server.aktiv = not server.aktiv
db.commit()
return {"aktiv": server.aktiv}
class DbServerTest(BaseModel):
typ: str
host: str
port: int = 3306
user: str
password: str
@router.post("/dbserver/test")
def teste_dbserver(data: DbServerTest):
"""Testet die Verbindung zu einem DB-Server"""
try:
if data.typ in ["mariadb", "mysql"]:
import pymysql
conn = pymysql.connect(
host=data.host,
port=data.port,
user=data.user,
password=data.password,
connect_timeout=5
)
conn.close()
elif data.typ == "postgresql":
import psycopg2
conn = psycopg2.connect(
host=data.host,
port=data.port,
user=data.user,
password=data.password,
connect_timeout=5
)
conn.close()
else:
raise HTTPException(status_code=400, detail="Unbekannter DB-Typ")
return {"message": "Verbindung erfolgreich!"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ Datenbanken API ============
class DatenbankCreate(BaseModel):
name: str
server_id: int
database: str
backup_pfad: str
aufbewahrung: int = 7
format: str = "sql" # sql, sql.gz, zip
class DatenbankResponse(BaseModel):
id: int
name: str
server_id: int
database: str
backup_pfad: str
aufbewahrung: int
format: str
aktiv: bool
letztes_backup: Optional[datetime]
server_name: Optional[str] = None
class Config:
from_attributes = True
@router.get("/datenbanken")
def liste_datenbanken(db: Session = Depends(get_db)):
"""Liste aller Datenbanken mit Server-Namen"""
dbs = db.query(Datenbank).all()
result = []
for d in dbs:
server = db.query(DbServer).filter(DbServer.id == d.server_id).first()
result.append({
"id": d.id,
"name": d.name,
"server_id": d.server_id,
"server_name": server.name if server else None,
"database": d.database,
"backup_pfad": d.backup_pfad,
"aufbewahrung": d.aufbewahrung,
"format": d.format,
"aktiv": d.aktiv,
"letztes_backup": d.letztes_backup
})
return result
@router.get("/datenbanken/{id}")
def hole_datenbank(id: int, db: Session = Depends(get_db)):
"""Einzelne Datenbank abrufen"""
datenbank = db.query(Datenbank).filter(Datenbank.id == id).first()
if not datenbank:
raise HTTPException(status_code=404, detail="Nicht gefunden")
return datenbank
@router.post("/datenbanken")
def erstelle_datenbank(data: DatenbankCreate, db: Session = Depends(get_db)):
"""Neue Datenbank-Konfiguration erstellen"""
datenbank = Datenbank(**data.dict())
db.add(datenbank)
db.commit()
db.refresh(datenbank)
return {"id": datenbank.id, "message": "Erstellt"}
@router.put("/datenbanken/{id}")
def aktualisiere_datenbank(id: int, data: DatenbankCreate, db: Session = Depends(get_db)):
"""Datenbank-Konfiguration aktualisieren"""
datenbank = db.query(Datenbank).filter(Datenbank.id == id).first()
if not datenbank:
raise HTTPException(status_code=404, detail="Nicht gefunden")
for key, value in data.dict().items():
setattr(datenbank, key, value)
db.commit()
return {"message": "Aktualisiert"}
@router.delete("/datenbanken/{id}")
def loesche_datenbank(id: int, db: Session = Depends(get_db)):
"""Datenbank-Konfiguration löschen"""
datenbank = db.query(Datenbank).filter(Datenbank.id == id).first()
if not datenbank:
raise HTTPException(status_code=404, detail="Nicht gefunden")
db.delete(datenbank)
db.commit()
return {"message": "Gelöscht"}
@router.post("/datenbanken/{id}/aktivieren")
def aktiviere_datenbank(id: int, db: Session = Depends(get_db)):
"""Datenbank aktivieren/deaktivieren"""
datenbank = db.query(Datenbank).filter(Datenbank.id == id).first()
if not datenbank:
raise HTTPException(status_code=404, detail="Nicht gefunden")
datenbank.aktiv = not datenbank.aktiv
db.commit()
return {"aktiv": datenbank.aktiv}
@router.post("/datenbanken/{id}/backup")
def erstelle_backup(id: int, db: Session = Depends(get_db)):
"""Erstellt ein Backup der angegebenen Datenbank"""
from ..services.backup_service import erstelle_db_backup
return erstelle_db_backup(id, db)
@router.get("/backups")
def liste_backups(db: Session = Depends(get_db)):
"""Liste der letzten Backups"""
logs = db.query(BackupLog).order_by(BackupLog.erstellt_am.desc()).limit(50).all()
result = []
for log in logs:
datenbank = db.query(Datenbank).filter(Datenbank.id == log.datenbank_id).first()
result.append({
"id": log.id,
"datenbank_name": datenbank.name if datenbank else "Unbekannt",
"dateiname": log.dateiname,
"groesse_mb": round(log.groesse_bytes / 1024 / 1024, 2) if log.groesse_bytes else 0,
"status": log.status,
"erstellt": log.erstellt_am.strftime("%d.%m.%Y %H:%M") if log.erstellt_am else None
})
return result

View file

@ -0,0 +1,262 @@
"""
Datenbank-Backup Service
Unterstützt MariaDB, MySQL und PostgreSQL
"""
import subprocess
import os
import gzip
import zipfile
from pathlib import Path
from datetime import datetime
from sqlalchemy.orm import Session
import logging
from ..models.database import DbServer, Datenbank, BackupLog
logger = logging.getLogger(__name__)
def erstelle_db_backup(datenbank_id: int, db: Session) -> dict:
"""
Erstellt ein Backup einer Datenbank.
Args:
datenbank_id: ID der Datenbank-Konfiguration
db: SQLAlchemy Session
Returns:
dict mit Backup-Informationen
"""
# Datenbank-Konfiguration laden
datenbank = db.query(Datenbank).filter(Datenbank.id == datenbank_id).first()
if not datenbank:
raise Exception("Datenbank nicht gefunden")
# Server-Konfiguration laden
server = db.query(DbServer).filter(DbServer.id == datenbank.server_id).first()
if not server:
raise Exception("DB-Server nicht gefunden")
# Backup-Verzeichnis erstellen
backup_dir = Path(datenbank.backup_pfad)
backup_dir.mkdir(parents=True, exist_ok=True)
# Dateiname generieren
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = f"{datenbank.database}_{timestamp}"
try:
# Backup erstellen basierend auf DB-Typ
if server.typ in ["mariadb", "mysql"]:
sql_file = backup_mysql(server, datenbank.database, backup_dir, base_name)
elif server.typ == "postgresql":
sql_file = backup_postgresql(server, datenbank.database, backup_dir, base_name)
else:
raise Exception(f"Unbekannter Datenbanktyp: {server.typ}")
# Format anwenden (Komprimierung)
final_file = sql_file
if datenbank.format == "sql.gz":
final_file = komprimiere_gzip(sql_file)
os.remove(sql_file) # Original löschen
elif datenbank.format == "zip":
final_file = komprimiere_zip(sql_file)
os.remove(sql_file) # Original löschen
# Dateigröße ermitteln
file_size = os.path.getsize(final_file)
# Backup in DB loggen
log_entry = BackupLog(
datenbank_id=datenbank.id,
dateiname=os.path.basename(final_file),
groesse_bytes=file_size,
status="erfolg"
)
db.add(log_entry)
# Datenbank-Status aktualisieren
datenbank.letztes_backup = datetime.now()
datenbank.letzte_groesse_mb = round(file_size / 1024 / 1024)
db.commit()
# Alte Backups aufräumen
bereinige_alte_backups(datenbank, db)
logger.info(f"Backup erstellt: {final_file} ({file_size} bytes)")
print(f"[BACKUP] ✓ Backup erstellt: {final_file}", flush=True)
return {
"erfolg": True,
"datei": os.path.basename(final_file),
"groesse_mb": round(file_size / 1024 / 1024, 2),
"pfad": str(final_file)
}
except Exception as e:
# Fehler loggen
log_entry = BackupLog(
datenbank_id=datenbank.id,
dateiname=f"{base_name}_FEHLER",
status="fehler",
fehler=str(e)
)
db.add(log_entry)
db.commit()
logger.error(f"Backup-Fehler: {e}")
print(f"[BACKUP] ✗ Fehler: {e}", flush=True)
raise Exception(f"Backup fehlgeschlagen: {e}")
def backup_mysql(server: DbServer, database: str, backup_dir: Path, base_name: str) -> str:
"""
Erstellt ein MySQL/MariaDB Backup mit mysqldump.
"""
output_file = backup_dir / f"{base_name}.sql"
cmd = [
"mysqldump",
f"--host={server.host}",
f"--port={server.port}",
f"--user={server.user}",
f"--password={server.passwort}",
"--single-transaction",
"--routines",
"--triggers",
"--events",
database
]
print(f"[BACKUP] Starte MySQL-Dump für {database}...", flush=True)
with open(output_file, 'w') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, timeout=3600)
if result.returncode != 0:
error_msg = result.stderr.decode('utf-8', errors='ignore')
raise Exception(f"mysqldump Fehler: {error_msg}")
return str(output_file)
def backup_postgresql(server: DbServer, database: str, backup_dir: Path, base_name: str) -> str:
"""
Erstellt ein PostgreSQL Backup mit pg_dump.
"""
output_file = backup_dir / f"{base_name}.sql"
# Passwort über Umgebungsvariable
env = os.environ.copy()
env["PGPASSWORD"] = server.passwort
cmd = [
"pg_dump",
f"--host={server.host}",
f"--port={server.port}",
f"--username={server.user}",
"--format=plain",
"--no-owner",
"--no-acl",
database
]
print(f"[BACKUP] Starte PostgreSQL-Dump für {database}...", flush=True)
with open(output_file, 'w') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, env=env, timeout=3600)
if result.returncode != 0:
error_msg = result.stderr.decode('utf-8', errors='ignore')
raise Exception(f"pg_dump Fehler: {error_msg}")
return str(output_file)
def komprimiere_gzip(input_file: str) -> str:
"""
Komprimiert eine Datei mit gzip.
"""
output_file = f"{input_file}.gz"
with open(input_file, 'rb') as f_in:
with gzip.open(output_file, 'wb') as f_out:
f_out.writelines(f_in)
return output_file
def komprimiere_zip(input_file: str) -> str:
"""
Komprimiert eine Datei als ZIP.
"""
output_file = input_file.replace('.sql', '.zip')
with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(input_file, os.path.basename(input_file))
return output_file
def bereinige_alte_backups(datenbank: Datenbank, db: Session):
"""
Löscht alte Backups basierend auf der Aufbewahrungsregel.
"""
backup_dir = Path(datenbank.backup_pfad)
if not backup_dir.exists():
return
# Alle Backup-Dateien für diese Datenbank finden
pattern = f"{datenbank.database}_*"
backups = sorted(backup_dir.glob(pattern), key=lambda x: x.stat().st_mtime, reverse=True)
# Nur die neuesten behalten
zu_loeschen = backups[datenbank.aufbewahrung:]
for backup_file in zu_loeschen:
try:
backup_file.unlink()
print(f"[BACKUP] Altes Backup gelöscht: {backup_file.name}", flush=True)
# Auch aus der DB löschen
db.query(BackupLog).filter(BackupLog.dateiname == backup_file.name).delete()
except Exception as e:
logger.warning(f"Konnte altes Backup nicht löschen: {e}")
db.commit()
def fuehre_alle_backups_aus(db: Session) -> dict:
"""
Führt Backups für alle aktiven Datenbanken aus.
Returns:
dict mit Zusammenfassung
"""
datenbanken = db.query(Datenbank).filter(Datenbank.aktiv == True).all()
ergebnis = {
"gesamt": len(datenbanken),
"erfolgreich": 0,
"fehlgeschlagen": 0,
"details": []
}
for datenbank in datenbanken:
try:
result = erstelle_db_backup(datenbank.id, db)
ergebnis["erfolgreich"] += 1
ergebnis["details"].append({
"name": datenbank.name,
"status": "erfolg",
"datei": result.get("datei")
})
except Exception as e:
ergebnis["fehlgeschlagen"] += 1
ergebnis["details"].append({
"name": datenbank.name,
"status": "fehler",
"fehler": str(e)
})
return ergebnis

View file

@ -361,6 +361,8 @@ def execute_zeitplan(zeitplan_id: int):
elif zeitplan.typ == "sortierung": elif zeitplan.typ == "sortierung":
# Legacy: alte "sortierung" wird wie "grobsortierung" behandelt # Legacy: alte "sortierung" wird wie "grobsortierung" behandelt
result = execute_grobsortierung(db, zeitplan) result = execute_grobsortierung(db, zeitplan)
elif zeitplan.typ == "db_backup":
result = execute_db_backup(db, zeitplan)
else: else:
result = {"erfolg": False, "meldung": f"Unbekannter Typ: {zeitplan.typ}"} result = {"erfolg": False, "meldung": f"Unbekannter Typ: {zeitplan.typ}"}
@ -511,10 +513,16 @@ def execute_grobsortierung(db, zeitplan: Zeitplan) -> Dict:
regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all()
print(f"[GROBSORTIERUNG] Gefunden: {len(regeln)} aktive Regeln", flush=True) print(f"[GROBSORTIERUNG] Gefunden: {len(regeln)} aktive Regeln", flush=True)
if not regeln: # Prüfen ob mindestens ein Ordner direkt_verschieben aktiviert hat
print("[GROBSORTIERUNG] ⚠️ Keine aktiven Regeln - Abbruch", flush=True) hat_direkt_verschieben = any(getattr(qo, 'direkt_verschieben', False) for qo in quell_ordner)
if not regeln and not hat_direkt_verschieben:
print("[GROBSORTIERUNG] ⚠️ Keine aktiven Regeln und kein direkt_verschieben - Abbruch", flush=True)
return {"erfolg": False, "meldung": "Keine aktiven Regeln definiert"} return {"erfolg": False, "meldung": "Keine aktiven Regeln definiert"}
if not regeln:
print("[GROBSORTIERUNG] Keine Regeln, aber direkt_verschieben aktiv - fahre fort", flush=True)
# Regeln in Dict-Format # Regeln in Dict-Format
regeln_dicts = [{ regeln_dicts = [{
"id": r.id, "id": r.id,
@ -860,6 +868,48 @@ def execute_sortierregeln(db, zeitplan: Zeitplan) -> Dict:
return {"erfolg": gesamt_fehler == 0, "meldung": meldung} return {"erfolg": gesamt_fehler == 0, "meldung": meldung}
def execute_db_backup(db, zeitplan: Zeitplan) -> Dict:
"""Führt Datenbank-Backup aus"""
from ..models.database import Datenbank
from .backup_service import erstelle_db_backup
print(f"[SCHEDULER] Starte DB-Backup: {zeitplan.name}", flush=True)
# Datenbanken laden (optional spezifische Datenbank)
if zeitplan.datenbank_id:
datenbanken = db.query(Datenbank).filter(
Datenbank.id == zeitplan.datenbank_id,
Datenbank.aktiv == True
).all()
else:
datenbanken = db.query(Datenbank).filter(Datenbank.aktiv == True).all()
if not datenbanken:
return {"erfolg": True, "meldung": "Keine aktiven Datenbanken gefunden"}
gesamt_erfolg = 0
gesamt_fehler = 0
fehler_meldungen = []
for datenbank in datenbanken:
try:
print(f"[SCHEDULER] Backup für: {datenbank.name}", flush=True)
result = erstelle_db_backup(datenbank.id, db)
gesamt_erfolg += 1
except Exception as e:
gesamt_fehler += 1
fehler_meldungen.append(f"{datenbank.name}: {str(e)}")
logger.error(f"Backup-Fehler für {datenbank.name}: {e}")
meldung = f"{gesamt_erfolg} Backups erstellt"
if gesamt_fehler > 0:
meldung += f", {gesamt_fehler} Fehler"
if fehler_meldungen:
meldung += f" ({'; '.join(fehler_meldungen[:3])})"
return {"erfolg": gesamt_fehler == 0, "meldung": meldung}
def get_scheduler_status() -> Dict: def get_scheduler_status() -> Dict:
"""Gibt den Status aller Zeitpläne zurück""" """Gibt den Status aller Zeitpläne zurück"""
global scheduler global scheduler

View file

@ -8,6 +8,7 @@ jinja2==3.1.3
sqlalchemy==2.0.25 sqlalchemy==2.0.25
aiosqlite==0.19.0 aiosqlite==0.19.0
pymysql==1.1.0 pymysql==1.1.0
psycopg2-binary==2.9.9
# PDF Processing # PDF Processing
pypdf==4.0.1 pypdf==4.0.1

View file

@ -14,7 +14,7 @@ services:
- /mnt:/mnt - /mnt:/mnt
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- DATABASE_URL=mysql+pymysql://data:8715@192.168.155.83/dateiverwaltung - DATABASE_URL=mysql+pymysql://data:8715@192.168.155.11/dateiverwaltung
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s interval: 30s

View file

@ -50,6 +50,163 @@ body {
font-weight: 600; font-weight: 600;
} }
/* ============ Tab Navigation ============ */
.tab-navigation {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0 1rem;
}
.tab-btn {
padding: 1rem 1.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border-bottom: 3px solid transparent;
margin-bottom: -1px;
}
.tab-btn:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
background: var(--bg);
}
/* ============ Tab Content ============ */
.tab-content {
display: none;
flex: 1;
overflow: hidden;
}
.tab-content.active {
display: block;
}
.tab-layout {
display: grid;
grid-template-columns: 35% 35% 30%;
gap: 1px;
height: calc(100vh - 120px);
background: var(--border);
}
.tab-left,
.tab-center,
.tab-right {
background: var(--bg);
padding: 1rem;
overflow-y: auto;
}
.tab-left {
border-right: 1px solid var(--border);
}
.tab-right {
border-left: 1px solid var(--border);
}
@media (max-width: 1400px) {
.tab-layout {
grid-template-columns: 42% 42% 16%;
}
}
@media (max-width: 1100px) {
.tab-layout {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
}
.tab-left {
grid-column: 1;
grid-row: 1;
}
.tab-center {
grid-column: 2;
grid-row: 1;
}
.tab-right {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
gap: 1rem;
}
.tab-right .card {
flex: 1;
}
}
@media (max-width: 768px) {
.tab-layout {
grid-template-columns: 1fr;
height: auto;
}
.tab-left,
.tab-center,
.tab-right {
border: none;
}
.tab-right {
flex-direction: column;
}
}
/* ============ Debug Log Header ============ */
.header-center {
flex: 1;
display: flex;
justify-content: center;
padding: 0 2rem;
}
.debug-log-header {
background: var(--bg-tertiary);
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8rem;
max-width: 600px;
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
}
.debug-log-label {
color: var(--text-secondary);
white-space: nowrap;
}
.debug-log-text {
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.debug-log-text.error {
color: var(--danger);
}
.debug-log-text.success {
color: var(--success);
}
.debug-log-text.warning {
color: var(--warning);
}
/* ============ Old Main Container (deprecated) ============ */
.main-container { .main-container {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);

View file

@ -341,6 +341,12 @@ document.addEventListener('DOMContentLoaded', () => {
ladeZeitplaene(); ladeZeitplaene();
ladeStatus(); ladeStatus();
// Gespeicherten Tab wiederherstellen
const gespeicherterTab = localStorage.getItem('aktiver-tab');
if (gespeicherterTab) {
wechsleTab(gespeicherterTab);
}
// Event-Listener für Dateityp-Checkboxen im Postfach-Modal // Event-Listener für Dateityp-Checkboxen im Postfach-Modal
const pfTypenGruppe = document.getElementById('pf-typen-gruppe'); const pfTypenGruppe = document.getElementById('pf-typen-gruppe');
if (pfTypenGruppe) { if (pfTypenGruppe) {
@ -985,26 +991,37 @@ async function ordnerVorschau(id) {
async function ordnerVerarbeiten(id) { async function ordnerVerarbeiten(id) {
if (!await showConfirm('Dateien jetzt verarbeiten und sortieren?')) return; if (!await showConfirm('Dateien jetzt verarbeiten und sortieren?')) return;
const logContainer = document.getElementById('grobsortierung-log');
logContainer.innerHTML = '<div class="log-entry info">Verarbeite...</div>';
try { try {
zeigeLoading('Verarbeite Dateien...'); debugLog('Verarbeite Ordner...', 'info');
const result = await api(`/ordner/${id}/verarbeiten`, { method: 'POST' }); const result = await api(`/ordner/${id}/verarbeiten`, { method: 'POST' });
let msg = `Verarbeitung abgeschlossen:\n\n`; // Ergebnis in der Mitte anzeigen
msg += `• Gesamt: ${result.gesamt}\n`; let html = `<div class="log-entry success">
msg += `• Sortiert: ${result.sortiert}\n`; <strong>Verarbeitung abgeschlossen</strong><br>
msg += `• ZUGFeRD: ${result.zugferd}\n`; Gesamt: ${result.gesamt} | Sortiert: ${result.sortiert} | ZUGFeRD: ${result.zugferd} | Keine Regel: ${result.keine_regel || 0} | Fehler: ${result.fehler}
msg += `• Keine Regel: ${result.keine_regel || 0}\n`; </div>`;
msg += `• Fehler: ${result.fehler}`;
if (result.fehler && result.fehler > 0) { // Details der verarbeiteten Dateien anzeigen
showAlert(msg, 'warning', 'Verarbeitung mit Warnungen'); if (result.verarbeitet && result.verarbeitet.length > 0) {
} else { result.verarbeitet.forEach(d => {
showAlert(msg, 'success', 'Verarbeitung abgeschlossen'); const klasse = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? 'success' :
(d.status === 'fehler' ? 'error' : 'info');
const icon = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? '✓' :
(d.status === 'fehler' ? '✗' : '');
html += `<div class="log-entry ${klasse}">
<span>${icon} ${escapeHtml(d.original)} ${escapeHtml(d.neuer_name || d.status)}</span>
</div>`;
});
} }
logContainer.innerHTML = html;
debugLog('Verarbeitung abgeschlossen', 'success');
} catch (error) { } catch (error) {
showAlert(error.message, 'error'); logContainer.innerHTML = `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
} finally { debugLog('Fehler: ' + error.message, 'error');
versteckeLoading();
} }
} }
@ -1012,9 +1029,43 @@ async function ordnerVerarbeiten(id) {
let editierteRegelId = null; let editierteRegelId = null;
// Natürliche Sortierung (Zahlen korrekt: 1, 2, 10, 100 statt 1, 10, 100, 2)
function natuerlicheSortierung(a, b) {
return a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' });
}
async function ladeRegeln() { async function ladeRegeln() {
try { try {
const regeln = await api('/regeln'); const regeln = await api('/regeln');
// Sortierung anwenden (und in localStorage speichern)
const sortSelect = document.getElementById('regeln-sortierung');
if (sortSelect) {
// Gespeicherte Sortierung wiederherstellen
const gespeichert = localStorage.getItem('regeln-sortierung');
if (gespeichert && sortSelect.value !== gespeichert) {
sortSelect.value = gespeichert;
}
// Aktuelle Sortierung speichern
localStorage.setItem('regeln-sortierung', sortSelect.value);
}
const sortierung = sortSelect?.value || 'name_asc';
regeln.sort((a, b) => {
switch (sortierung) {
case 'name_asc':
return natuerlicheSortierung(a.name || '', b.name || '');
case 'name_desc':
return natuerlicheSortierung(b.name || '', a.name || '');
case 'prio_asc':
return (a.prioritaet || 0) - (b.prioritaet || 0);
case 'prio_desc':
return (b.prioritaet || 0) - (a.prioritaet || 0);
default:
return 0;
}
});
renderRegeln(regeln); renderRegeln(regeln);
} catch (error) { } catch (error) {
console.error('Fehler:', error); console.error('Fehler:', error);
@ -1059,6 +1110,114 @@ async function kopiereRegel(id) {
} }
} }
// ============ Regeln Import/Export ============
async function exportiereRegeln() {
try {
const result = await api('/regeln/export');
const json = JSON.stringify(result.regeln, null, 2);
// Download als Datei
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sortierregeln_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showAlert(`${result.anzahl} Regeln exportiert`, 'success');
} catch (error) {
showAlert('Fehler beim Export: ' + error.message, 'error');
}
}
async function importiereRegeln() {
// Datei-Input erstellen
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const regeln = JSON.parse(text);
// Prüfen ob es ein Array oder ein Objekt mit "regeln" ist
const regelListe = Array.isArray(regeln) ? regeln : regeln.regeln;
if (!regelListe || !Array.isArray(regelListe)) {
showAlert('Ungültiges Format: Array von Regeln erwartet', 'error');
return;
}
// Import-Modus fragen
const modus = await new Promise(resolve => {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h3>Import: ${regelListe.length} Regeln</h3>
</div>
<div class="modal-body">
<p>Wie sollen die Regeln importiert werden?</p>
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem;">
<button class="btn btn-primary" onclick="this.closest('.modal').resolve('hinzufuegen')">
Nur neue hinzufügen
</button>
<button class="btn" onclick="this.closest('.modal').resolve('aktualisieren')">
Bestehende aktualisieren
</button>
<button class="btn btn-danger" onclick="this.closest('.modal').resolve('ersetzen')">
Alle ersetzen
</button>
<button class="btn" onclick="this.closest('.modal').resolve(null)">
Abbrechen
</button>
</div>
</div>
</div>
`;
modal.resolve = (value) => {
modal.remove();
resolve(value);
};
document.body.appendChild(modal);
});
if (!modus) return;
if (modus === 'ersetzen') {
if (!await showConfirm('Alle bestehenden Regeln werden gelöscht. Fortfahren?')) {
return;
}
}
const result = await api('/regeln/import', {
method: 'POST',
body: JSON.stringify({ regeln: regelListe, modus })
});
showAlert(
`Import: ${result.importiert} neu, ${result.aktualisiert} aktualisiert, ${result.uebersprungen} übersprungen`,
'success'
);
ladeRegeln();
} catch (error) {
showAlert('Fehler beim Import: ' + error.message, 'error');
}
};
input.click();
}
// ============ Regel-Modal (NEU) ============ // ============ Regel-Modal (NEU) ============
let alleOrdner = []; // Cache für Ordner-Liste let alleOrdner = []; // Cache für Ordner-Liste
@ -1090,6 +1249,7 @@ async function zeigeRegelModal(regel = null) {
const muster = regel?.muster || {}; const muster = regel?.muster || {};
document.getElementById('regel-keywords').value = muster.keywords || ''; document.getElementById('regel-keywords').value = muster.keywords || '';
document.getElementById('regel-keywords-nicht').value = muster.keywords_nicht || ''; document.getElementById('regel-keywords-nicht').value = muster.keywords_nicht || '';
document.getElementById('regel-auch-dateiname').checked = muster.auch_dateiname || false;
document.getElementById('regel-text-regex').value = muster.text_regex || ''; document.getElementById('regel-text-regex').value = muster.text_regex || '';
// Extraktion-Tabelle befüllen // Extraktion-Tabelle befüllen
@ -1436,10 +1596,12 @@ async function speichereRegel() {
const muster = {}; const muster = {};
const keywords = document.getElementById('regel-keywords').value.trim(); const keywords = document.getElementById('regel-keywords').value.trim();
const keywordsNicht = document.getElementById('regel-keywords-nicht').value.trim(); const keywordsNicht = document.getElementById('regel-keywords-nicht').value.trim();
const auchDateiname = document.getElementById('regel-auch-dateiname').checked;
const textRegex = document.getElementById('regel-text-regex').value.trim(); const textRegex = document.getElementById('regel-text-regex').value.trim();
if (keywords) muster.keywords = keywords; if (keywords) muster.keywords = keywords;
if (keywordsNicht) muster.keywords_nicht = keywordsNicht; if (keywordsNicht) muster.keywords_nicht = keywordsNicht;
if (auchDateiname) muster.auch_dateiname = true;
if (textRegex) muster.text_regex = textRegex; if (textRegex) muster.text_regex = textRegex;
// Extraktion aus Tabelle sammeln // Extraktion aus Tabelle sammeln
@ -2533,14 +2695,641 @@ function truncatePath(path, maxLength = 35) {
return `<span title="${escaped}" style="cursor:help;">${escapeHtml(start)}...${escapeHtml(end)}</span>`; return `<span title="${escaped}" style="cursor:help;">${escapeHtml(start)}...${escapeHtml(end)}</span>`;
} }
document.addEventListener('click', (e) => { // Modal nur über X-Button oder Abbrechen schließen, nicht durch Klick auf Hintergrund
if (e.target.classList.contains('modal')) { // (entfernt: Klick auf Modal-Hintergrund schließt nicht mehr)
e.target.classList.add('hidden');
}
});
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden')); document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden'));
} }
}); });
// ============ Tab Navigation ============
function wechsleTab(tabName) {
// Alle Tabs deaktivieren
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// Gewählten Tab aktivieren
document.querySelector(`.tab-btn[data-tab="${tabName}"]`).classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
// Tab in localStorage speichern
localStorage.setItem('aktiver-tab', tabName);
// Tab-spezifische Daten laden
switch(tabName) {
case 'mailabruf':
ladePostfaecher();
ladeZeitplaeneNachTyp('mail_abruf', 'zeitplaene-mailabruf');
break;
case 'grobsortierung':
ladeOrdner();
ladeZeitplaeneNachTyp('grobsortierung', 'zeitplaene-grobsortierung');
break;
case 'feinsortierung':
ladeRegeln();
ladeZeitplaeneNachTyp('sortierregeln', 'zeitplaene-feinsortierung');
break;
case 'dbbackup':
ladeDbServer();
ladeDatenbanken();
ladeBackups();
ladeZeitplaeneNachTyp('db_backup', 'zeitplaene-dbbackup');
break;
}
ladeStatus();
debugLog(`Tab gewechselt: ${tabName}`);
}
// ============ Debug Log Header ============
let lastDebugMessage = '';
function debugLog(message, type = 'info') {
lastDebugMessage = message;
const textEl = document.getElementById('debug-log-text');
if (textEl) {
textEl.textContent = message;
textEl.className = 'debug-log-text ' + type;
}
console.log(`[DEBUG] ${message}`);
}
// ============ Zeitpläne nach Typ laden ============
async function ladeZeitplaeneNachTyp(typ, containerId) {
try {
const zeitplaene = await api('/zeitplaene');
const gefiltert = zeitplaene.filter(z => z.typ === typ);
const container = document.getElementById(containerId);
if (gefiltert.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Zeitpläne</p>';
return;
}
container.innerHTML = gefiltert.map(z => `
<div class="config-item">
<div class="config-item-info">
<h4>${escapeHtml(z.name)} ${z.aktiv ? '✓' : ''}</h4>
<small>${z.intervall} ${z.stunde != null ? `um ${z.stunde}:${String(z.minute || 0).padStart(2, '0')}` : ''}</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm" onclick="zeitplanAusfuehren(${z.id})" title="Jetzt ausführen"></button>
<button class="btn btn-sm" onclick="zeitplanAktivieren(${z.id})">${z.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm btn-danger" onclick="zeitplanLoeschen(${z.id})">🗑</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Fehler beim Laden der Zeitpläne:', error);
}
}
// ============ Grobsortierung / Alle Ordner verarbeiten ============
async function alleOrdnerVerarbeiten() {
const logContainer = document.getElementById('grobsortierung-log');
logContainer.innerHTML = '<div class="log-entry info">Starte Grobsortierung...</div>';
try {
debugLog('Starte Grobsortierung aller Ordner...', 'info');
const ordner = await api('/ordner');
const aktiveOrdner = ordner.filter(o => o.aktiv);
if (aktiveOrdner.length === 0) {
logContainer.innerHTML = '<div class="log-entry warning">Keine aktiven Ordner konfiguriert</div>';
return;
}
logContainer.innerHTML = '';
let gesamtSortiert = 0;
let gesamtFehler = 0;
for (const o of aktiveOrdner) {
debugLog(`Verarbeite: ${o.name}`, 'info');
try {
const result = await api(`/ordner/${o.id}/verarbeiten`, { method: 'POST' });
gesamtSortiert += result.sortiert || 0;
// Detaillierte Ausgabe pro Ordner
let ordnerHtml = `<div class="log-entry success">
<strong> ${escapeHtml(o.name)}</strong>: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD
</div>`;
// Dateien im Ordner anzeigen
if (result.verarbeitet && result.verarbeitet.length > 0) {
result.verarbeitet.forEach(d => {
const klasse = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? 'success' :
(d.status === 'fehler' ? 'error' : 'info');
ordnerHtml += `<div class="log-entry ${klasse}" style="padding-left: 2rem;">
<span> ${escapeHtml(d.original)} ${escapeHtml(d.neuer_name || d.status)}</span>
</div>`;
});
}
logContainer.innerHTML += ordnerHtml;
} catch (error) {
gesamtFehler++;
logContainer.innerHTML += `<div class="log-entry error">
<span> ${escapeHtml(o.name)}: ${error.message}</span>
</div>`;
}
}
// Zusammenfassung am Ende
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong>Zusammenfassung:</strong> ${gesamtSortiert} Dateien sortiert, ${gesamtFehler} Fehler
</div>`;
debugLog('Grobsortierung abgeschlossen', 'success');
} catch (error) {
logContainer.innerHTML = `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
debugLog('Fehler: ' + error.message, 'error');
}
}
// ============ Feinsortierung starten ============
async function feinsortierungStarten() {
const logContainer = document.getElementById('sortierung-log');
logContainer.innerHTML = '<div class="log-entry info">Starte Feinsortierung...</div>';
try {
debugLog('Starte Feinsortierung mit Live-Updates...', 'info');
// Streaming-Endpoint verwenden für Live-Updates
const response = await fetch('/api/sortierung/starten/stream');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let zusammenfassungDiv = null;
let gesamt = 0, sortiert = 0, zugferd = 0, fehler = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Unvollständige Zeile behalten
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case 'start':
logContainer.innerHTML = `<div class="log-entry info">
<strong>Starte Feinsortierung...</strong> ${data.ordner_count} Ordner, ${data.gesamt} Dateien
</div>`;
// Zusammenfassungs-Div am Anfang erstellen
zusammenfassungDiv = document.createElement('div');
zusammenfassungDiv.className = 'log-entry success';
zusammenfassungDiv.style.cssText = 'position: sticky; top: 0; background: var(--bg-secondary); border-bottom: 1px solid var(--border); z-index: 1;';
zusammenfassungDiv.innerHTML = `<strong>Gesamt: 0 | Sortiert: 0 | ZUGFeRD: 0 | Fehler: 0</strong>`;
logContainer.appendChild(zusammenfassungDiv);
break;
case 'ordner':
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong>📁 ${escapeHtml(data.ordner)}</strong> (${data.dateien} Dateien)
</div>`;
break;
case 'datei_start':
// Optional: Aktuelle Datei anzeigen
break;
case 'datei_fertig':
sortiert++;
if (data.zugferd) zugferd++;
gesamt++;
const hatRegel = data.regel;
const icon = hatRegel ? '✓' : '';
let text = escapeHtml(data.original || '');
if (data.neuer_name) {
text += `${escapeHtml(data.neuer_name)}`;
}
if (data.regel) {
text += ` <small style="opacity:0.7">[${escapeHtml(data.regel)}]</small>`;
}
if (data.zugferd) {
text += ` <small style="color:var(--success)">(ZUGFeRD)</small>`;
}
logContainer.innerHTML += `<div class="log-entry success">
<span>${icon} ${text}</span>
</div>`;
// Zusammenfassung aktualisieren
if (zusammenfassungDiv) {
zusammenfassungDiv.innerHTML = `<strong>Gesamt: ${gesamt} | Sortiert: ${sortiert} | ZUGFeRD: ${zugferd} | Fehler: ${fehler}</strong>`;
}
break;
case 'datei_fehler':
fehler++;
gesamt++;
logContainer.innerHTML += `<div class="log-entry error">
<span> ${escapeHtml(data.original || '')} <small style="color:var(--danger)">(${escapeHtml(data.fehler || 'Unbekannter Fehler')})</small></span>
</div>`;
// Zusammenfassung aktualisieren
if (zusammenfassungDiv) {
zusammenfassungDiv.innerHTML = `<strong>Gesamt: ${gesamt} | Sortiert: ${sortiert} | ZUGFeRD: ${zugferd} | Fehler: ${fehler}</strong>`;
}
break;
case 'fertig':
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong> Feinsortierung abgeschlossen</strong>
</div>`;
break;
}
// Auto-Scroll zum Ende
logContainer.scrollTop = logContainer.scrollHeight;
} catch (parseError) {
console.error('SSE Parse-Fehler:', parseError, line);
}
}
}
}
if (gesamt === 0) {
logContainer.innerHTML = `<div class="log-entry info">Keine Dateien zur Verarbeitung gefunden</div>`;
}
debugLog('Feinsortierung abgeschlossen', 'success');
} catch (error) {
logContainer.innerHTML = `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
debugLog('Fehler: ' + error.message, 'error');
}
}
// ============ Datenbank-Backup Funktionen ============
let editierterDbServerId = null;
let editierteDbId = null;
async function ladeDbServer() {
try {
const server = await api('/dbserver');
const container = document.getElementById('dbserver-liste');
if (server.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Server konfiguriert</p>';
return;
}
container.innerHTML = server.map(s => `
<div class="config-item">
<div class="config-item-info">
<h4>${escapeHtml(s.name)} ${s.aktiv ? '✓' : ''}</h4>
<small>${s.typ} @ ${s.host}:${s.port}</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm" onclick="dbServerBearbeiten(${s.id})"></button>
<button class="btn btn-sm" onclick="dbServerAktivieren(${s.id})">${s.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm btn-danger" onclick="dbServerLoeschen(${s.id})">🗑</button>
</div>
</div>
`).join('');
} catch (error) {
debugLog('Fehler beim Laden der DB-Server: ' + error.message, 'error');
}
}
async function ladeDatenbanken() {
try {
const dbs = await api('/datenbanken');
const container = document.getElementById('datenbanken-liste');
if (dbs.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Datenbanken konfiguriert</p>';
return;
}
container.innerHTML = dbs.map(db => `
<div class="config-item">
<div class="config-item-info">
<h4>${escapeHtml(db.name)} ${db.aktiv ? '✓' : ''}</h4>
<small>${escapeHtml(db.database)} (${db.server_name || 'Server'})</small>
</div>
<div class="config-item-actions">
<button class="btn btn-sm btn-success" onclick="dbBackupErstellen(${db.id})" title="Backup jetzt erstellen">💾</button>
<button class="btn btn-sm" onclick="dbBearbeiten(${db.id})"></button>
<button class="btn btn-sm" onclick="dbAktivieren(${db.id})">${db.aktiv ? '⏸' : '▶'}</button>
<button class="btn btn-sm btn-danger" onclick="dbLoeschen(${db.id})">🗑</button>
</div>
</div>
`).join('');
} catch (error) {
debugLog('Fehler beim Laden der Datenbanken: ' + error.message, 'error');
}
}
async function ladeBackups() {
try {
const backups = await api('/backups');
const container = document.getElementById('backups-liste');
if (!backups || backups.length === 0) {
container.innerHTML = '<p class="empty-state">Keine Backups vorhanden</p>';
return;
}
container.innerHTML = backups.slice(0, 20).map(b => `
<div class="log-entry info">
<span>${escapeHtml(b.dateiname)}</span>
<small>${b.groesse_mb} MB - ${b.erstellt}</small>
</div>
`).join('');
} catch (error) {
// Backups-Endpoint existiert evtl. noch nicht
console.log('Backups-Endpoint nicht verfügbar');
}
}
function zeigeDbServerModal(server = null) {
editierterDbServerId = server?.id || null;
document.getElementById('dbserver-modal-title').textContent = server ? 'DB-Server bearbeiten' : 'DB-Server hinzufügen';
document.getElementById('dbs-name').value = server?.name || '';
document.getElementById('dbs-typ').value = server?.typ || 'mariadb';
document.getElementById('dbs-host').value = server?.host || '';
document.getElementById('dbs-port').value = server?.port || 3306;
document.getElementById('dbs-user').value = server?.user || '';
document.getElementById('dbs-password').value = '';
document.getElementById('dbserver-modal').classList.remove('hidden');
}
async function speichereDbServer() {
const data = {
name: document.getElementById('dbs-name').value.trim(),
typ: document.getElementById('dbs-typ').value,
host: document.getElementById('dbs-host').value.trim(),
port: parseInt(document.getElementById('dbs-port').value) || 3306,
user: document.getElementById('dbs-user').value.trim(),
password: document.getElementById('dbs-password').value
};
if (!data.name || !data.host || !data.user) {
showAlert('Bitte alle Pflichtfelder ausfüllen', 'warning');
return;
}
try {
if (editierterDbServerId) {
await api(`/dbserver/${editierterDbServerId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/dbserver', { method: 'POST', body: JSON.stringify(data) });
}
schliesseModal('dbserver-modal');
ladeDbServer();
debugLog('DB-Server gespeichert', 'success');
} catch (error) {
showAlert(error.message, 'error');
}
}
async function testeDbServer() {
const data = {
typ: document.getElementById('dbs-typ').value,
host: document.getElementById('dbs-host').value.trim(),
port: parseInt(document.getElementById('dbs-port').value) || 3306,
user: document.getElementById('dbs-user').value.trim(),
password: document.getElementById('dbs-password').value
};
try {
zeigeLoading('Teste Verbindung...');
const result = await api('/dbserver/test', { method: 'POST', body: JSON.stringify(data) });
showAlert(result.message || 'Verbindung erfolgreich!', 'success');
} catch (error) {
showAlert('Verbindung fehlgeschlagen: ' + error.message, 'error');
} finally {
versteckeLoading();
}
}
async function dbServerBearbeiten(id) {
try {
const server = await api(`/dbserver/${id}`);
zeigeDbServerModal(server);
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbServerAktivieren(id) {
try {
await api(`/dbserver/${id}/aktivieren`, { method: 'POST' });
ladeDbServer();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbServerLoeschen(id) {
if (!await showConfirm('DB-Server wirklich löschen?')) return;
try {
await api(`/dbserver/${id}`, { method: 'DELETE' });
ladeDbServer();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function zeigeDbModal(db = null) {
editierteDbId = db?.id || null;
document.getElementById('db-modal-title').textContent = db ? 'Datenbank bearbeiten' : 'Datenbank hinzufügen';
// Server-Dropdown befüllen
const serverSelect = document.getElementById('db-server-id');
try {
const server = await api('/dbserver');
serverSelect.innerHTML = '<option value="">Server wählen...</option>' +
server.map(s => `<option value="${s.id}">${escapeHtml(s.name)} (${s.typ})</option>`).join('');
} catch (error) {
serverSelect.innerHTML = '<option value="">Fehler beim Laden</option>';
}
document.getElementById('db-name').value = db?.name || '';
document.getElementById('db-server-id').value = db?.server_id || '';
document.getElementById('db-database').value = db?.database || '';
document.getElementById('db-backup-pfad').value = db?.backup_pfad || '';
document.getElementById('db-aufbewahrung').value = db?.aufbewahrung || 7;
document.getElementById('db-format').value = db?.format || 'sql';
document.getElementById('db-modal').classList.remove('hidden');
}
async function speichereDb() {
const data = {
name: document.getElementById('db-name').value.trim(),
server_id: parseInt(document.getElementById('db-server-id').value),
database: document.getElementById('db-database').value.trim(),
backup_pfad: document.getElementById('db-backup-pfad').value.trim(),
aufbewahrung: parseInt(document.getElementById('db-aufbewahrung').value) || 7,
format: document.getElementById('db-format').value
};
if (!data.name || !data.server_id || !data.database || !data.backup_pfad) {
showAlert('Bitte alle Pflichtfelder ausfüllen', 'warning');
return;
}
try {
if (editierteDbId) {
await api(`/datenbanken/${editierteDbId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/datenbanken', { method: 'POST', body: JSON.stringify(data) });
}
schliesseModal('db-modal');
ladeDatenbanken();
debugLog('Datenbank gespeichert', 'success');
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbBearbeiten(id) {
try {
const db = await api(`/datenbanken/${id}`);
zeigeDbModal(db);
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbAktivieren(id) {
try {
await api(`/datenbanken/${id}/aktivieren`, { method: 'POST' });
ladeDatenbanken();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbLoeschen(id) {
if (!await showConfirm('Datenbank-Konfiguration wirklich löschen?')) return;
try {
await api(`/datenbanken/${id}`, { method: 'DELETE' });
ladeDatenbanken();
} catch (error) {
showAlert(error.message, 'error');
}
}
async function dbBackupErstellen(id) {
const logContainer = document.getElementById('dbbackup-log');
logContainer.innerHTML = '<div class="log-entry info">Erstelle Backup...</div>';
try {
debugLog('Erstelle Backup...', 'info');
const result = await api(`/datenbanken/${id}/backup`, { method: 'POST' });
logContainer.innerHTML = `<div class="log-entry success">
<span> Backup erstellt: ${escapeHtml(result.datei || 'Erfolgreich')}</span>
<small>${result.groesse_mb ? result.groesse_mb + ' MB' : ''}</small>
</div>`;
debugLog('Backup erstellt: ' + (result.datei || 'Erfolgreich'), 'success');
ladeBackups();
} catch (error) {
logContainer.innerHTML = `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
debugLog('Backup-Fehler: ' + error.message, 'error');
}
}
async function alleDbBackupsErstellen() {
const logContainer = document.getElementById('dbbackup-log');
logContainer.innerHTML = '<div class="log-entry info">Starte Backups...</div>';
try {
debugLog('Starte Backup aller Datenbanken...', 'info');
const dbs = await api('/datenbanken');
const aktiveDbs = dbs.filter(db => db.aktiv);
if (aktiveDbs.length === 0) {
logContainer.innerHTML = '<div class="log-entry warning">Keine aktiven Datenbanken konfiguriert</div>';
return;
}
logContainer.innerHTML = '';
let erfolg = 0;
let fehler = 0;
for (const db of aktiveDbs) {
debugLog(`Backup: ${db.name}`, 'info');
try {
const result = await api(`/datenbanken/${db.id}/backup`, { method: 'POST' });
erfolg++;
logContainer.innerHTML += `<div class="log-entry success">
<span> ${escapeHtml(db.name)}: ${escapeHtml(result.datei || 'OK')}</span>
<small>${result.groesse_mb ? result.groesse_mb + ' MB' : ''}</small>
</div>`;
} catch (error) {
fehler++;
logContainer.innerHTML += `<div class="log-entry error">
<span> ${escapeHtml(db.name)}: ${error.message}</span>
</div>`;
}
}
// Zusammenfassung
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong>Zusammenfassung:</strong> ${erfolg} erfolgreich, ${fehler} Fehler
</div>`;
debugLog('Alle Backups abgeschlossen', 'success');
ladeBackups();
} catch (error) {
logContainer.innerHTML = `<div class="log-entry error">Fehler: ${escapeHtml(error.message)}</div>`;
debugLog('Fehler: ' + error.message, 'error');
}
}
// ============ Zeitplan Modal erweitert ============
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');
document.getElementById('zp-db-gruppe').classList.toggle('hidden', typ !== 'db_backup');
// Datenbanken-Dropdown befüllen wenn DB-Backup gewählt
if (typ === 'db_backup') {
ladeDbFuerZeitplan();
}
}
async function ladeDbFuerZeitplan() {
try {
const dbs = await api('/datenbanken');
const select = document.getElementById('zp-db');
select.innerHTML = '<option value="">Alle aktiven Datenbanken</option>' +
dbs.map(db => `<option value="${db.id}">${escapeHtml(db.name)}</option>`).join('');
} catch (error) {
console.error('Fehler beim Laden der Datenbanken für Zeitplan:', error);
}
}

View file

@ -13,6 +13,13 @@
<div class="header-left"> <div class="header-left">
<h1>Dateiverwaltung</h1> <h1>Dateiverwaltung</h1>
</div> </div>
<div class="header-center">
<!-- Debug-Log Anzeige -->
<div id="debug-log-header" class="debug-log-header">
<span class="debug-log-label">Log:</span>
<span id="debug-log-text" class="debug-log-text">Bereit</span>
</div>
</div>
<div class="header-right"> <div class="header-right">
<span id="status-indicator"></span> <span id="status-indicator"></span>
<button class="btn-icon" onclick="zeigeLogModal()" title="Debug-Log">📋</button> <button class="btn-icon" onclick="zeigeLogModal()" title="Debug-Log">📋</button>
@ -20,20 +27,30 @@
</div> </div>
</header> </header>
<!-- Main Content --> <!-- Tab-Navigation -->
<div class="main-container"> <nav class="tab-navigation">
<!-- Bereich 1: Mail-Abruf --> <button class="tab-btn active" data-tab="mailabruf" onclick="wechsleTab('mailabruf')">
<section class="bereich"> 📧 Mailabruf
<div class="bereich-header"> </button>
<h2>📧 Mail-Abruf</h2> <button class="tab-btn" data-tab="grobsortierung" onclick="wechsleTab('grobsortierung')">
<p class="bereich-desc">Attachments aus Postfächern in Ordner speichern</p> 📁 Grobsortierung
</div> </button>
<button class="tab-btn" data-tab="feinsortierung" onclick="wechsleTab('feinsortierung')">
📑 Feinsortierung
</button>
<button class="tab-btn" data-tab="dbbackup" onclick="wechsleTab('dbbackup')">
🗄️ Datenbank-Backup
</button>
</nav>
<div class="bereich-content"> <!-- Tab: Mailabruf -->
<!-- Postfächer Liste --> <div id="tab-mailabruf" class="tab-content active">
<div class="tab-layout">
<!-- Links: Postfächer -->
<section class="tab-left">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Postfächer</h3> <h3>📧 Postfächer</h3>
<button class="btn btn-sm btn-primary" onclick="zeigePostfachModal()">+ Hinzufügen</button> <button class="btn btn-sm btn-primary" onclick="zeigePostfachModal()">+ Hinzufügen</button>
</div> </div>
<div class="card-body"> <div class="card-body">
@ -42,15 +59,23 @@
</div> </div>
</div> </div>
</div> </div>
</section>
<!-- Abruf starten --> <!-- Mitte: Abruf-Bereich -->
<section class="tab-center">
<div class="card">
<div class="card-header">
<h3>Aktion</h3>
</div>
<div class="card-body">
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-success btn-large" onclick="allePostfaecherAbrufen()"> <button class="btn btn-success btn-large" onclick="allePostfaecherAbrufen()">
▶ Alle Postfächer abrufen ▶ Alle Postfächer abrufen
</button> </button>
</div> </div>
</div>
</div>
<!-- Letzter Abruf Log -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Letzter Abruf</h3> <h3>Letzter Abruf</h3>
@ -61,51 +86,154 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<!-- Bereich 2: Datei-Sortierung --> <!-- Rechts: Status & Scheduler -->
<section class="bereich"> <section class="tab-right">
<div class="bereich-header">
<h2>📁 Datei-Sortierung</h2>
<p class="bereich-desc">Dateien nach Regeln umbenennen und verschieben</p>
</div>
<div class="bereich-content">
<!-- Grobsortierung -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Grobsortierung</h3> <h3>⏰ Status</h3>
<button class="btn btn-sm" onclick="ladeStatus()">🔄</button>
</div>
<div class="card-body">
<div id="status-mailabruf">
<p class="empty-state">Status wird geladen...</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Zeitpläne</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeZeitplanModal('mail_abruf')">+ Neu</button>
</div>
<div class="card-body">
<div id="zeitplaene-mailabruf">
<p class="empty-state">Keine Zeitpläne</p>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Tab: Grobsortierung -->
<div id="tab-grobsortierung" class="tab-content">
<div class="tab-layout">
<!-- Links: Grobsortierung-Regeln -->
<section class="tab-left">
<div class="card">
<div class="card-header">
<h3>📁 Grobsortierung</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeOrdnerModal()">+ Hinzufügen</button> <button class="btn btn-sm btn-primary" onclick="zeigeOrdnerModal()">+ Hinzufügen</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="ordner-liste"> <div id="ordner-liste">
<p class="empty-state">Keine Ordner konfiguriert</p> <p class="empty-state">Keine Grobsortierung konfiguriert</p>
</div>
</div>
</div>
</section>
<!-- Mitte: Verarbeitung -->
<section class="tab-center">
<div class="card">
<div class="card-header">
<h3>Aktion</h3>
</div>
<div class="card-body">
<div class="action-bar">
<button class="btn btn-success btn-large" onclick="alleOrdnerVerarbeiten()">
▶ Alle verarbeiten
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Regeln -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Sortier-Regeln</h3> <h3>Verarbeitungs-Log</h3>
</div>
<div class="card-body">
<div id="grobsortierung-log" class="log-output">
<p class="empty-state">Noch keine Verarbeitung durchgeführt</p>
</div>
</div>
</div>
</section>
<!-- Rechts: Status & Scheduler -->
<section class="tab-right">
<div class="card">
<div class="card-header">
<h3>⏰ Status</h3>
<button class="btn btn-sm" onclick="ladeStatus()">🔄</button>
</div>
<div class="card-body">
<div id="status-grobsortierung">
<p class="empty-state">Status wird geladen...</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Zeitpläne</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeZeitplanModal('grobsortierung')">+ Neu</button>
</div>
<div class="card-body">
<div id="zeitplaene-grobsortierung">
<p class="empty-state">Keine Zeitpläne</p>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Tab: Feinsortierung -->
<div id="tab-feinsortierung" class="tab-content">
<div class="tab-layout">
<!-- Links: Sortier-Regeln -->
<section class="tab-left">
<div class="card">
<div class="card-header">
<h3>📑 Sortier-Regeln</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<select id="regeln-sortierung" onchange="ladeRegeln()" title="Sortierung" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="prio_asc">Priorität ↑</option>
<option value="prio_desc">Priorität ↓</option>
</select>
<button class="btn btn-sm" onclick="exportiereRegeln()" title="Regeln exportieren">📤</button>
<button class="btn btn-sm" onclick="importiereRegeln()" title="Regeln importieren">📥</button>
<button class="btn btn-sm btn-primary" onclick="zeigeRegelModal()">+ Hinzufügen</button> <button class="btn btn-sm btn-primary" onclick="zeigeRegelModal()">+ Hinzufügen</button>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<div id="regeln-liste"> <div id="regeln-liste">
<p class="empty-state">Keine Regeln definiert</p> <p class="empty-state">Keine Regeln definiert</p>
</div> </div>
</div> </div>
</div> </div>
</section>
<!-- Sortierung starten --> <!-- Mitte: Verarbeitung -->
<section class="tab-center">
<div class="card">
<div class="card-header">
<h3>Aktion</h3>
</div>
<div class="card-body">
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-success btn-large" onclick="sortierungStarten()"> <button class="btn btn-success btn-large" onclick="feinsortierungStarten()">
▶ Sortierung starten ▶ Feinsortierung starten
</button> </button>
</div> </div>
</div>
</div>
<!-- Sortierungs-Log -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Verarbeitete Dateien</h3> <h3>Verarbeitete Dateien</h3>
@ -116,45 +244,133 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<!-- Bereich 3: Zeitpläne / Scheduler --> <!-- Rechts: Status & Scheduler -->
<section class="bereich"> <section class="tab-right">
<div class="bereich-header">
<h2>⏰ Zeitpläne</h2>
<p class="bereich-desc">Automatische Ausführung von Mail-Abruf und Sortierung</p>
</div>
<div class="bereich-content">
<!-- Status-Übersicht -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Status-Übersicht</h3> <h3>⏰ Status</h3>
<button class="btn btn-sm" onclick="ladeStatus()">🔄 Aktualisieren</button> <button class="btn btn-sm" onclick="ladeStatus()">🔄</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="status-uebersicht"> <div id="status-feinsortierung">
<p class="empty-state">Status wird geladen...</p> <p class="empty-state">Status wird geladen...</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Zeitpläne Liste -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Zeitpläne</h3> <h3>Zeitpläne</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeZeitplanModal()">+ Hinzufügen</button> <button class="btn btn-sm btn-primary" onclick="zeigeZeitplanModal('sortierregeln')">+ Neu</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="zeitplaene-liste"> <div id="zeitplaene-feinsortierung">
<p class="empty-state">Keine Zeitpläne konfiguriert</p> <p class="empty-state">Keine Zeitpläne</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</div>
<!-- Tab: Datenbank-Backup -->
<div id="tab-dbbackup" class="tab-content">
<div class="tab-layout">
<!-- Links: DB-Server & Datenbanken -->
<section class="tab-left">
<div class="card">
<div class="card-header">
<h3>🗄️ DB-Server</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeDbServerModal()">+ Server</button>
</div>
<div class="card-body">
<div id="dbserver-liste">
<p class="empty-state">Keine Server konfiguriert</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>📊 Datenbanken</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeDbModal()">+ Datenbank</button>
</div>
<div class="card-body">
<div id="datenbanken-liste">
<p class="empty-state">Keine Datenbanken konfiguriert</p>
</div>
</div>
</div>
</section>
<!-- Mitte: Backup-Bereich -->
<section class="tab-center">
<div class="card">
<div class="card-header">
<h3>Aktion</h3>
</div>
<div class="card-body">
<div class="action-bar">
<button class="btn btn-success btn-large" onclick="alleDbBackupsErstellen()">
▶ Alle Backups erstellen
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Backup-Log</h3>
</div>
<div class="card-body">
<div id="dbbackup-log" class="log-output">
<p class="empty-state">Noch keine Backups durchgeführt</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Vorhandene Backups</h3>
</div>
<div class="card-body">
<div id="backups-liste">
<p class="empty-state">Keine Backups vorhanden</p>
</div>
</div>
</div>
</section>
<!-- Rechts: Status & Scheduler -->
<section class="tab-right">
<div class="card">
<div class="card-header">
<h3>⏰ Status</h3>
<button class="btn btn-sm" onclick="ladeStatus()">🔄</button>
</div>
<div class="card-body">
<div id="status-dbbackup">
<p class="empty-state">Status wird geladen...</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Zeitpläne</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeZeitplanModal('db_backup')">+ Neu</button>
</div>
<div class="card-body">
<div id="zeitplaene-dbbackup">
<p class="empty-state">Keine Zeitpläne</p>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Modal: Postfach hinzufügen --> <!-- Modal: Postfach hinzufügen -->
<div id="postfach-modal" class="modal hidden"> <div id="postfach-modal" class="modal hidden">
@ -266,7 +482,7 @@
</div> </div>
</div> </div>
<!-- Modal: Ordner hinzufügen/bearbeiten - Breit mit 2 Spalten --> <!-- Modal: Grobsortierung hinzufügen/bearbeiten -->
<div id="ordner-modal" class="modal hidden"> <div id="ordner-modal" class="modal hidden">
<div class="modal-content modal-fullwidth"> <div class="modal-content modal-fullwidth">
<div class="modal-header"> <div class="modal-header">
@ -499,6 +715,12 @@
<label title="Komma-getrennte Wörter die NICHT im Dokument vorkommen dürfen">Ausschluss-Keywords</label> <label title="Komma-getrennte Wörter die NICHT im Dokument vorkommen dürfen">Ausschluss-Keywords</label>
<input type="text" id="regel-keywords-nicht" placeholder="gutschrift, storno" title="Wenn eines dieser Wörter vorkommt, greift die Regel NICHT. Nützlich um z.B. Gutschriften von Rechnungen zu unterscheiden."> <input type="text" id="regel-keywords-nicht" placeholder="gutschrift, storno" title="Wenn eines dieser Wörter vorkommt, greift die Regel NICHT. Nützlich um z.B. Gutschriften von Rechnungen zu unterscheiden.">
</div> </div>
<div class="form-row" style="margin-top: 0.5rem;">
<label class="checkbox-label compact" title="Wenn aktiviert, werden Keywords auch im Dateinamen gesucht (Standard: nur PDF-Text)">
<input type="checkbox" id="regel-auch-dateiname">
<span>Auch Dateinamen prüfen</span>
</label>
</div>
</div> </div>
<!-- Feld-Extraktion --> <!-- Feld-Extraktion -->
@ -612,6 +834,105 @@
</div> </div>
</div> </div>
<!-- Modal: DB-Server hinzufügen -->
<div id="dbserver-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="dbserver-modal-title">DB-Server hinzufügen</h3>
<button class="modal-close" onclick="schliesseModal('dbserver-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input type="text" id="dbs-name" placeholder="z.B. Nextcloud Server">
</div>
<div class="form-group">
<label>Typ</label>
<select id="dbs-typ">
<option value="mariadb">MariaDB</option>
<option value="mysql">MySQL</option>
<option value="postgresql">PostgreSQL</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Host / IP</label>
<input type="text" id="dbs-host" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" id="dbs-port" value="3306">
</div>
</div>
<div class="form-group">
<label>Benutzer</label>
<input type="text" id="dbs-user" placeholder="root">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="dbs-password">
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('dbserver-modal')">Abbrechen</button>
<button class="btn" onclick="testeDbServer()">🔌 Verbindung testen</button>
<button class="btn btn-primary" onclick="speichereDbServer()">Speichern</button>
</div>
</div>
</div>
<!-- Modal: Datenbank hinzufügen -->
<div id="db-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="db-modal-title">Datenbank hinzufügen</h3>
<button class="modal-close" onclick="schliesseModal('db-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Name (zur Identifikation)</label>
<input type="text" id="db-name" placeholder="z.B. Nextcloud DB">
</div>
<div class="form-group">
<label>Server</label>
<select id="db-server-id">
<option value="">Server wählen...</option>
</select>
</div>
<div class="form-group">
<label>Datenbank-Name</label>
<input type="text" id="db-database" placeholder="nextcloud">
</div>
<div class="form-group">
<label>Backup-Zielordner</label>
<div class="input-with-btn">
<input type="text" id="db-backup-pfad" placeholder="/mnt/backups/datenbanken/">
<button class="btn" type="button" onclick="oeffneBrowser('db-backup-pfad')">📁</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Backups behalten</label>
<input type="number" id="db-aufbewahrung" value="7" min="1" style="width: 80px;">
<small>Anzahl der aufzubewahrenden Backups</small>
</div>
<div class="form-group">
<label>Format</label>
<select id="db-format">
<option value="sql">SQL-Datei (.sql)</option>
<option value="sql.gz">Komprimiert (.sql.gz)</option>
<option value="zip">ZIP-Archiv (.zip)</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('db-modal')">Abbrechen</button>
<button class="btn btn-primary" onclick="speichereDb()">Speichern</button>
</div>
</div>
</div>
<!-- Modal: Verzeichnis-Browser --> <!-- Modal: Verzeichnis-Browser -->
<div id="browser-modal" class="modal hidden"> <div id="browser-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
@ -653,9 +974,10 @@
<div class="form-group"> <div class="form-group">
<label>Was soll ausgeführt werden?</label> <label>Was soll ausgeführt werden?</label>
<select id="zp-typ" onchange="zeitplanTypChanged()"> <select id="zp-typ" onchange="zeitplanTypChanged()">
<option value="mail_abruf">Mail-Abruf</option> <option value="mail_abruf">Mailabruf</option>
<option value="grobsortierung">Grobsortierung</option> <option value="grobsortierung">Grobsortierung</option>
<option value="sortierregeln">Nur Sortierregeln</option> <option value="sortierregeln">Feinsortierung</option>
<option value="db_backup">Datenbank-Backup</option>
</select> </select>
</div> </div>
@ -680,6 +1002,13 @@
</select> </select>
</div> </div>
<div class="form-group hidden" id="zp-db-gruppe">
<label>Datenbank (leer = alle aktiven)</label>
<select id="zp-db">
<option value="">Alle aktiven Datenbanken</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Intervall</label> <label>Intervall</label>
<select id="zp-intervall" onchange="zeitplanIntervallChanged()"> <select id="zp-intervall" onchange="zeitplanIntervallChanged()">