From c5ee82e1c2c31f8ab53f5577b5079333c67a3e82 Mon Sep 17 00:00:00 2001 From: data Date: Tue, 10 Feb 2026 13:42:12 +0100 Subject: [PATCH] V 2.0 - Feinsortierung Live-Streaming, Import/Export, PDF-Rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Source/Dockerfile | 6 +- Source/backend/app/models/database.py | 53 +- Source/backend/app/modules/pdf_processor.py | 4 +- Source/backend/app/modules/sorter.py | 45 +- Source/backend/app/routes/api.py | 614 ++++++++++++- Source/backend/app/services/backup_service.py | 262 ++++++ .../backend/app/services/scheduler_service.py | 54 +- Source/backend/requirements.txt | 1 + Source/docker-compose.yml | 2 +- Source/frontend/static/css/style.css | 157 ++++ Source/frontend/static/js/app.js | 827 +++++++++++++++++- Source/frontend/templates/index.html | 457 ++++++++-- 12 files changed, 2382 insertions(+), 100 deletions(-) create mode 100755 Source/backend/app/services/backup_service.py 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 = ` + + `; + 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 @@

Dateiverwaltung

+
+ +
+ Log: + Bereit +
+
@@ -20,20 +27,30 @@
- -
- -
-
-

📧 Mail-Abruf

-

Attachments aus Postfächern in Ordner speichern

-
+ + -
- + +
+
+ +
-

Postfächer

+

📧 Postfächer

@@ -42,15 +59,23 @@
+
- -
- + +
+
+
+

Aktion

+
+
+
+ +
+
-

Letzter Abruf

@@ -61,35 +86,130 @@
-
- + - -
-
-

📁 Datei-Sortierung

-

Dateien nach Regeln umbenennen und verschieben

-
- -
- + +
-

Grobsortierung

- +

⏰ Status

+
-
-

Keine Ordner konfiguriert

+
+

Status wird geladen...

-
-

Sortier-Regeln

- +

Zeitpläne

+ +
+
+
+

Keine Zeitpläne

+
+
+
+
+
+ + + +
+
+ +
+
+
+

📁 Grobsortierung

+ +
+
+
+

Keine Grobsortierung konfiguriert

+
+
+
+
+ + +
+
+
+

Aktion

+
+
+
+ +
+
+
+ +
+
+

Verarbeitungs-Log

+
+
+
+

Noch keine Verarbeitung durchgeführt

+
+
+
+
+ + +
+
+
+

⏰ Status

+ +
+
+
+

Status wird geladen...

+
+
+
+ +
+
+

Zeitpläne

+ +
+
+
+

Keine Zeitpläne

+
+
+
+
+
+
+ + +
+
+ +
+
+
+

📑 Sortier-Regeln

+
+ + + + +
@@ -97,15 +217,23 @@
+
- -
- + +
+
+
+

Aktion

+
+
+
+ +
+
-

Verarbeitete Dateien

@@ -116,44 +244,132 @@
-
-
+ - -
-
-

⏰ Zeitpläne

-

Automatische Ausführung von Mail-Abruf und Sortierung

-
- -
- + +
-

Status-Übersicht

- +

⏰ Status

+
-
+

Status wird geladen...

-

Zeitpläne

- +
-
-

Keine Zeitpläne konfiguriert

+
+

Keine Zeitpläne

-
-
+
+ + + + +
+
+ +
+
+
+

🗄️ DB-Server

+ +
+
+
+

Keine Server konfiguriert

+
+
+
+ +
+
+

📊 Datenbanken

+ +
+
+
+

Keine Datenbanken konfiguriert

+
+
+
+
+ + +
+
+
+

Aktion

+
+
+
+ +
+
+
+ +
+
+

Backup-Log

+
+
+
+

Noch keine Backups durchgeführt

+
+
+
+ +
+
+

Vorhandene Backups

+
+
+
+

Keine Backups vorhanden

+
+
+
+
+ + +
+
+
+

⏰ Status

+ +
+
+
+

Status wird geladen...

+
+
+
+ +
+
+

Zeitpläne

+ +
+
+
+

Keine Zeitpläne

+
+
+
+
+
@@ -266,7 +482,7 @@ - + + + + + + +