diff --git a/Source/Dockerfile b/Source/Dockerfile
index 52ac556..99f0147 100755
--- a/Source/Dockerfile
+++ b/Source/Dockerfile
@@ -1,15 +1,19 @@
# Dateiverwaltung Docker Image
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 \
tesseract-ocr \
tesseract-ocr-deu \
ocrmypdf \
+ unpaper \
poppler-utils \
ghostscript \
libmagic1 \
curl \
+ mariadb-client \
+ postgresql-client \
+ gzip \
&& rm -rf /var/lib/apt/lists/*
# Arbeitsverzeichnis
diff --git a/Source/backend/app/models/database.py b/Source/backend/app/models/database.py
index 41312d7..e92bf05 100755
--- a/Source/backend/app/models/database.py
+++ b/Source/backend/app/models/database.py
@@ -152,10 +152,11 @@ class Zeitplan(Base):
aktiv = Column(Boolean, default=True)
# 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
quell_ordner_id = Column(Integer) # Optional: spezifischer Quellordner
regel_id = Column(Integer) # Optional: spezifische Regel (für sortierregeln)
+ datenbank_id = Column(Integer) # Optional: spezifische Datenbank (für db_backup)
# Zeitplan-Intervall
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)
+# ============ 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():
"""Fügt fehlende Spalten hinzu ohne Daten zu löschen"""
from sqlalchemy import inspect, text
@@ -240,7 +288,8 @@ def migrate_db():
"nur_umbenennen": "BOOLEAN DEFAULT 0"
},
"zeitplaene": {
- "regel_id": "INTEGER"
+ "regel_id": "INTEGER",
+ "datenbank_id": "INTEGER"
}
}
diff --git a/Source/backend/app/modules/pdf_processor.py b/Source/backend/app/modules/pdf_processor.py
index ade3042..15224fd 100755
--- a/Source/backend/app/modules/pdf_processor.py
+++ b/Source/backend/app/modules/pdf_processor.py
@@ -444,15 +444,15 @@ class PDFProcessor:
"ocrmypdf",
"--language", self.ocr_language,
"--deskew", # Schräge Scans korrigieren
+ "--rotate-pages", # Automatische Seitenrotation (90°, 180°, 270°)
"--clean", # Bild verbessern
"--skip-text", # Seiten mit Text überspringen
- "--force-ocr", # OCR erzwingen falls nötig
str(pfad),
str(temp_pfad)
],
capture_output=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():
diff --git a/Source/backend/app/modules/sorter.py b/Source/backend/app/modules/sorter.py
index 7aa6f8c..425c183 100755
--- a/Source/backend/app/modules/sorter.py
+++ b/Source/backend/app/modules/sorter.py
@@ -52,16 +52,29 @@ class Sorter:
original_name = dokument_info.get("original_name", "").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)
+ # Verwendet Wortgrenzen (\b) um Teilstring-Matches zu vermeiden
+ # z.B. "rechnung" matched nicht "Versicherungsnummer" oder "Berechnung"
if "keywords" in muster:
keywords = muster["keywords"]
if isinstance(keywords, str):
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:
keyword = keyword.lower().strip()
- if keyword and keyword not in text and keyword not in original_name:
- return False
+ 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
# absender_contains
if "absender_contains" in muster:
@@ -105,15 +118,23 @@ class Sorter:
# ============ NEGATIVE MUSTER (dürfen NICHT vorkommen) ============
- # keywords_nicht (keines darf vorkommen)
+ # keywords_nicht (keines darf vorkommen - als ganzes Wort)
if "keywords_nicht" in muster:
keywords = muster["keywords_nicht"]
if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(",")]
for keyword in keywords:
keyword = keyword.lower().strip()
- if keyword and (keyword in text or keyword in original_name):
- return False # Verbotenes Keyword gefunden
+ 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
# text_not_match (keines darf enthalten sein)
if "text_not_match" in muster:
@@ -217,7 +238,17 @@ class Sorter:
# Datum formatieren
if "format" in config:
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")
except:
pass
diff --git a/Source/backend/app/routes/api.py b/Source/backend/app/routes/api.py
index 375b836..966fd37 100755
--- a/Source/backend/app/routes/api.py
+++ b/Source/backend/app/routes/api.py
@@ -13,7 +13,7 @@ import asyncio
import tempfile
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.pdf_processor import PDFProcessor
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": []}
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": []}
# Regeln in Dict-Format
@@ -833,6 +837,49 @@ def verarbeite_ordner(id: int, db: Session = Depends(get_db)):
ergebnis["verarbeitet"].append(datei_info)
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
doc_info = {
"text": text,
@@ -1007,6 +1054,95 @@ def teste_regel(data: RegelTestRequest):
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 ============
@router.get("/ordner/{ordner_id}/regeln")
@@ -1507,6 +1643,207 @@ def starte_sortierung(db: Session = Depends(get_db)):
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 ============
@router.post("/pdf/extrahieren")
@@ -2172,3 +2509,276 @@ def status_uebersicht(db: Session = Depends(get_db)):
"quell_ordner": ordner_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
diff --git a/Source/backend/app/services/backup_service.py b/Source/backend/app/services/backup_service.py
new file mode 100755
index 0000000..097670e
--- /dev/null
+++ b/Source/backend/app/services/backup_service.py
@@ -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
diff --git a/Source/backend/app/services/scheduler_service.py b/Source/backend/app/services/scheduler_service.py
index 8637915..863a9e2 100755
--- a/Source/backend/app/services/scheduler_service.py
+++ b/Source/backend/app/services/scheduler_service.py
@@ -361,6 +361,8 @@ def execute_zeitplan(zeitplan_id: int):
elif zeitplan.typ == "sortierung":
# Legacy: alte "sortierung" wird wie "grobsortierung" behandelt
result = execute_grobsortierung(db, zeitplan)
+ elif zeitplan.typ == "db_backup":
+ result = execute_db_backup(db, zeitplan)
else:
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()
print(f"[GROBSORTIERUNG] Gefunden: {len(regeln)} aktive Regeln", flush=True)
- if not regeln:
- print("[GROBSORTIERUNG] ⚠️ Keine aktiven Regeln - Abbruch", flush=True)
+ # Prüfen ob mindestens ein Ordner direkt_verschieben aktiviert hat
+ 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"}
+ if not regeln:
+ print("[GROBSORTIERUNG] ℹ️ Keine Regeln, aber direkt_verschieben aktiv - fahre fort", flush=True)
+
# Regeln in Dict-Format
regeln_dicts = [{
"id": r.id,
@@ -860,6 +868,48 @@ def execute_sortierregeln(db, zeitplan: Zeitplan) -> Dict:
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:
"""Gibt den Status aller Zeitpläne zurück"""
global scheduler
diff --git a/Source/backend/requirements.txt b/Source/backend/requirements.txt
index e3f4b7e..15f7caa 100755
--- a/Source/backend/requirements.txt
+++ b/Source/backend/requirements.txt
@@ -8,6 +8,7 @@ jinja2==3.1.3
sqlalchemy==2.0.25
aiosqlite==0.19.0
pymysql==1.1.0
+psycopg2-binary==2.9.9
# PDF Processing
pypdf==4.0.1
diff --git a/Source/docker-compose.yml b/Source/docker-compose.yml
index 79d4f3e..6cb5d6b 100755
--- a/Source/docker-compose.yml
+++ b/Source/docker-compose.yml
@@ -14,7 +14,7 @@ services:
- /mnt:/mnt
environment:
- 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:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
diff --git a/Source/frontend/static/css/style.css b/Source/frontend/static/css/style.css
index 3359ef3..4cf7786 100755
--- a/Source/frontend/static/css/style.css
+++ b/Source/frontend/static/css/style.css
@@ -50,6 +50,163 @@ body {
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 {
display: grid;
grid-template-columns: repeat(3, 1fr);
diff --git a/Source/frontend/static/js/app.js b/Source/frontend/static/js/app.js
index 9c3f582..fa90d64 100755
--- a/Source/frontend/static/js/app.js
+++ b/Source/frontend/static/js/app.js
@@ -341,6 +341,12 @@ document.addEventListener('DOMContentLoaded', () => {
ladeZeitplaene();
ladeStatus();
+ // Gespeicherten Tab wiederherstellen
+ const gespeicherterTab = localStorage.getItem('aktiver-tab');
+ if (gespeicherterTab) {
+ wechsleTab(gespeicherterTab);
+ }
+
// Event-Listener für Dateityp-Checkboxen im Postfach-Modal
const pfTypenGruppe = document.getElementById('pf-typen-gruppe');
if (pfTypenGruppe) {
@@ -985,26 +991,37 @@ async function ordnerVorschau(id) {
async function ordnerVerarbeiten(id) {
if (!await showConfirm('Dateien jetzt verarbeiten und sortieren?')) return;
+ const logContainer = document.getElementById('grobsortierung-log');
+ logContainer.innerHTML = '
Verarbeite...
';
+
try {
- zeigeLoading('Verarbeite Dateien...');
+ debugLog('Verarbeite Ordner...', 'info');
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}`;
+ // Ergebnis in der Mitte anzeigen
+ let html = `
+ Verarbeitung abgeschlossen
+ Gesamt: ${result.gesamt} | Sortiert: ${result.sortiert} | ZUGFeRD: ${result.zugferd} | Keine Regel: ${result.keine_regel || 0} | Fehler: ${result.fehler}
+
`;
- if (result.fehler && result.fehler > 0) {
- showAlert(msg, 'warning', 'Verarbeitung mit Warnungen');
- } else {
- showAlert(msg, 'success', 'Verarbeitung abgeschlossen');
+ // Details der verarbeiteten Dateien 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');
+ const icon = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? '✓' :
+ (d.status === 'fehler' ? '✗' : 'ℹ');
+ html += `
+ ${icon} ${escapeHtml(d.original)} → ${escapeHtml(d.neuer_name || d.status)}
+
`;
+ });
}
+
+ logContainer.innerHTML = html;
+ debugLog('Verarbeitung abgeschlossen', 'success');
} catch (error) {
- showAlert(error.message, 'error');
- } finally {
- versteckeLoading();
+ logContainer.innerHTML = `Fehler: ${escapeHtml(error.message)}
`;
+ debugLog('Fehler: ' + error.message, 'error');
}
}
@@ -1012,9 +1029,43 @@ async function ordnerVerarbeiten(id) {
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() {
try {
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);
} catch (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 = `
+
+
+
+
Wie sollen die Regeln importiert werden?
+
+
+
+
+
+
+
+
+ `;
+ 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) ============
let alleOrdner = []; // Cache für Ordner-Liste
@@ -1090,6 +1249,7 @@ async function zeigeRegelModal(regel = null) {
const muster = regel?.muster || {};
document.getElementById('regel-keywords').value = muster.keywords || '';
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 || '';
// Extraktion-Tabelle befüllen
@@ -1436,10 +1596,12 @@ async function speichereRegel() {
const muster = {};
const keywords = document.getElementById('regel-keywords').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();
if (keywords) muster.keywords = keywords;
if (keywordsNicht) muster.keywords_nicht = keywordsNicht;
+ if (auchDateiname) muster.auch_dateiname = true;
if (textRegex) muster.text_regex = textRegex;
// Extraktion aus Tabelle sammeln
@@ -2533,14 +2695,641 @@ function truncatePath(path, maxLength = 35) {
return `${escapeHtml(start)}...${escapeHtml(end)}`;
}
-document.addEventListener('click', (e) => {
- if (e.target.classList.contains('modal')) {
- e.target.classList.add('hidden');
- }
-});
+// Modal nur über X-Button oder Abbrechen schließen, nicht durch Klick auf Hintergrund
+// (entfernt: Klick auf Modal-Hintergrund schließt nicht mehr)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
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 = 'Keine Zeitpläne
';
+ return;
+ }
+
+ container.innerHTML = gefiltert.map(z => `
+
+
+
${escapeHtml(z.name)} ${z.aktiv ? '✓' : ''}
+ ${z.intervall} ${z.stunde != null ? `um ${z.stunde}:${String(z.minute || 0).padStart(2, '0')}` : ''}
+
+
+
+
+
+
+
+ `).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 = 'Starte Grobsortierung...
';
+
+ 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 = 'Keine aktiven Ordner konfiguriert
';
+ 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 = `
+ ✓ ${escapeHtml(o.name)}: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD
+
`;
+
+ // 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 += `
+ → ${escapeHtml(d.original)} → ${escapeHtml(d.neuer_name || d.status)}
+
`;
+ });
+ }
+
+ logContainer.innerHTML += ordnerHtml;
+ } catch (error) {
+ gesamtFehler++;
+ logContainer.innerHTML += `
+ ✗ ${escapeHtml(o.name)}: ${error.message}
+
`;
+ }
+ }
+
+ // Zusammenfassung am Ende
+ logContainer.innerHTML += `
+ Zusammenfassung: ${gesamtSortiert} Dateien sortiert, ${gesamtFehler} Fehler
+
`;
+
+ debugLog('Grobsortierung abgeschlossen', 'success');
+ } catch (error) {
+ logContainer.innerHTML = `Fehler: ${escapeHtml(error.message)}
`;
+ debugLog('Fehler: ' + error.message, 'error');
+ }
+}
+
+// ============ Feinsortierung starten ============
+
+async function feinsortierungStarten() {
+ const logContainer = document.getElementById('sortierung-log');
+ logContainer.innerHTML = 'Starte Feinsortierung...
';
+
+ 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 = `
+ Starte Feinsortierung... ${data.ordner_count} Ordner, ${data.gesamt} Dateien
+
`;
+ // 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 = `Gesamt: 0 | Sortiert: 0 | ZUGFeRD: 0 | Fehler: 0`;
+ logContainer.appendChild(zusammenfassungDiv);
+ break;
+
+ case 'ordner':
+ logContainer.innerHTML += `
+ 📁 ${escapeHtml(data.ordner)} (${data.dateien} Dateien)
+
`;
+ 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 += ` [${escapeHtml(data.regel)}]`;
+ }
+ if (data.zugferd) {
+ text += ` (ZUGFeRD)`;
+ }
+
+ logContainer.innerHTML += `
+ ${icon} ${text}
+
`;
+
+ // Zusammenfassung aktualisieren
+ if (zusammenfassungDiv) {
+ zusammenfassungDiv.innerHTML = `Gesamt: ${gesamt} | Sortiert: ${sortiert} | ZUGFeRD: ${zugferd} | Fehler: ${fehler}`;
+ }
+ break;
+
+ case 'datei_fehler':
+ fehler++;
+ gesamt++;
+
+ logContainer.innerHTML += `
+ ✗ ${escapeHtml(data.original || '')} (${escapeHtml(data.fehler || 'Unbekannter Fehler')})
+
`;
+
+ // Zusammenfassung aktualisieren
+ if (zusammenfassungDiv) {
+ zusammenfassungDiv.innerHTML = `Gesamt: ${gesamt} | Sortiert: ${sortiert} | ZUGFeRD: ${zugferd} | Fehler: ${fehler}`;
+ }
+ break;
+
+ case 'fertig':
+ logContainer.innerHTML += `
+ ✓ Feinsortierung abgeschlossen
+
`;
+ break;
+ }
+
+ // Auto-Scroll zum Ende
+ logContainer.scrollTop = logContainer.scrollHeight;
+
+ } catch (parseError) {
+ console.error('SSE Parse-Fehler:', parseError, line);
+ }
+ }
+ }
+ }
+
+ if (gesamt === 0) {
+ logContainer.innerHTML = `Keine Dateien zur Verarbeitung gefunden
`;
+ }
+
+ debugLog('Feinsortierung abgeschlossen', 'success');
+ } catch (error) {
+ logContainer.innerHTML = `Fehler: ${escapeHtml(error.message)}
`;
+ 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 = 'Keine Server konfiguriert
';
+ return;
+ }
+
+ container.innerHTML = server.map(s => `
+
+
+
${escapeHtml(s.name)} ${s.aktiv ? '✓' : ''}
+ ${s.typ} @ ${s.host}:${s.port}
+
+
+
+
+
+
+
+ `).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 = 'Keine Datenbanken konfiguriert
';
+ return;
+ }
+
+ container.innerHTML = dbs.map(db => `
+
+
+
${escapeHtml(db.name)} ${db.aktiv ? '✓' : ''}
+ ${escapeHtml(db.database)} (${db.server_name || 'Server'})
+
+
+
+
+
+
+
+
+ `).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 = 'Keine Backups vorhanden
';
+ return;
+ }
+
+ container.innerHTML = backups.slice(0, 20).map(b => `
+
+ ${escapeHtml(b.dateiname)}
+ ${b.groesse_mb} MB - ${b.erstellt}
+
+ `).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 = '' +
+ server.map(s => ``).join('');
+ } catch (error) {
+ serverSelect.innerHTML = '';
+ }
+
+ 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 = 'Erstelle Backup...
';
+
+ try {
+ debugLog('Erstelle Backup...', 'info');
+ const result = await api(`/datenbanken/${id}/backup`, { method: 'POST' });
+
+ logContainer.innerHTML = `
+ ✓ Backup erstellt: ${escapeHtml(result.datei || 'Erfolgreich')}
+ ${result.groesse_mb ? result.groesse_mb + ' MB' : ''}
+
`;
+
+ debugLog('Backup erstellt: ' + (result.datei || 'Erfolgreich'), 'success');
+ ladeBackups();
+ } catch (error) {
+ logContainer.innerHTML = `Fehler: ${escapeHtml(error.message)}
`;
+ debugLog('Backup-Fehler: ' + error.message, 'error');
+ }
+}
+
+async function alleDbBackupsErstellen() {
+ const logContainer = document.getElementById('dbbackup-log');
+ logContainer.innerHTML = 'Starte Backups...
';
+
+ 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 = 'Keine aktiven Datenbanken konfiguriert
';
+ 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 += `
+ ✓ ${escapeHtml(db.name)}: ${escapeHtml(result.datei || 'OK')}
+ ${result.groesse_mb ? result.groesse_mb + ' MB' : ''}
+
`;
+ } catch (error) {
+ fehler++;
+ logContainer.innerHTML += `
+ ✗ ${escapeHtml(db.name)}: ${error.message}
+
`;
+ }
+ }
+
+ // Zusammenfassung
+ logContainer.innerHTML += `
+ Zusammenfassung: ${erfolg} erfolgreich, ${fehler} Fehler
+
`;
+
+ debugLog('Alle Backups abgeschlossen', 'success');
+ ladeBackups();
+ } catch (error) {
+ logContainer.innerHTML = `Fehler: ${escapeHtml(error.message)}
`;
+ 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 = '' +
+ dbs.map(db => ``).join('');
+ } catch (error) {
+ console.error('Fehler beim Laden der Datenbanken für Zeitplan:', error);
+ }
+}
diff --git a/Source/frontend/templates/index.html b/Source/frontend/templates/index.html
index 6dd0692..916dbc7 100755
--- a/Source/frontend/templates/index.html
+++ b/Source/frontend/templates/index.html
@@ -13,6 +13,13 @@
+
-
-
-
-
-
+
+
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
-
-
Keine Ordner konfiguriert
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine Grobsortierung konfiguriert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Noch keine Verarbeitung durchgeführt
+
+
+
+
+
+
+
+
+
+
+
+
Status wird geladen...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
+
+
-
-
-
Keine Zeitpläne konfiguriert
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine Server konfiguriert
+
+
+
+
+
+
+
+
+
Keine Datenbanken konfiguriert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Noch keine Backups durchgeführt
+
+
+
+
+
+
+
+
+
Keine Backups vorhanden
+
+
+
+
+
+
+
+
+
+
+
+
Status wird geladen...
+
+
+
+
+
+
+
@@ -266,7 +482,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -653,9 +974,10 @@
@@ -680,6 +1002,13 @@
+
+
+
+
+