diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/Docker - Image/V1.9.tar b/Docker - Image/V1.9.tar new file mode 100755 index 0000000..b8cf395 Binary files /dev/null and b/Docker - Image/V1.9.tar differ diff --git a/README.md b/README.md deleted file mode 100644 index f6b377c..0000000 --- a/README.md +++ /dev/null @@ -1,149 +0,0 @@ -<<<<<<< HEAD -# docker.dateiverwaltung - -======= -# Dateiverwaltung - -Modulares Dokumenten-Management-System für automatische Verarbeitung, Sortierung und Benennung von Dokumenten. - -## Features - -- **Mail-Abruf**: Automatischer Abruf von Attachments aus IMAP-Postfächern -- **PDF-Verarbeitung**: Text-Extraktion und OCR für gescannte Dokumente -- **ZUGFeRD-Erkennung**: Automatische Erkennung und separate Ablage von ZUGFeRD-Rechnungen -- **Regel-Engine**: Flexible, erweiterbare Regeln für Erkennung und Benennung -- **Pipeline-System**: Mehrere unabhängige Pipelines (Firma, Privat, etc.) - -## Schnellstart - -### Mit Docker (empfohlen) - -```bash -# Image bauen und starten -docker-compose up -d - -# Logs ansehen -docker-compose logs -f - -# Stoppen -docker-compose down -``` - -Dann im Browser öffnen: http://localhost:8000 - -### Ohne Docker - -```bash -# Virtuelle Umgebung erstellen -cd backend -python -m venv venv -source venv/bin/activate # Linux/Mac -# oder: venv\Scripts\activate # Windows - -# Abhängigkeiten installieren -pip install -r requirements.txt - -# Starten -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -## Benennungsschema - -### Wiederkehrende Dokumente (Rechnungen) -``` -{Jahr}.{Monat}.{Tag} - {Kategorie} - {Ersteller} - {Dokumentennummer} - {Sammelbegriff} - {Preis} EUR.pdf - -Beispiel: -2026.02.01 - Rechnung - Sonepar - 10023934 - Material - 1600 EUR.pdf -``` - -### Einmalige Dokumente (Verträge, Zeugnisse) -``` -{Typ} - {Aussteller} - {Beschreibung} - {Jahr}.pdf - -Beispiel: -Zeugnis - Schule X - Grundschulzeugnis - 2026.pdf -``` - -## Projektstruktur - -``` -dateiverwaltung/ -├── backend/ -│ ├── app/ -│ │ ├── models/ # Datenbank-Modelle -│ │ ├── modules/ # Kernmodule (Mail, PDF, Sorter) -│ │ ├── routes/ # API Endpoints -│ │ ├── services/ # Business Logic -│ │ └── main.py # FastAPI App -│ └── requirements.txt -├── frontend/ -│ ├── static/ -│ │ ├── css/ -│ │ └── js/ -│ └── templates/ -├── data/ # Persistente Daten -│ ├── inbox/ # Neue Dateien -│ ├── processed/ # Verarbeitete Dateien -│ ├── archive/ # Sortierte Dateien -│ └── zugferd/ # ZUGFeRD-Rechnungen -├── regeln/ # Regel-Beispiele -├── docker-compose.yml -├── Dockerfile -└── README.md -``` - -## Module - -### Mail-Fetcher -Holt Attachments aus IMAP-Postfächern mit konfigurierbaren Filtern: -- Dateitypen (.pdf, .jpg, etc.) -- Maximale Größe -- IMAP-Ordner - -### PDF-Processor -- **Text-Extraktion**: Mit pdfplumber/pypdf -- **OCR**: Mit ocrmypdf + Tesseract (deutsch) -- **ZUGFeRD**: Erkennung via factur-x Library - -### Sorter -Regelbasierte Erkennung und Benennung: -- Pattern-Matching (Text, Absender, Dateiname) -- Regex-basierte Feldextraktion -- Konfigurierbares Namensschema - -## API Endpoints - -| Methode | Endpoint | Beschreibung | -|---------|----------|--------------| -| GET | /api/pipelines | Alle Pipelines | -| POST | /api/pipelines | Neue Pipeline | -| POST | /api/pipelines/{id}/run | Pipeline ausführen | -| GET | /api/pipelines/{id}/mail-configs | Mail-Konfigurationen | -| POST | /api/pipelines/{id}/mail-configs | Postfach hinzufügen | -| GET | /api/pipelines/{id}/regeln | Sortier-Regeln | -| POST | /api/pipelines/{id}/regeln | Regel hinzufügen | -| POST | /api/regeln/test | Regel testen | -| GET | /api/dokumente | Verarbeitete Dokumente | -| GET | /api/stats | Statistiken | - -## Regex-Beispiele für Regeln - -```yaml -# Datum (DD.MM.YYYY) -(\d{2}[./]\d{2}[./]\d{4}) - -# Rechnungsnummer -(?:Rechnungsnummer|Invoice)[:\s]*(\d+) - -# Betrag mit EUR -(?:Gesamtbetrag|Summe)[:\s]*([\d.,]+)\s*(?:EUR|€) -``` - -## Erweiterungen (geplant) - -- [ ] Claude API Integration für KI-Validierung -- [ ] Scheduler für automatische Ausführung -- [ ] Dolibarr-Integration -- [ ] Dashboard mit Grafiken ->>>>>>> 8585cc3 (Dateiverwaltung Email attachment abruf läuft) diff --git a/Dockerfile b/Source/Dockerfile old mode 100644 new mode 100755 similarity index 81% rename from Dockerfile rename to Source/Dockerfile index 1c60c27..52ac556 --- a/Dockerfile +++ b/Source/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ poppler-utils \ ghostscript \ libmagic1 \ + curl \ && rm -rf /var/lib/apt/lists/* # Arbeitsverzeichnis @@ -21,15 +22,10 @@ RUN pip install --no-cache-dir -r requirements.txt # Anwendung kopieren COPY backend/ ./backend/ COPY frontend/ ./frontend/ -COPY config/ ./config/ COPY regeln/ ./regeln/ -# Daten-Verzeichnis -RUN mkdir -p /app/data/inbox /app/data/processed /app/data/archive /app/data/zugferd - # Umgebungsvariablen ENV PYTHONPATH=/app -ENV DATABASE_URL=sqlite:////app/data/dateiverwaltung.db # Port EXPOSE 8000 diff --git a/Source/README.md b/Source/README.md new file mode 100755 index 0000000..745679f --- /dev/null +++ b/Source/README.md @@ -0,0 +1,150 @@ +# Dateiverwaltung + +Dokumenten-Management-System für automatische Verarbeitung, Sortierung und Benennung von Dokumenten. + +## Features + +- **Mail-Abruf**: Automatischer Abruf von Attachments aus IMAP-Postfächern +- **Grobsortierung**: Dateien nach Typ verschieben (PDF, Bilder, ZUGFeRD, Signiert) +- **PDF-Verarbeitung**: Text-Extraktion und OCR für gescannte Dokumente +- **ZUGFeRD-Erkennung**: Automatische Erkennung von ZUGFeRD-Rechnungen +- **Regel-Engine**: Flexible Regeln für Erkennung und automatische Benennung +- **Zeitpläne**: Automatische Ausführung per Scheduler + +## Deployment mit Portainer + +### 1. Image bauen oder pullen + +**Option A: Image aus tar laden** +```bash +docker load -i dateiverwaltung-image.tar +``` + +**Option B: Image selbst bauen** +```bash +docker build -t dateiverwaltung:latest . +``` + +### 2. Container in Portainer erstellen + +Neuen Container erstellen mit folgenden Einstellungen: + +**Image:** `dateiverwaltung:latest` + +**Port Mapping:** +| Host | Container | +|------|-----------| +| 8080 | 8000 | + +**Volumes:** +| Host | Container | Beschreibung | +|------|-----------|--------------| +| `/mnt/user/...` | `/mnt/user/...` | Zugriff auf NAS-Ordner | + +**Environment Variables:** + +| Variable | Beschreibung | Beispiel | +|----------|--------------|----------| +| `DATABASE_URL` | Datenbank-Verbindung (MariaDB/MySQL) | `mysql+pymysql://user:pass@host/db` | +| `TZ` | Zeitzone | `Europe/Berlin` | + +**Beispiel DATABASE_URL Formate:** + +``` +# MariaDB/MySQL +mysql+pymysql://benutzer:passwort@192.168.1.100:3306/dateiverwaltung + +# SQLite (nur für Tests) +sqlite:///dateiverwaltung.db +``` + +### 3. Datenbank vorbereiten + +Bei MariaDB/MySQL die Datenbank vorher erstellen: + +```sql +CREATE DATABASE dateiverwaltung CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'dateiverwaltung'@'%' IDENTIFIED BY 'sicheres_passwort'; +GRANT ALL PRIVILEGES ON dateiverwaltung.* TO 'dateiverwaltung'@'%'; +FLUSH PRIVILEGES; +``` + +Die Tabellen werden beim ersten Start automatisch erstellt. + +### 4. Container starten + +Nach dem Start ist die Web-Oberfläche erreichbar unter: +``` +http://:8080 +``` + +## Docker Compose (Alternative) + +```yaml +version: '3.8' + +services: + dateiverwaltung: + image: dateiverwaltung:latest + container_name: dateiverwaltung + restart: unless-stopped + ports: + - "8080:8000" + volumes: + - /mnt:/mnt + environment: + - TZ=Europe/Berlin + - DATABASE_URL=mysql+pymysql://user:pass@db-host/dateiverwaltung +``` + +## Konfiguration + +Alle Einstellungen werden in der Datenbank gespeichert: + +- **Postfächer**: IMAP-Server, Zugangsdaten, Filter +- **Quell-Ordner**: Pfade, Dateitypen, ZUGFeRD/Signiert-Behandlung +- **Sortier-Regeln**: Erkennungsmuster, Extraktion, Benennungsschema +- **Zeitpläne**: Automatische Ausführung + +## Module + +### Mail-Fetcher +Holt Attachments aus IMAP-Postfächern: +- Filter nach Dateitypen und Größe +- Nur ungelesene oder alle Mails +- Alle IMAP-Ordner durchsuchen + +### Grobsortierung +Sortiert Dateien nach Typ: +- Konfigurierbare Dateitypen +- ZUGFeRD-Erkennung +- Signierte PDF-Erkennung +- Optional: Direkt verschieben ohne Regeln + +### PDF-Processor +- Text-Extraktion mit pdfplumber/pypdf +- OCR mit ocrmypdf + Tesseract (deutsch) +- ZUGFeRD-Erkennung via factur-x + +### Sortier-Regeln +- Keyword-basierte Erkennung +- Regex für Feldextraktion (Datum, Betrag, Nummer) +- Flexibles Benennungsschema + +## Benennungsschema Beispiele + +``` +# Rechnungen +{datum} - {firma} - Rechnung {nummer}.pdf +-> 2026-02-01 - Amazon - Rechnung 123456.pdf + +# Mit Betrag +{datum} - {firma} - {betrag} EUR.pdf +-> 2026-02-01 - Amazon - 49.99 EUR.pdf +``` + +## Systemanforderungen + +- Docker oder Python 3.11+ +- MariaDB/MySQL (empfohlen) oder SQLite +- Für OCR: tesseract-ocr, ocrmypdf diff --git a/backend/app/__init__.py b/Source/backend/app/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/__init__.py rename to Source/backend/app/__init__.py diff --git a/Source/backend/app/config.py b/Source/backend/app/config.py new file mode 100755 index 0000000..fededa2 --- /dev/null +++ b/Source/backend/app/config.py @@ -0,0 +1,25 @@ +"""Zentrale Konfiguration""" +import os +from pathlib import Path + +# Basis-Pfade +BASE_DIR = Path(__file__).parent.parent.parent +REGELN_DIR = BASE_DIR / "regeln" + +# Datenbank (Default SQLite nur für lokale Entwicklung) +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///dateiverwaltung.db") + +# Fallback-Ordner (werden nur verwendet wenn kein Zielordner angegeben) +# Diese werden NICHT automatisch erstellt - nur als Fallback-Pfade definiert +DATA_DIR = Path("/app/data") # Container-interner Pfad +INBOX_DIR = DATA_DIR / "inbox" +PROCESSED_DIR = DATA_DIR / "processed" +ARCHIVE_DIR = DATA_DIR / "archive" +ZUGFERD_DIR = DATA_DIR / "zugferd" + +# OCR Einstellungen +OCR_LANGUAGE = "deu" # Deutsch +OCR_DPI = 300 + +# Nur Regeln-Ordner erstellen (wird per Volume gemountet) +REGELN_DIR.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/main.py b/Source/backend/app/main.py old mode 100644 new mode 100755 similarity index 69% rename from backend/app/main.py rename to Source/backend/app/main.py index 811f0de..ed0c380 --- a/backend/app/main.py +++ b/Source/backend/app/main.py @@ -2,6 +2,7 @@ Dateiverwaltung - Modulares Dokumenten-Management-System Hauptanwendung mit FastAPI """ +from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -13,18 +14,39 @@ import logging from .models import init_db from .routes.api import router as api_router from .config import BASE_DIR +from .services.scheduler_service import init_scheduler, shutdown_scheduler # Logging konfigurieren logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle-Management für die App""" + # Startup + print("=== Dateiverwaltung startet ===", flush=True) + init_db() + print("Datenbank initialisiert", flush=True) + init_scheduler() + print("Scheduler initialisiert", flush=True) + + yield + + # Shutdown + shutdown_scheduler() + print("Scheduler beendet", flush=True) + # App erstellen app = FastAPI( title="Dateiverwaltung", description="Modulares Dokumenten-Management-System", - version="1.0.0" + version="1.0.0", + lifespan=lifespan ) # Statische Dateien @@ -38,13 +60,6 @@ templates = Jinja2Templates(directory=frontend_dir / "templates") app.include_router(api_router) -@app.on_event("startup") -async def startup(): - """Initialisierung beim Start""" - init_db() - logging.info("Datenbank initialisiert") - - @app.get("/", response_class=HTMLResponse) async def index(request: Request): """Hauptseite""" diff --git a/Source/backend/app/models/__init__.py b/Source/backend/app/models/__init__.py new file mode 100755 index 0000000..ca023cf --- /dev/null +++ b/Source/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +from .database import ( + Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, Zeitplan, + VerarbeiteteMail, init_db, get_db, SessionLocal +) diff --git a/Source/backend/app/models/database.py b/Source/backend/app/models/database.py new file mode 100755 index 0000000..41312d7 --- /dev/null +++ b/Source/backend/app/models/database.py @@ -0,0 +1,276 @@ +"""Datenbank-Modelle - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung""" +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, JSON, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from datetime import datetime + +from sqlalchemy import event +from ..config import DATABASE_URL + +# Datenbank-Engine erstellen (SQLite oder MariaDB) +is_sqlite = DATABASE_URL.startswith("sqlite") + +if is_sqlite: + # SQLite mit WAL-Modus und Timeout für bessere Concurrency + engine = create_engine( + DATABASE_URL, + echo=False, + connect_args={"check_same_thread": False, "timeout": 30} + ) + + # WAL-Modus aktivieren für bessere gleichzeitige Zugriffe + @event.listens_for(engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=30000") + cursor.close() +else: + # MariaDB/MySQL + engine = create_engine( + DATABASE_URL, + echo=False, + pool_pre_ping=True, + pool_recycle=3600 + ) + +SessionLocal = sessionmaker(bind=engine) +Base = declarative_base() + + +# ============ BEREICH 1: Mail-Abruf ============ + +class Postfach(Base): + """IMAP-Postfach Konfiguration""" + __tablename__ = "postfaecher" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + + # IMAP + imap_server = Column(String(255), nullable=False) + imap_port = Column(Integer, default=993) + email = Column(String(255), nullable=False) + passwort = Column(String(255), nullable=False) + ordner = Column(String(100), default="INBOX") + alle_ordner = Column(Boolean, default=False) # Alle IMAP-Ordner durchsuchen + nur_ungelesen = Column(Boolean, default=False) # Nur ungelesene Mails (False = alle) + + # Ziel + ziel_ordner = Column(String(500), nullable=False) + + # Filter + erlaubte_typen = Column(JSON, default=lambda: [".pdf"]) + max_groesse_mb = Column(Integer, default=25) + min_groesse_kb = Column(Integer, default=10) # Mindestgröße in KB (gegen Icons) + ab_datum = Column(DateTime) # Nur Mails ab diesem Datum verarbeiten + # Größenfilter pro Dateityp: {".pdf": {"min_kb": 10, "max_mb": 25}, ".jpg": {"min_kb": 50, "max_mb": 10}} + groessen_filter = Column(JSON, default=lambda: {}) + + # Status + aktiv = Column(Boolean, default=True) + letzter_abruf = Column(DateTime) + letzte_anzahl = Column(Integer, default=0) + + +# ============ BEREICH 2: Datei-Sortierung ============ + +class QuellOrdner(Base): + """Ordner der nach Dateien gescannt wird""" + __tablename__ = "quell_ordner" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + pfad = Column(String(500), nullable=False) + ziel_ordner = Column(String(500), nullable=False) + rekursiv = Column(Boolean, default=True) # Unterordner einschließen + dateitypen = Column(JSON, default=lambda: [".pdf", ".jpg", ".jpeg", ".png", ".tiff"]) + # ZUGFeRD-Behandlung: "separieren", "regel", "normal", "ignorieren" + zugferd_behandlung = Column(String(20), default="separieren") + # Signierte PDFs: "normal", "separieren", "regel", "ignorieren" + signiert_behandlung = Column(String(20), default="normal") + aktiv = Column(Boolean, default=True) + # NEU: Direkt verschieben ohne Regelprüfung + direkt_verschieben = Column(Boolean, default=False) + # OCR-Optionen + ocr_aktivieren = Column(Boolean, default=True) # OCR für gescannte PDFs + original_sichern = Column(String(500)) # Ordner für Original-Backup (vor OCR) + + # Status (wie bei Postfächern) + letzte_verarbeitung = Column(DateTime) + letzte_anzahl = Column(Integer, default=0) + + +class SortierRegel(Base): + """Regeln für Datei-Erkennung und Benennung""" + __tablename__ = "sortier_regeln" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + prioritaet = Column(Integer, default=100) + aktiv = Column(Boolean, default=True) + + # Erkennungsmuster (JSON mit keywords, text_match, text_regex, etc.) + # NEU: Auch negative Muster möglich (keywords_nicht, text_not_match) + muster = Column(JSON, default=dict) + + # Extraktion (JSON mit datum, betrag, nummer, firma, etc.) + extraktion = Column(JSON, default=dict) + + # Ausgabe + schema = Column(String(500), default="{datum} - Dokument.pdf") + unterordner = Column(String(100)) # Optional: Unterordner im Ziel + + # NEU: Fallback-Regel (greift wenn keine andere Regel passt) + ist_fallback = Column(Boolean, default=False) + + # Freie Ordner (zusätzlich zu den zugewiesenen Quell-Ordnern) + freie_ordner = Column(JSON, default=list) + + # Ziel-Ordner für diese Regel (optional, überschreibt Quell-Ordner Ziel) + ziel_ordner = Column(String(500)) + + # Nur umbenennen, nicht verschieben (Dateien bleiben im Quellordner) + nur_umbenennen = Column(Boolean, default=False) + + +class OrdnerRegel(Base): + """Verknüpfung zwischen Quell-Ordnern und Sortier-Regeln""" + __tablename__ = "ordner_regeln" + + id = Column(Integer, primary_key=True) + ordner_id = Column(Integer, ForeignKey("quell_ordner.id", ondelete="CASCADE"), nullable=False) + regel_id = Column(Integer, ForeignKey("sortier_regeln.id", ondelete="CASCADE"), nullable=False) + + +class Zeitplan(Base): + """Scheduler-Konfiguration für automatische Ausführung""" + __tablename__ = "zeitplaene" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + aktiv = Column(Boolean, default=True) + + # Was wird ausgeführt + typ = Column(String(50), nullable=False) # "mail_abruf", "grobsortierung", "sortierregeln" + 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) + + # Zeitplan-Intervall + intervall = Column(String(20), nullable=False) # "stündlich", "täglich", "wöchentlich", "monatlich" + stunde = Column(Integer, default=6) # Uhrzeit (0-23) + minute = Column(Integer, default=0) # Minute (0-59) + wochentag = Column(Integer) # 0=Montag, 6=Sonntag (für wöchentlich) + monatstag = Column(Integer) # 1-28 (für monatlich) + + # Status + letzte_ausfuehrung = Column(DateTime) + naechste_ausfuehrung = Column(DateTime) + letzter_status = Column(String(50)) # "erfolg", "fehler" + letzte_meldung = Column(Text) + + erstellt_am = Column(DateTime, default=datetime.utcnow) + + +class VerarbeiteteMail(Base): + """Tracking welche Mails bereits verarbeitet wurden""" + __tablename__ = "verarbeitete_mails" + + id = Column(Integer, primary_key=True) + postfach_id = Column(Integer, nullable=False) + message_id = Column(String(500), nullable=False) # Email Message-ID Header + ordner = Column(String(200)) # IMAP Ordner + betreff = Column(String(500)) + absender = Column(String(255)) + anzahl_attachments = Column(Integer, default=0) + verarbeitet_am = Column(DateTime, default=datetime.utcnow) + + +class VerarbeiteteDatei(Base): + """Log verarbeiteter Dateien""" + __tablename__ = "verarbeitete_dateien" + + id = Column(Integer, primary_key=True) + original_pfad = Column(String(1000)) + original_name = Column(String(500)) + neuer_pfad = Column(String(1000)) + neuer_name = Column(String(500)) + + ist_zugferd = Column(Boolean, default=False) + ocr_durchgefuehrt = Column(Boolean, default=False) + + status = Column(String(50)) # sortiert, zugferd, fehler, keine_regel + fehler = Column(Text) + + extrahierte_daten = Column(JSON) + verarbeitet_am = Column(DateTime, default=datetime.utcnow) + + +def migrate_db(): + """Fügt fehlende Spalten hinzu ohne Daten zu löschen""" + from sqlalchemy import inspect, text + + inspector = inspect(engine) + + # Migrations-Definitionen: {tabelle: {spalte: sql_typ}} + migrations = { + "postfaecher": { + "alle_ordner": "BOOLEAN DEFAULT 0", + "nur_ungelesen": "BOOLEAN DEFAULT 0", + "min_groesse_kb": "INTEGER DEFAULT 10", + "ab_datum": "DATETIME", + "groessen_filter": "JSON" + }, + "quell_ordner": { + "rekursiv": "BOOLEAN DEFAULT 1", + "dateitypen": "JSON", + "zugferd_behandlung": "VARCHAR(20) DEFAULT 'separieren'", + "signiert_behandlung": "VARCHAR(20) DEFAULT 'normal'", + "direkt_verschieben": "BOOLEAN DEFAULT 0", + "ocr_aktivieren": "BOOLEAN DEFAULT 1", + "original_sichern": "VARCHAR(500)", + "letzte_verarbeitung": "DATETIME", + "letzte_anzahl": "INTEGER DEFAULT 0" + }, + "sortier_regeln": { + "ist_fallback": "BOOLEAN DEFAULT 0", + "freie_ordner": "JSON", + "ziel_ordner": "VARCHAR(500)", + "nur_umbenennen": "BOOLEAN DEFAULT 0" + }, + "zeitplaene": { + "regel_id": "INTEGER" + } + } + + with engine.connect() as conn: + for table, columns in migrations.items(): + if table not in inspector.get_table_names(): + continue + + existing = [col["name"] for col in inspector.get_columns(table)] + + for col_name, col_type in columns.items(): + if col_name not in existing: + try: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_type}")) + conn.commit() + print(f"Migration: {table}.{col_name} hinzugefügt") + except Exception as e: + print(f"Migration übersprungen: {table}.{col_name} - {e}") + + +def init_db(): + """Datenbank initialisieren""" + Base.metadata.create_all(engine) + migrate_db() + + +def get_db(): + """Database Session Generator""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/modules/__init__.py b/Source/backend/app/modules/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/modules/__init__.py rename to Source/backend/app/modules/__init__.py diff --git a/backend/app/modules/extraktoren.py b/Source/backend/app/modules/extraktoren.py old mode 100644 new mode 100755 similarity index 94% rename from backend/app/modules/extraktoren.py rename to Source/backend/app/modules/extraktoren.py index b5089b0..07d6881 --- a/backend/app/modules/extraktoren.py +++ b/Source/backend/app/modules/extraktoren.py @@ -12,22 +12,22 @@ logger = logging.getLogger(__name__) # ============ DATUM ============ DATUM_MUSTER = [ - # Mit Kontext (zuverlässiger) - {"regex": r"Rechnungsdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"}, - {"regex": r"Belegdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"}, - {"regex": r"Datum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"}, - {"regex": r"Date[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"}, - {"regex": r"vom[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"}, + # Mit Kontext (zuverlässiger) - akzeptiert 1 oder 2 Ziffern für Tag/Monat + {"regex": r"Rechnungsdatum[:\s]*(\d{1,2})[./](\d{1,2})[./](\d{4})", "order": "dmy"}, + {"regex": r"Belegdatum[:\s]*(\d{1,2})[./](\d{1,2})[./](\d{4})", "order": "dmy"}, + {"regex": r"Datum[:\s]*(\d{1,2})[./](\d{1,2})[./](\d{4})", "order": "dmy"}, + {"regex": r"Date[:\s]*(\d{1,2})[./](\d{1,2})[./](\d{4})", "order": "dmy"}, + {"regex": r"vom[:\s]*(\d{1,2})[./](\d{1,2})[./](\d{4})", "order": "dmy"}, - # ISO Format + # ISO Format (immer 2 Ziffern) {"regex": r"(\d{4})-(\d{2})-(\d{2})", "order": "ymd"}, - # Deutsches Format ohne Kontext - {"regex": r"(\d{2})\.(\d{2})\.(\d{4})", "order": "dmy"}, - {"regex": r"(\d{2})/(\d{2})/(\d{4})", "order": "dmy"}, + # Deutsches Format ohne Kontext - akzeptiert 1 oder 2 Ziffern + {"regex": r"(\d{1,2})\.(\d{1,2})\.(\d{4})", "order": "dmy"}, + {"regex": r"(\d{1,2})/(\d{1,2})/(\d{4})", "order": "dmy"}, # Amerikanisches Format - {"regex": r"(\d{2})/(\d{2})/(\d{4})", "order": "mdy"}, + {"regex": r"(\d{1,2})/(\d{1,2})/(\d{4})", "order": "mdy"}, # Ausgeschriebene Monate {"regex": r"(\d{1,2})\.\s*(Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*(\d{4})", "order": "dMy"}, diff --git a/backend/app/modules/mail_fetcher.py b/Source/backend/app/modules/mail_fetcher.py old mode 100644 new mode 100755 similarity index 76% rename from backend/app/modules/mail_fetcher.py rename to Source/backend/app/modules/mail_fetcher.py index 61cc0be..a213d42 --- a/backend/app/modules/mail_fetcher.py +++ b/Source/backend/app/modules/mail_fetcher.py @@ -102,6 +102,8 @@ class MailFetcher: ergebnisse = [] erlaubte_typen = self.config.get("erlaubte_typen", [".pdf"]) max_groesse = self.config.get("max_groesse_mb", 25) * 1024 * 1024 + min_groesse = self.config.get("min_groesse_kb", 10) * 1024 # Mindestgröße in Bytes + groessen_filter = self.config.get("groessen_filter", {}) # Pro Dateityp: {".pdf": {"min_kb": 10, "max_mb": 25}} bereits_verarbeitet = bereits_verarbeitet or set() # Ordner bestimmen @@ -113,14 +115,15 @@ class MailFetcher: for ordner in ordner_liste: ergebnisse.extend(self._fetch_from_folder( - ordner, ziel, erlaubte_typen, max_groesse, - nur_ungelesen, markiere_gelesen, bereits_verarbeitet + ordner, ziel, erlaubte_typen, max_groesse, min_groesse, + groessen_filter, nur_ungelesen, markiere_gelesen, bereits_verarbeitet )) return ergebnisse def _fetch_from_folder(self, ordner: str, ziel: Path, erlaubte_typen: List[str], max_groesse: int, + min_groesse: int, groessen_filter: dict, nur_ungelesen: bool, markiere_gelesen: bool, bereits_verarbeitet: set) -> List[Dict]: """Holt Attachments aus einem einzelnen Ordner""" @@ -129,9 +132,25 @@ class MailFetcher: try: # Ordner auswählen status, _ = self.connection.select(ordner) + if status != "OK": + logger.debug(f"Ordner nicht zugreifbar: {ordner}") + return [] - # Suche nach Mails - search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" + # Suche nach Mails mit optionalem Datumfilter + ab_datum = self.config.get("ab_datum") + if nur_ungelesen: + search_criteria = "(UNSEEN)" + elif ab_datum: + # IMAP SINCE erwartet Format: dd-Mon-yyyy + try: + if isinstance(ab_datum, str): + ab_datum = datetime.fromisoformat(ab_datum.replace("Z", "+00:00")) + datum_str = ab_datum.strftime("%d-%b-%Y") + search_criteria = f'(SINCE {datum_str})' + except: + search_criteria = "ALL" + else: + search_criteria = "ALL" status, messages = self.connection.search(None, search_criteria) if status != "OK": @@ -181,8 +200,17 @@ class MailFetcher: if not payload: continue - if len(payload) > max_groesse: - logger.warning(f"Überspringe {filename}: Zu groß ({len(payload)} bytes)") + # Größenlimits: Pro Dateityp oder global + typ_filter = groessen_filter.get(datei_endung, {}) + typ_max = typ_filter.get("max_mb", max_groesse / (1024 * 1024)) * 1024 * 1024 + typ_min = typ_filter.get("min_kb", min_groesse / 1024) * 1024 + + if len(payload) > typ_max: + logger.warning(f"Überspringe {filename}: Zu groß ({len(payload)} bytes, max {typ_max})") + continue + + if len(payload) < typ_min: + logger.debug(f"Überspringe {filename}: Zu klein ({len(payload)} bytes, min {typ_min})") continue # Speichern @@ -219,8 +247,11 @@ class MailFetcher: logger.error(f"Fehler bei Mail {mail_id}: {e}") continue + except imaplib.IMAP4.error as e: + # IMAP-Fehler beim Ordner-Zugriff (z.B. nicht existent, keine Berechtigung) + logger.debug(f"Ordner übersprungen: {ordner} - {e}") except Exception as e: - logger.error(f"Fehler beim Abrufen: {e}") + logger.error(f"Fehler beim Abrufen aus {ordner}: {e}") return ergebnisse @@ -269,6 +300,8 @@ class MailFetcher: erlaubte_typen = self.config.get("erlaubte_typen", [".pdf"]) max_groesse = self.config.get("max_groesse_mb", 25) * 1024 * 1024 + min_groesse = self.config.get("min_groesse_kb", 10) * 1024 + groessen_filter = self.config.get("groessen_filter", {}) # Pro Dateityp bereits_verarbeitet = bereits_verarbeitet or set() # Ordner bestimmen @@ -283,7 +316,25 @@ class MailFetcher: try: status, _ = self.connection.select(ordner) - search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" + if status != "OK": + # Ordner konnte nicht geöffnet werden (nicht zugreifbar) + logger.debug(f"Ordner nicht zugreifbar: {ordner}") + continue + + # Suche mit optionalem Datumfilter + ab_datum = self.config.get("ab_datum") + if nur_ungelesen: + search_criteria = "(UNSEEN)" + elif ab_datum: + try: + if isinstance(ab_datum, str): + ab_datum = datetime.fromisoformat(ab_datum.replace("Z", "+00:00")) + datum_str = ab_datum.strftime("%d-%b-%Y") + search_criteria = f'(SINCE {datum_str})' + except: + search_criteria = "ALL" + else: + search_criteria = "ALL" status, messages = self.connection.search(None, search_criteria) if status != "OK": @@ -326,8 +377,17 @@ class MailFetcher: if not payload: continue - if len(payload) > max_groesse: - yield {"type": "skip", "datei": filename, "grund": "zu groß"} + # Größenlimits: Pro Dateityp oder global + typ_filter = groessen_filter.get(datei_endung, {}) + typ_max = typ_filter.get("max_mb", max_groesse / (1024 * 1024)) * 1024 * 1024 + typ_min = typ_filter.get("min_kb", min_groesse / 1024) * 1024 + + if len(payload) > typ_max: + yield {"type": "skip", "datei": filename, "grund": f"zu groß ({len(payload)//1024}KB > {int(typ_max//1024)}KB)"} + continue + + if len(payload) < typ_min: + yield {"type": "skip", "datei": filename, "grund": f"zu klein ({len(payload)//1024}KB < {int(typ_min//1024)}KB)"} continue timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -360,8 +420,15 @@ class MailFetcher: yield {"type": "fehler", "nachricht": f"Mail-Fehler: {str(e)[:100]}"} continue + except imaplib.IMAP4.error as e: + # IMAP-Fehler beim Ordner-Zugriff (z.B. nicht existent, keine Berechtigung) + # Diese Fehler sind normal bei "alle_ordner" und werden nur geloggt + logger.debug(f"Ordner übersprungen (nicht zugreifbar): {ordner} - {e}") + continue except Exception as e: - yield {"type": "fehler", "nachricht": f"Ordner-Fehler {ordner}: {str(e)[:100]}"} + # Andere unerwartete Fehler werden als Warnung gemeldet + logger.warning(f"Ordner-Fehler {ordner}: {e}") + continue def test_connection(self) -> Dict: """Testet die Verbindung und gibt Status zurück""" diff --git a/Source/backend/app/modules/pdf_processor.py b/Source/backend/app/modules/pdf_processor.py new file mode 100755 index 0000000..ade3042 --- /dev/null +++ b/Source/backend/app/modules/pdf_processor.py @@ -0,0 +1,510 @@ +""" +PDF-Processor Modul +Text-Extraktion, OCR und ZUGFeRD-Erkennung +""" +import subprocess +from pathlib import Path +from typing import Dict, Optional, Tuple +import logging +import re + +logger = logging.getLogger(__name__) + +# Versuche Libraries zu importieren +try: + import pdfplumber + PDFPLUMBER_AVAILABLE = True +except ImportError: + PDFPLUMBER_AVAILABLE = False + logger.warning("pdfplumber nicht installiert") + +try: + from pypdf import PdfReader + PYPDF_AVAILABLE = True +except ImportError: + PYPDF_AVAILABLE = False + logger.warning("pypdf nicht installiert") + + +class PDFProcessor: + """Verarbeitet PDFs: Text-Extraktion, OCR, ZUGFeRD-Erkennung""" + + def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300): + self.ocr_language = ocr_language + self.ocr_dpi = ocr_dpi + + def verarbeite(self, pdf_pfad: str, ocr_erlaubt: bool = True, original_backup_pfad: str = None) -> Dict: + """ + Vollständige PDF-Verarbeitung + + Args: + pdf_pfad: Pfad zur PDF + ocr_erlaubt: OCR durchführen wenn nötig + original_backup_pfad: Ordner für Original-Backup vor OCR + + Returns: + Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt + """ + pfad = Path(pdf_pfad) + if not pfad.exists(): + return {"fehler": f"Datei nicht gefunden: {pdf_pfad}"} + + ergebnis = { + "pfad": str(pfad), + "text": "", + "ist_zugferd": False, + "zugferd_xml": None, + "ist_signiert": False, + "hat_text": False, + "ocr_durchgefuehrt": False, + "original_gesichert": None, + "seiten": 0 + } + + # 1. ZUGFeRD prüfen + zugferd_result = self.pruefe_zugferd(pdf_pfad) + ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"] + ergebnis["zugferd_xml"] = zugferd_result.get("xml") + + # 2. Signatur prüfen + ergebnis["ist_signiert"] = self.pruefe_signatur(pdf_pfad) + + # 3. Text extrahieren + text, seiten = self.extrahiere_text(pdf_pfad) + ergebnis["text"] = text + ergebnis["seiten"] = seiten + ergebnis["hat_text"] = bool(text and len(text.strip()) > 50) + + # 4. OCR falls kein Text (aber NICHT bei ZUGFeRD oder signierten PDFs!) + if ocr_erlaubt and not ergebnis["hat_text"] and not ergebnis["ist_zugferd"] and not ergebnis["ist_signiert"]: + # Zusätzliche Sicherheitsprüfung: Attachments auf ZUGFeRD prüfen + # (falls die normale ZUGFeRD-Erkennung fehlgeschlagen ist) + hat_zugferd_attachment = self._hat_zugferd_attachment(pdf_pfad) + if hat_zugferd_attachment: + ergebnis["ist_zugferd"] = True + logger.info(f"ZUGFeRD-Attachment gefunden, überspringe OCR: {pfad.name}") + else: + logger.info(f"Kein Text gefunden, starte OCR für {pfad.name}") + + # Original sichern falls gewünscht + if original_backup_pfad: + backup_pfad = self.sichere_original(pdf_pfad, original_backup_pfad) + if backup_pfad: + ergebnis["original_gesichert"] = backup_pfad + logger.info(f"Original gesichert: {backup_pfad}") + + ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad) + if ocr_erfolg: + ergebnis["text"] = ocr_text + ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50) + ergebnis["ocr_durchgefuehrt"] = True + + return ergebnis + + def sichere_original(self, pdf_pfad: str, backup_ordner: str) -> Optional[str]: + """Sichert das Original-PDF vor OCR""" + try: + import shutil + pfad = Path(pdf_pfad) + backup_dir = Path(backup_ordner) + backup_dir.mkdir(parents=True, exist_ok=True) + + # Eindeutigen Namen generieren + backup_pfad = backup_dir / pfad.name + counter = 1 + while backup_pfad.exists(): + backup_pfad = backup_dir / f"{pfad.stem}_{counter}{pfad.suffix}" + counter += 1 + + shutil.copy2(str(pfad), str(backup_pfad)) + return str(backup_pfad) + except Exception as e: + logger.error(f"Original-Sicherung fehlgeschlagen: {e}") + return None + + def extrahiere_text(self, pdf_pfad: str) -> Tuple[str, int]: + """ + Extrahiert Text aus PDF + + Returns: + Tuple von (text, seitenanzahl) + """ + text_parts = [] + seiten = 0 + + # Methode 1: pdfplumber (besser für Tabellen) + if PDFPLUMBER_AVAILABLE: + try: + with pdfplumber.open(pdf_pfad) as pdf: + seiten = len(pdf.pages) + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + if text_parts: + return "\n\n".join(text_parts), seiten + except Exception as e: + logger.debug(f"pdfplumber Fehler: {e}") + + # Methode 2: pypdf (Fallback) + if PYPDF_AVAILABLE: + try: + reader = PdfReader(pdf_pfad) + seiten = len(reader.pages) + for page in reader.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + if text_parts: + return "\n\n".join(text_parts), seiten + except Exception as e: + logger.debug(f"pypdf Fehler: {e}") + + # Methode 3: pdftotext CLI (Fallback) + try: + result = subprocess.run( + ["pdftotext", "-layout", pdf_pfad, "-"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout, seiten + except Exception as e: + logger.debug(f"pdftotext Fehler: {e}") + + return "", seiten + + def pruefe_zugferd(self, pdf_pfad: str) -> Dict: + """ + Prüft ob PDF eine ZUGFeRD/Factur-X Rechnung ist + + Returns: + Dict mit: ist_zugferd, xml (falls vorhanden) + """ + ergebnis = {"ist_zugferd": False, "xml": None} + + # Methode 1: factur-x Library + try: + from facturx import get_facturx_xml_from_pdf + xml_bytes = get_facturx_xml_from_pdf(pdf_pfad) + if xml_bytes: + ergebnis["ist_zugferd"] = True + ergebnis["xml"] = xml_bytes.decode("utf-8") if isinstance(xml_bytes, bytes) else xml_bytes + logger.info(f"ZUGFeRD erkannt: {Path(pdf_pfad).name}") + return ergebnis + except ImportError: + logger.debug("factur-x nicht installiert") + except Exception as e: + logger.debug(f"factur-x Fehler: {e}") + + # Methode 2: Manuell nach eingebettetem ZUGFeRD-XML suchen + if PYPDF_AVAILABLE: + try: + reader = PdfReader(pdf_pfad) + + # ZUGFeRD/Factur-X XML-Dateinamen + zugferd_dateinamen = [ + "factur-x.xml", + "zugferd-invoice.xml", + "xrechnung.xml", + "ZUGFeRD-invoice.xml", + ] + + # Eingebettete Dateien aus dem Catalog extrahieren + if reader.trailer and "/Root" in reader.trailer: + root = reader.trailer["/Root"] + if hasattr(root, "get_object"): + root = root.get_object() + + if "/Names" in root: + names = root["/Names"] + if hasattr(names, "get_object"): + names = names.get_object() + + if "/EmbeddedFiles" in names: + embedded = names["/EmbeddedFiles"] + if hasattr(embedded, "get_object"): + embedded = embedded.get_object() + + # Namen-Array durchsuchen + if "/Names" in embedded: + names_array = embedded["/Names"] + # Format: [name1, ref1, name2, ref2, ...] + for i in range(0, len(names_array), 2): + if i < len(names_array): + dateiname = str(names_array[i]).lower() + if any(zf.lower() in dateiname for zf in zugferd_dateinamen): + ergebnis["ist_zugferd"] = True + logger.info(f"ZUGFeRD-XML gefunden: {Path(pdf_pfad).name}") + return ergebnis + except Exception as e: + logger.debug(f"ZUGFeRD-Prüfung Fehler: {e}") + + return ergebnis + + def pruefe_signatur(self, pdf_pfad: str) -> bool: + """ + Prüft ob PDF digital signiert ist + + Returns: + True wenn signiert, False sonst + """ + if not PYPDF_AVAILABLE: + return False + + try: + reader = PdfReader(pdf_pfad) + + # Methode 1: AcroForm mit Sig-Feldern prüfen + if "/AcroForm" in reader.trailer.get("/Root", {}): + root = reader.trailer["/Root"] + if "/AcroForm" in root: + acro_form = root["/AcroForm"] + if "/SigFlags" in acro_form: + sig_flags = acro_form["/SigFlags"] + if sig_flags and int(sig_flags) > 0: + logger.info(f"Signatur erkannt (SigFlags): {Path(pdf_pfad).name}") + return True + + # Methode 2: Nach /Sig Objekten suchen + for page in reader.pages: + if "/Annots" in page: + annots = page["/Annots"] + if annots: + for annot in annots: + try: + annot_obj = annot.get_object() if hasattr(annot, 'get_object') else annot + if annot_obj.get("/Subtype") == "/Widget": + ft = annot_obj.get("/FT") + if ft == "/Sig": + logger.info(f"Signatur erkannt (Annot): {Path(pdf_pfad).name}") + return True + except: + pass + + except Exception as e: + logger.debug(f"Signatur-Prüfung Fehler: {e}") + + return False + + def _hat_zugferd_attachment(self, pdf_pfad: str) -> bool: + """ + Prüft ob die PDF ein ZUGFeRD/Factur-X XML-Attachment enthält. + Zusätzliche Sicherheitsprüfung vor OCR. + + Returns: + True wenn ZUGFeRD-Attachment gefunden + """ + zugferd_dateinamen = [ + "factur-x.xml", + "zugferd-invoice.xml", + "xrechnung.xml", + "zugferd-invoice.xml", + ] + + attachments = self._extrahiere_attachments(pdf_pfad) + for dateiname, _ in attachments: + dateiname_lower = dateiname.lower() + if any(zf.lower() in dateiname_lower for zf in zugferd_dateinamen): + return True + + return False + + def _extrahiere_attachments(self, pdf_pfad: str) -> list: + """ + Extrahiert alle eingebetteten Dateien (Attachments) aus einer PDF + + Returns: + Liste von Tuples: (dateiname, daten_bytes) + """ + attachments = [] + + if not PYPDF_AVAILABLE: + return attachments + + try: + reader = PdfReader(pdf_pfad) + + if reader.trailer and "/Root" in reader.trailer: + root = reader.trailer["/Root"] + if hasattr(root, "get_object"): + root = root.get_object() + + if "/Names" in root: + names = root["/Names"] + if hasattr(names, "get_object"): + names = names.get_object() + + if "/EmbeddedFiles" in names: + embedded = names["/EmbeddedFiles"] + if hasattr(embedded, "get_object"): + embedded = embedded.get_object() + + if "/Names" in embedded: + names_array = embedded["/Names"] + # Format: [name1, filespec1, name2, filespec2, ...] + for i in range(0, len(names_array), 2): + if i + 1 < len(names_array): + dateiname = str(names_array[i]) + filespec = names_array[i + 1] + if hasattr(filespec, "get_object"): + filespec = filespec.get_object() + + if "/EF" in filespec: + ef = filespec["/EF"] + if hasattr(ef, "get_object"): + ef = ef.get_object() + + if "/F" in ef: + stream = ef["/F"] + if hasattr(stream, "get_object"): + stream = stream.get_object() + + daten = stream.get_data() + attachments.append((dateiname, daten)) + logger.debug(f"Attachment extrahiert: {dateiname}") + + except Exception as e: + logger.debug(f"Attachment-Extraktion Fehler: {e}") + + return attachments + + def _fuege_attachments_ein(self, pdf_pfad: str, attachments: list) -> bool: + """ + Fügt Attachments in eine PDF ein + + Args: + pdf_pfad: Pfad zur PDF + attachments: Liste von Tuples (dateiname, daten_bytes) + + Returns: + True bei Erfolg + """ + if not attachments: + return True + + if not PYPDF_AVAILABLE: + return False + + try: + from pypdf import PdfWriter + + # PDF lesen + reader = PdfReader(pdf_pfad) + writer = PdfWriter() + + # Alle Seiten kopieren + for page in reader.pages: + writer.add_page(page) + + # Metadaten kopieren + if reader.metadata: + writer.add_metadata(reader.metadata) + + # Attachments hinzufügen + for dateiname, daten in attachments: + writer.add_attachment(dateiname, daten) + logger.debug(f"Attachment eingefügt: {dateiname}") + + # Temporäre Datei schreiben + temp_pfad = Path(pdf_pfad).with_suffix(".attached.pdf") + with open(temp_pfad, "wb") as f: + writer.write(f) + + # Original ersetzen + Path(pdf_pfad).unlink() + temp_pfad.rename(pdf_pfad) + + return True + + except Exception as e: + logger.error(f"Attachment-Einfügung Fehler: {e}") + return False + + def fuehre_ocr_aus(self, pdf_pfad: str) -> Tuple[str, bool]: + """ + Führt OCR mit ocrmypdf durch, erhält dabei eingebettete Attachments + + Returns: + Tuple von (text, erfolg) + """ + pfad = Path(pdf_pfad) + temp_pfad = pfad.with_suffix(".ocr.pdf") + + # Attachments VOR OCR extrahieren (ocrmypdf verliert diese sonst) + attachments = self._extrahiere_attachments(pdf_pfad) + if attachments: + logger.info(f"{len(attachments)} Attachment(s) gesichert vor OCR") + + try: + # ocrmypdf ausführen + result = subprocess.run( + [ + "ocrmypdf", + "--language", self.ocr_language, + "--deskew", # Schräge Scans korrigieren + "--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 + ) + + if result.returncode == 0 and temp_pfad.exists(): + # Original mit OCR-Version ersetzen + pfad.unlink() + temp_pfad.rename(pfad) + + # Attachments wieder einfügen + if attachments: + if self._fuege_attachments_ein(str(pfad), attachments): + logger.info(f"Attachments wiederhergestellt nach OCR") + else: + logger.warning(f"Attachments konnten nicht wiederhergestellt werden") + + # Text aus OCR-PDF extrahieren + text, _ = self.extrahiere_text(str(pfad)) + return text, True + else: + logger.error(f"OCR Fehler: {result.stderr}") + if temp_pfad.exists(): + temp_pfad.unlink() + return "", False + + except subprocess.TimeoutExpired: + logger.error(f"OCR Timeout für {pfad.name}") + if temp_pfad.exists(): + temp_pfad.unlink() + return "", False + except FileNotFoundError: + logger.error("ocrmypdf nicht installiert") + return "", False + except Exception as e: + logger.error(f"OCR Fehler: {e}") + if temp_pfad.exists(): + temp_pfad.unlink() + return "", False + + def extrahiere_metadaten(self, pdf_pfad: str) -> Dict: + """Extrahiert PDF-Metadaten""" + metadaten = {} + + if PYPDF_AVAILABLE: + try: + reader = PdfReader(pdf_pfad) + if reader.metadata: + metadaten = { + "titel": reader.metadata.get("/Title", ""), + "autor": reader.metadata.get("/Author", ""), + "ersteller": reader.metadata.get("/Creator", ""), + "erstellt": reader.metadata.get("/CreationDate", ""), + } + except Exception as e: + logger.debug(f"Metadaten-Fehler: {e}") + + return metadaten diff --git a/backend/app/modules/sorter.py b/Source/backend/app/modules/sorter.py old mode 100644 new mode 100755 similarity index 70% rename from backend/app/modules/sorter.py rename to Source/backend/app/modules/sorter.py index d7e7654..7aa6f8c --- a/backend/app/modules/sorter.py +++ b/Source/backend/app/modules/sorter.py @@ -83,8 +83,9 @@ class Sorter: patterns = muster["text_match"] if isinstance(patterns, str): patterns = [patterns] + # Nur prüfen wenn Liste nicht leer for pattern in patterns: - if pattern.lower() not in text: + if pattern and pattern.lower() not in text: return False # text_match_any (mindestens einer muss enthalten sein) @@ -92,7 +93,8 @@ class Sorter: patterns = muster["text_match_any"] if isinstance(patterns, str): patterns = [patterns] - if not any(p.lower() in text for p in patterns): + # Nur prüfen wenn Liste nicht leer + if patterns and not any(p.lower() in text for p in patterns if p): return False # text_regex @@ -101,6 +103,33 @@ class Sorter: if not re.search(pattern, text, re.IGNORECASE): return False + # ============ NEGATIVE MUSTER (dürfen NICHT vorkommen) ============ + + # keywords_nicht (keines darf vorkommen) + 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 + + # text_not_match (keines darf enthalten sein) + if "text_not_match" in muster: + patterns = muster["text_not_match"] + if isinstance(patterns, str): + patterns = [patterns] + for pattern in patterns: + if pattern and pattern.lower() in text: + return False # Verbotenes Pattern gefunden + + # text_not_regex (Regex darf nicht matchen) + if "text_not_regex" in muster: + pattern = muster["text_not_regex"] + if re.search(pattern, text, re.IGNORECASE): + return False # Verbotenes Regex gefunden + return True def extrahiere_felder(self, regel: Dict, dokument_info: Dict) -> Dict[str, Any]: @@ -133,11 +162,57 @@ class Sorter: return felder def _extrahiere_mit_regex(self, config: Dict, text: str) -> Optional[str]: - """Extrahiert ein Feld mit einem einzelnen Regex""" - try: - match = re.search(config["regex"], text, re.IGNORECASE | re.MULTILINE) - if match: - wert = match.group(1) if match.groups() else match.group(0) + """ + Extrahiert ein Feld mit Regex - unterstützt einzelne oder mehrere Alternativen + + Unterstützt "auswahl" Option für mehrere Treffer: + - "first": Erster Treffer (Standard) + - "last": Letzter Treffer + - "max": Größter numerischer Wert + - "min": Kleinster numerischer Wert + """ + regex_pattern = config.get("regex") + if not regex_pattern: + return None + + # Mehrere Regex-Alternativen (Array) + patterns = regex_pattern if isinstance(regex_pattern, list) else [regex_pattern] + + # Auswahl-Modus (max, min, first, last) + auswahl = config.get("auswahl", "first") + + for pattern in patterns: + try: + # Bei max/min/last: Alle Treffer finden + if auswahl in ("max", "min", "last"): + alle_matches = list(re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE)) + if not alle_matches: + continue + + # Werte extrahieren + werte = [] + for match in alle_matches: + wert = match.group(1) if match.groups() else match.group(0) + werte.append(wert.strip()) + + if not werte: + continue + + # Auswahl treffen + if auswahl == "last": + wert = werte[-1] + elif auswahl in ("max", "min"): + # Versuche numerische Auswahl + wert = self._waehle_numerisch(werte, auswahl) + else: + wert = werte[0] + else: + # Standard: Erster Treffer (first) + match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE) + if not match: + continue + wert = match.group(1) if match.groups() else match.group(0) + wert = wert.strip() # Datum formatieren if "format" in config: @@ -151,12 +226,35 @@ class Sorter: if config.get("typ") == "betrag": wert = self._formatiere_betrag(wert) - return wert.strip() - except Exception as e: - logger.debug(f"Regex-Extraktion fehlgeschlagen: {e}") + return wert + except Exception as e: + logger.debug(f"Regex-Extraktion fehlgeschlagen für '{pattern}': {e}") return None + def _waehle_numerisch(self, werte: List[str], modus: str) -> str: + """Wählt max oder min aus einer Liste von Werten (versucht numerisch zu parsen)""" + # Versuche alle Werte als Zahlen zu parsen + numerische_werte = [] + for wert in werte: + try: + # Deutsches Format: 1.234,56 -> 1234.56 + clean = wert.replace(" ", "").replace(".", "").replace(",", ".") + zahl = float(clean) + numerische_werte.append((zahl, wert)) + except ValueError: + # Wenn nicht parsebar, ignorieren + pass + + if not numerische_werte: + # Fallback: Erster Wert wenn keine Zahlen gefunden + return werte[0] + + if modus == "max": + return max(numerische_werte, key=lambda x: x[0])[1] + else: # min + return min(numerische_werte, key=lambda x: x[0])[1] + def _formatiere_betrag(self, betrag: str) -> str: """Formatiert Betrag einheitlich""" betrag = betrag.replace(" ", "").replace(".", "").replace(",", ".") diff --git a/backend/app/routes/__init__.py b/Source/backend/app/routes/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/routes/__init__.py rename to Source/backend/app/routes/__init__.py diff --git a/Source/backend/app/routes/api.py b/Source/backend/app/routes/api.py new file mode 100755 index 0000000..375b836 --- /dev/null +++ b/Source/backend/app/routes/api.py @@ -0,0 +1,2174 @@ +""" +API Routes - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung +""" +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime +from pathlib import Path +import json +import asyncio +import tempfile +import re + +from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail, Zeitplan, OrdnerRegel +from ..modules.mail_fetcher import MailFetcher +from ..modules.pdf_processor import PDFProcessor +from ..modules.sorter import Sorter +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["api"]) + + +# ============ Pydantic Models ============ + +class PostfachCreate(BaseModel): + name: str + imap_server: str + imap_port: int = 993 + email: str + passwort: str + ordner: str = "INBOX" + alle_ordner: bool = False # Alle IMAP-Ordner durchsuchen + nur_ungelesen: bool = False # Nur ungelesene Mails (False = alle) + ziel_ordner: str + erlaubte_typen: List[str] = [".pdf"] + max_groesse_mb: int = 25 + min_groesse_kb: int = 10 # Mindestgröße (gegen kleine Icons) + ab_datum: Optional[datetime] = None # Nur Mails ab diesem Datum + # Größenfilter pro Dateityp: {".pdf": {"min_kb": 10, "max_mb": 25}} + groessen_filter: Optional[dict] = None + + +class PostfachUpdate(BaseModel): + """Für Updates - Passwort ist optional""" + name: str + imap_server: str + imap_port: int = 993 + email: str + passwort: Optional[str] = None # Optional beim Update + ordner: str = "INBOX" + alle_ordner: bool = False + nur_ungelesen: bool = False + ziel_ordner: str + erlaubte_typen: List[str] = [".pdf"] + max_groesse_mb: int = 25 + min_groesse_kb: int = 10 + ab_datum: Optional[datetime] = None + groessen_filter: Optional[dict] = None + + +class PostfachResponse(BaseModel): + id: int + name: str + imap_server: str + email: str + ordner: str + alle_ordner: bool + nur_ungelesen: bool + ziel_ordner: str + erlaubte_typen: List[str] + max_groesse_mb: int + min_groesse_kb: int + ab_datum: Optional[datetime] + groessen_filter: Optional[dict] + aktiv: bool + letzter_abruf: Optional[datetime] + letzte_anzahl: int + + class Config: + from_attributes = True + + +class OrdnerCreate(BaseModel): + name: str + pfad: str + ziel_ordner: str + rekursiv: bool = True + dateitypen: List[str] = [".pdf", ".jpg", ".jpeg", ".png", ".tiff"] + zugferd_behandlung: str = "separieren" # separieren, regel, normal, ignorieren + signiert_behandlung: str = "normal" # normal, separieren, regel, ignorieren + direkt_verschieben: bool = False # Ohne Regelprüfung verschieben + ocr_aktivieren: bool = True # OCR für gescannte PDFs + original_sichern: Optional[str] = None # Ordner für Original-Backup + + +class OrdnerResponse(BaseModel): + id: int + name: str + pfad: str + ziel_ordner: str + rekursiv: bool + dateitypen: List[str] + direkt_verschieben: bool = False + zugferd_behandlung: str + signiert_behandlung: str = "normal" + ocr_aktivieren: bool = True + original_sichern: Optional[str] = None + aktiv: bool + letzte_verarbeitung: Optional[datetime] = None + letzte_anzahl: int = 0 + + class Config: + from_attributes = True + + +class RegelCreate(BaseModel): + name: str + prioritaet: int = 100 + muster: dict = {} + extraktion: dict = {} + schema: str = "{datum} - Dokument.pdf" + unterordner: Optional[str] = None + ist_fallback: bool = False + ziel_ordner: Optional[str] = None # Ziel-Ordner für diese Regel + nur_umbenennen: bool = False # Nur umbenennen, nicht verschieben + + +class RegelResponse(BaseModel): + id: int + name: str + prioritaet: int + aktiv: bool + muster: dict + extraktion: dict + ist_fallback: bool = False + schema: str + unterordner: Optional[str] + freie_ordner: Optional[List[str]] = [] + ziel_ordner: Optional[str] = None + nur_umbenennen: bool = False + + class Config: + from_attributes = True + + +class RegelTestRequest(BaseModel): + regel: dict + text: str + + +# ============ Verzeichnis-Browser ============ + +@router.get("/browse") +def browse_directory(path: str = "/"): + """Listet Verzeichnisse für File-Browser""" + import os + + # Sicherheit: Nur bestimmte Basispfade erlauben + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + path = os.path.abspath(path) + + # Prüfen ob Pfad erlaubt + is_allowed = any(path.startswith(base) for base in allowed_bases) or path == "/" + if not is_allowed: + return {"error": "Pfad nicht erlaubt", "entries": []} + + if not os.path.exists(path): + return {"error": "Pfad existiert nicht", "entries": []} + + if not os.path.isdir(path): + return {"error": "Kein Verzeichnis", "entries": []} + + try: + entries = [] + for entry in sorted(os.listdir(path)): + full_path = os.path.join(path, entry) + if os.path.isdir(full_path): + entries.append({ + "name": entry, + "path": full_path, + "type": "directory" + }) + + return { + "current": path, + "parent": os.path.dirname(path) if path != "/" else None, + "entries": entries + } + except PermissionError: + return {"error": "Zugriff verweigert", "entries": []} + + +# ============ BEREICH 1: Postfächer ============ + +@router.get("/postfaecher", response_model=List[PostfachResponse]) +def liste_postfaecher(db: Session = Depends(get_db)): + return db.query(Postfach).all() + + +@router.post("/postfaecher", response_model=PostfachResponse) +def erstelle_postfach(data: PostfachCreate, db: Session = Depends(get_db)): + postfach = Postfach(**data.dict()) + db.add(postfach) + db.commit() + db.refresh(postfach) + return postfach + + +@router.put("/postfaecher/{id}", response_model=PostfachResponse) +def aktualisiere_postfach(id: int, data: PostfachUpdate, db: Session = Depends(get_db)): + postfach = db.query(Postfach).filter(Postfach.id == id).first() + if not postfach: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + update_data = data.dict() + # Passwort nur aktualisieren wenn nicht leer + if not update_data.get("passwort"): + del update_data["passwort"] + + for key, value in update_data.items(): + setattr(postfach, key, value) + + db.commit() + db.refresh(postfach) + return postfach + + +@router.delete("/postfaecher/{id}") +def loesche_postfach(id: int, db: Session = Depends(get_db)): + postfach = db.query(Postfach).filter(Postfach.id == id).first() + if not postfach: + raise HTTPException(status_code=404, detail="Nicht gefunden") + db.delete(postfach) + db.commit() + return {"message": "Gelöscht"} + + +@router.post("/postfaecher/{id}/test") +def teste_postfach(id: int, db: Session = Depends(get_db)): + postfach = db.query(Postfach).filter(Postfach.id == id).first() + if not postfach: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + fetcher = MailFetcher({ + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner + }) + return fetcher.test_connection() + + +@router.get("/postfaecher/{id}/abrufen/stream") +def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): + """Streaming-Endpoint für Mail-Abruf mit Live-Updates""" + postfach = db.query(Postfach).filter(Postfach.id == id).first() + if not postfach: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + # Daten kopieren für Generator (Session ist nach return nicht mehr verfügbar) + pf_data = { + "id": postfach.id, + "name": postfach.name, + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner, + "alle_ordner": postfach.alle_ordner, + "erlaubte_typen": postfach.erlaubte_typen, + "max_groesse_mb": postfach.max_groesse_mb, + "min_groesse_kb": postfach.min_groesse_kb or 10, + "ziel_ordner": postfach.ziel_ordner, + "ab_datum": postfach.ab_datum.isoformat() if postfach.ab_datum else None, + "groessen_filter": postfach.groessen_filter or {} + } + + # Bereits verarbeitete Message-IDs laden + bereits_verarbeitet = set( + row.message_id for row in + db.query(VerarbeiteteMail.message_id) + .filter(VerarbeiteteMail.postfach_id == id) + .all() + ) + + def event_generator(): + from ..models.database import SessionLocal + + def send_event(data): + return f"data: {json.dumps(data)}\n\n" + + yield send_event({"type": "start", "postfach": pf_data["name"], "bereits_verarbeitet": len(bereits_verarbeitet)}) + + # Zielordner erstellen + ziel = Path(pf_data["ziel_ordner"]) + ziel.mkdir(parents=True, exist_ok=True) + + fetcher = MailFetcher({ + "imap_server": pf_data["imap_server"], + "imap_port": pf_data["imap_port"], + "email": pf_data["email"], + "passwort": pf_data["passwort"], + "ordner": pf_data["ordner"], + "erlaubte_typen": pf_data["erlaubte_typen"], + "max_groesse_mb": pf_data["max_groesse_mb"], + "min_groesse_kb": pf_data["min_groesse_kb"], + "ab_datum": pf_data["ab_datum"], + "groessen_filter": pf_data["groessen_filter"] + }) + + attachments = [] + + try: + # Generator für streaming + for event in fetcher.fetch_attachments_generator( + ziel, + nur_ungelesen=False, + alle_ordner=pf_data["alle_ordner"], + bereits_verarbeitet=bereits_verarbeitet + ): + yield send_event(event) + + if event.get("type") == "datei": + attachments.append(event) + + # DB-Session für Speicherung + session = SessionLocal() + try: + verarbeitete_msg_ids = set() + for att in attachments: + msg_id = att.get("message_id") + if msg_id and msg_id not in verarbeitete_msg_ids: + verarbeitete_msg_ids.add(msg_id) + session.add(VerarbeiteteMail( + postfach_id=pf_data["id"], + message_id=msg_id, + ordner=att.get("ordner", ""), + betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, + absender=att.get("absender", "")[:255] if att.get("absender") else None, + anzahl_attachments=1 + )) + + # Postfach aktualisieren + pf = session.query(Postfach).filter(Postfach.id == pf_data["id"]).first() + if pf: + pf.letzter_abruf = datetime.utcnow() + pf.letzte_anzahl = len(attachments) + session.commit() + finally: + session.close() + + yield send_event({"type": "fertig", "anzahl": len(attachments)}) + + except Exception as e: + yield send_event({"type": "fehler", "nachricht": str(e)}) + finally: + fetcher.disconnect() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@router.post("/postfaecher/{id}/abrufen") +def rufe_postfach_ab(id: int, db: Session = Depends(get_db)): + postfach = db.query(Postfach).filter(Postfach.id == id).first() + if not postfach: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + # Bereits verarbeitete Message-IDs laden + bereits_verarbeitet = set( + row.message_id for row in + db.query(VerarbeiteteMail.message_id) + .filter(VerarbeiteteMail.postfach_id == id) + .all() + ) + + # Zielordner erstellen + ziel = Path(postfach.ziel_ordner) + ziel.mkdir(parents=True, exist_ok=True) + + fetcher = MailFetcher({ + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner, + "erlaubte_typen": postfach.erlaubte_typen, + "max_groesse_mb": postfach.max_groesse_mb, + "min_groesse_kb": postfach.min_groesse_kb or 10 + }) + + try: + attachments = fetcher.fetch_attachments( + ziel, + nur_ungelesen=False, # Alle Mails durchsuchen + alle_ordner=postfach.alle_ordner, + bereits_verarbeitet=bereits_verarbeitet + ) + + # Verarbeitete Mails in DB speichern + verarbeitete_msg_ids = set() + for att in attachments: + msg_id = att.get("message_id") + if msg_id and msg_id not in verarbeitete_msg_ids: + verarbeitete_msg_ids.add(msg_id) + db.add(VerarbeiteteMail( + postfach_id=id, + message_id=msg_id, + ordner=att.get("ordner", ""), + betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, + absender=att.get("absender", "")[:255] if att.get("absender") else None, + anzahl_attachments=1 + )) + + postfach.letzter_abruf = datetime.utcnow() + postfach.letzte_anzahl = len(attachments) + db.commit() + + return { + "ergebnisse": [{ + "postfach": postfach.name, + "anzahl": len(attachments), + "dateien": [a["original_name"] for a in attachments], + "bereits_verarbeitet": len(bereits_verarbeitet) + }] + } + except Exception as e: + return { + "ergebnisse": [{ + "postfach": postfach.name, + "fehler": str(e) + }] + } + finally: + fetcher.disconnect() + + +@router.post("/postfaecher/abrufen-alle") +def rufe_alle_postfaecher_ab(db: Session = Depends(get_db)): + postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() + ergebnisse = [] + + for postfach in postfaecher: + ziel = Path(postfach.ziel_ordner) + ziel.mkdir(parents=True, exist_ok=True) + + fetcher = MailFetcher({ + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner, + "erlaubte_typen": postfach.erlaubte_typen, + "max_groesse_mb": postfach.max_groesse_mb, + "min_groesse_kb": postfach.min_groesse_kb or 10 + }) + + try: + attachments = fetcher.fetch_attachments(ziel) + postfach.letzter_abruf = datetime.utcnow() + postfach.letzte_anzahl = len(attachments) + + ergebnisse.append({ + "postfach": postfach.name, + "anzahl": len(attachments), + "dateien": [a["original_name"] for a in attachments] + }) + except Exception as e: + ergebnisse.append({ + "postfach": postfach.name, + "fehler": str(e) + }) + finally: + fetcher.disconnect() + + db.commit() + return {"ergebnisse": ergebnisse} + + +@router.get("/postfaecher/abrufen-alle/stream") +def rufe_alle_postfaecher_ab_stream(db: Session = Depends(get_db)): + """Streaming-Endpoint für Abruf aller Postfächer mit Live-Updates""" + postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() + + # Daten kopieren für Generator + pf_data_list = [] + for postfach in postfaecher: + bereits_verarbeitet = set( + row.message_id for row in + db.query(VerarbeiteteMail.message_id) + .filter(VerarbeiteteMail.postfach_id == postfach.id) + .all() + ) + pf_data_list.append({ + "id": postfach.id, + "name": postfach.name, + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner, + "alle_ordner": postfach.alle_ordner, + "erlaubte_typen": postfach.erlaubte_typen, + "max_groesse_mb": postfach.max_groesse_mb, + "min_groesse_kb": postfach.min_groesse_kb or 10, + "ziel_ordner": postfach.ziel_ordner, + "ab_datum": postfach.ab_datum.isoformat() if postfach.ab_datum else None, + "groessen_filter": postfach.groessen_filter or {}, + "bereits_verarbeitet": bereits_verarbeitet + }) + + def event_generator(): + from ..models.database import SessionLocal + + def send_event(data): + return f"data: {json.dumps(data)}\n\n" + + yield send_event({"type": "init", "anzahl_postfaecher": len(pf_data_list)}) + + for pf_data in pf_data_list: + yield send_event({"type": "postfach_start", "name": pf_data["name"], "bereits_verarbeitet": len(pf_data["bereits_verarbeitet"])}) + + ziel = Path(pf_data["ziel_ordner"]) + ziel.mkdir(parents=True, exist_ok=True) + + fetcher = MailFetcher({ + "imap_server": pf_data["imap_server"], + "imap_port": pf_data["imap_port"], + "email": pf_data["email"], + "passwort": pf_data["passwort"], + "ordner": pf_data["ordner"], + "erlaubte_typen": pf_data["erlaubte_typen"], + "max_groesse_mb": pf_data["max_groesse_mb"], + "min_groesse_kb": pf_data["min_groesse_kb"], + "ab_datum": pf_data["ab_datum"], + "groessen_filter": pf_data["groessen_filter"] + }) + + attachments = [] + try: + for event in fetcher.fetch_attachments_generator( + ziel, + nur_ungelesen=False, + alle_ordner=pf_data["alle_ordner"], + bereits_verarbeitet=pf_data["bereits_verarbeitet"] + ): + # Postfach-Name zum Event hinzufügen + event["postfach"] = pf_data["name"] + yield send_event(event) + + if event.get("type") == "datei": + attachments.append(event) + + # Verarbeitete Mails speichern + session = SessionLocal() + try: + verarbeitete_msg_ids = set() + for att in attachments: + msg_id = att.get("message_id") + if msg_id and msg_id not in verarbeitete_msg_ids: + verarbeitete_msg_ids.add(msg_id) + session.add(VerarbeiteteMail( + postfach_id=pf_data["id"], + message_id=msg_id, + ordner=att.get("ordner"), + betreff=att.get("betreff", "")[:500], + absender=att.get("absender", "")[:255], + anzahl_attachments=1 + )) + + # Postfach Status aktualisieren + postfach_obj = session.query(Postfach).filter(Postfach.id == pf_data["id"]).first() + if postfach_obj: + postfach_obj.letzter_abruf = datetime.utcnow() + postfach_obj.letzte_anzahl = len(attachments) + + session.commit() + finally: + session.close() + + yield send_event({"type": "postfach_done", "name": pf_data["name"], "anzahl": len(attachments)}) + + except Exception as e: + yield send_event({"type": "postfach_error", "name": pf_data["name"], "fehler": str(e)}) + finally: + fetcher.disconnect() + + yield send_event({"type": "done"}) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} + ) + + +# ============ BEREICH 2: Quell-Ordner ============ + +@router.get("/ordner", response_model=List[OrdnerResponse]) +def liste_ordner(db: Session = Depends(get_db)): + return db.query(QuellOrdner).all() + + +@router.post("/ordner", response_model=OrdnerResponse) +def erstelle_ordner(data: OrdnerCreate, db: Session = Depends(get_db)): + ordner = QuellOrdner(**data.dict()) + db.add(ordner) + db.commit() + db.refresh(ordner) + return ordner + + +@router.put("/ordner/{id}", response_model=OrdnerResponse) +def aktualisiere_ordner(id: int, data: OrdnerCreate, db: Session = Depends(get_db)): + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not ordner: + raise HTTPException(status_code=404, detail="Nicht gefunden") + for key, value in data.dict().items(): + setattr(ordner, key, value) + db.commit() + db.refresh(ordner) + return ordner + + +@router.delete("/ordner/{id}") +def loesche_ordner(id: int, db: Session = Depends(get_db)): + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not ordner: + raise HTTPException(status_code=404, detail="Nicht gefunden") + db.delete(ordner) + db.commit() + return {"message": "Gelöscht"} + + +@router.post("/ordner/{id}/aktivieren") +def aktiviere_ordner(id: int, db: Session = Depends(get_db)): + """Aktiviert/Deaktiviert einen Quellordner""" + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not ordner: + raise HTTPException(status_code=404, detail="Ordner nicht gefunden") + ordner.aktiv = not ordner.aktiv + db.commit() + return {"message": f"Ordner {'aktiviert' if ordner.aktiv else 'deaktiviert'}", "aktiv": ordner.aktiv} + + +@router.post("/ordner/{id}/kopieren") +def kopiere_ordner(id: int, db: Session = Depends(get_db)): + """Kopiert eine Grobsortierung (QuellOrdner)""" + original = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not original: + raise HTTPException(status_code=404, detail="Ordner nicht gefunden") + + # Kopie erstellen + kopie = QuellOrdner( + name=f"{original.name} (Kopie)", + pfad=original.pfad, + ziel_ordner=original.ziel_ordner, + dateitypen=original.dateitypen.copy() if original.dateitypen else [".pdf"], + rekursiv=original.rekursiv, + aktiv=False, # Kopie erstmal deaktiviert + zugferd_behandlung=original.zugferd_behandlung, + signiert_behandlung=original.signiert_behandlung, + ocr_aktivieren=original.ocr_aktivieren, + original_sichern=original.original_sichern + ) + + db.add(kopie) + db.commit() + db.refresh(kopie) + + return {"id": kopie.id, "name": kopie.name, "message": "Grobsortierung kopiert"} + + +@router.get("/ordner/{id}/scannen") +def scanne_ordner(id: int, db: Session = Depends(get_db)): + """Zeigt Vorschau der Dateien im Ordner (ohne Verarbeitung)""" + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not ordner: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + pfad = Path(ordner.pfad) + if not pfad.exists(): + return {"anzahl": 0, "fehler": "Ordner existiert nicht"} + + # Dateien sammeln (rekursiv oder nicht) + dateien = [] + pattern = "**/*" if ordner.rekursiv else "*" + for f in pfad.glob(pattern): + if f.is_file() and f.suffix.lower() in [t.lower() for t in ordner.dateitypen]: + dateien.append(f) + + return {"anzahl": len(dateien), "dateien": [str(f.relative_to(pfad)) for f in dateien[:30]]} + + +@router.post("/ordner/{id}/verarbeiten") +def verarbeite_ordner(id: int, db: Session = Depends(get_db)): + """Verarbeitet alle Dateien eines spezifischen Ordners""" + quell_ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() + if not quell_ordner: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + pfad = Path(quell_ordner.pfad) + if not pfad.exists(): + return {"fehler": "Ordner existiert nicht", "verarbeitet": []} + + regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() + if not regeln: + return {"fehler": "Keine Regeln definiert", "verarbeitet": []} + + # Regeln in Dict-Format + regeln_dicts = [] + for r in regeln: + regeln_dicts.append({ + "id": r.id, + "name": r.name, + "prioritaet": r.prioritaet, + "muster": r.muster, + "extraktion": r.extraktion, + "schema": r.schema, + "unterordner": r.unterordner + }) + + sorter = Sorter(regeln_dicts) + pdf_processor = PDFProcessor() + + ergebnis = { + "gesamt": 0, + "sortiert": 0, + "zugferd": 0, + "keine_regel": 0, + "fehler": 0, + "verarbeitet": [] + } + + ziel_basis = Path(quell_ordner.ziel_ordner) + dateien = sammle_dateien(quell_ordner) + + for datei in dateien: + ergebnis["gesamt"] += 1 + try: + rel_pfad = str(datei.relative_to(pfad)) + except: + rel_pfad = datei.name + datei_info = {"original": rel_pfad} + + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + ocr_gemacht = False + + # Nur PDFs durch den PDF-Processor + if ist_pdf: + pdf_result = pdf_processor.verarbeite(str(datei)) + + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + + text = pdf_result.get("text", "") + ist_zugferd = pdf_result.get("ist_zugferd", False) + ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) + + # ZUGFeRD behandeln basierend auf Einstellung + zugferd_modus = getattr(quell_ordner, 'zugferd_behandlung', 'separieren') or 'separieren' + + if ist_zugferd: + if zugferd_modus == "ignorieren": + # ZUGFeRD überspringen + datei_info["status"] = "zugferd_ignoriert" + ergebnis["zugferd"] += 1 + ergebnis["verarbeitet"].append(datei_info) + continue + elif zugferd_modus == "separieren": + # Original sichern falls konfiguriert (VOR dem Verschieben) + 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) + logger.info(f"ZUGFeRD Original gesichert: {backup_pfad}") + + # Direkt in Zielordner verschieben (ohne Umbenennung) + ziel_basis.mkdir(parents=True, exist_ok=True) + + 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["zugferd"] += 1 + datei_info["zugferd"] = True + datei_info["neuer_name"] = neuer_pfad.name + datei_info["status"] = "zugferd_verschoben" + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(neuer_pfad), + neuer_name=neuer_pfad.name, + ist_zugferd=True, + status="zugferd" + )) + ergebnis["verarbeitet"].append(datei_info) + continue + # Bei "normal" oder "regel": fortfahren mit Regel-Matching + else: + # Keine ZUGFeRD-PDF + if zugferd_modus == "separieren": + # Bei "separieren" NUR ZUGFeRD verarbeiten, normale PDFs ignorieren + datei_info["status"] = "kein_zugferd_ignoriert" + ergebnis["verarbeitet"].append(datei_info) + continue + + # Regel finden + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower() + } + + regel = sorter.finde_passende_regel(doc_info) + + if not regel: + datei_info["status"] = "keine_regel" + ergebnis["keine_regel"] += 1 + ergebnis["verarbeitet"].append(datei_info) + continue + + # Felder extrahieren + extrahiert = sorter.extrahiere_felder(regel, doc_info) + + # Dateiendung beibehalten + schema = regel.get("schema", "{datum} - Dokument.pdf") + if schema.endswith(".pdf"): + schema = schema[:-4] + datei.suffix + neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert) + + # Zielordner + ziel = ziel_basis + if regel.get("unterordner"): + ziel = ziel / regel["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + # Original sichern falls konfiguriert (VOR dem Verschieben) + 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) + logger.info(f"Original gesichert: {backup_pfad}") + + # Verschieben + neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + + ergebnis["sortiert"] += 1 + datei_info["neuer_name"] = neuer_name + datei_info["regel"] = regel.get("name") + if ist_zugferd: + datei_info["zugferd"] = True + ergebnis["zugferd"] += 1 + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=neuer_pfad, + neuer_name=neuer_name, + ist_zugferd=ist_zugferd, + ocr_durchgefuehrt=ocr_gemacht, + status="sortiert", + extrahierte_daten=extrahiert + )) + + except Exception as e: + ergebnis["fehler"] += 1 + datei_info["fehler"] = str(e) + + ergebnis["verarbeitet"].append(datei_info) + + db.commit() + return ergebnis + + +# ============ Regeln ============ + +@router.get("/regeln", response_model=List[RegelResponse]) +def liste_regeln(db: Session = Depends(get_db)): + return db.query(SortierRegel).order_by(SortierRegel.prioritaet).all() + + +@router.post("/regeln", response_model=RegelResponse) +def erstelle_regel(data: RegelCreate, db: Session = Depends(get_db)): + regel = SortierRegel(**data.dict()) + db.add(regel) + db.commit() + db.refresh(regel) + return regel + + +@router.put("/regeln/{id}", response_model=RegelResponse) +def aktualisiere_regel(id: int, data: RegelCreate, db: Session = Depends(get_db)): + regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not regel: + raise HTTPException(status_code=404, detail="Nicht gefunden") + for key, value in data.dict().items(): + setattr(regel, key, value) + db.commit() + db.refresh(regel) + return regel + + +@router.delete("/regeln/{id}") +def loesche_regel(id: int, db: Session = Depends(get_db)): + regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not regel: + raise HTTPException(status_code=404, detail="Nicht gefunden") + db.delete(regel) + db.commit() + return {"message": "Gelöscht"} + + +@router.post("/regeln/{id}/aktivieren") +def aktiviere_regel(id: int, db: Session = Depends(get_db)): + """Aktiviert/Deaktiviert eine Sortierregel""" + regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not regel: + raise HTTPException(status_code=404, detail="Regel nicht gefunden") + regel.aktiv = not regel.aktiv + db.commit() + return {"message": f"Regel {'aktiviert' if regel.aktiv else 'deaktiviert'}", "aktiv": regel.aktiv} + + +@router.post("/regeln/{id}/kopieren") +def kopiere_regel(id: int, db: Session = Depends(get_db)): + """Kopiert eine Sortierregel""" + original = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not original: + raise HTTPException(status_code=404, detail="Regel nicht gefunden") + + # Kopie erstellen + kopie = SortierRegel( + name=f"{original.name} (Kopie)", + prioritaet=original.prioritaet, + aktiv=False, # Kopie erstmal deaktiviert + muster=original.muster.copy() if original.muster else {}, + extraktion=original.extraktion.copy() if original.extraktion else {}, + schema=original.schema, + unterordner=original.unterordner, + ist_fallback=original.ist_fallback, + freie_ordner=original.freie_ordner.copy() if original.freie_ordner else [], + ziel_ordner=original.ziel_ordner, + nur_umbenennen=original.nur_umbenennen + ) + + db.add(kopie) + db.commit() + db.refresh(kopie) + + return {"id": kopie.id, "name": kopie.name, "message": "Regel kopiert"} + + +@router.post("/regeln/test") +def teste_regel(data: RegelTestRequest): + regel = data.regel + regel["aktiv"] = True + regel["prioritaet"] = 1 + + sorter = Sorter([regel]) + doc_info = {"text": data.text, "original_name": "test.pdf", "absender": ""} + + passend = sorter.finde_passende_regel(doc_info) + + if passend: + extrahiert = sorter.extrahiere_felder(passend, doc_info) + dateiname = sorter.generiere_dateinamen(passend, extrahiert) + return {"passt": True, "extrahiert": extrahiert, "dateiname": dateiname} + + return {"passt": False} + + +# ============ Ordner-Regel-Zuweisungen ============ + +@router.get("/ordner/{ordner_id}/regeln") +def get_ordner_regeln(ordner_id: int, db: Session = Depends(get_db)): + """Gibt alle Regeln zurück die diesem Ordner zugewiesen sind""" + zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == ordner_id).all() + regel_ids = [z.regel_id for z in zuweisungen] + return {"regel_ids": regel_ids} + + +@router.post("/ordner/{ordner_id}/regeln/{regel_id}") +def ordner_regel_zuweisen(ordner_id: int, regel_id: int, db: Session = Depends(get_db)): + """Weist eine Regel einem Ordner zu""" + # Prüfe ob Ordner und Regel existieren + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == ordner_id).first() + regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() + + if not ordner: + raise HTTPException(status_code=404, detail="Ordner nicht gefunden") + if not regel: + raise HTTPException(status_code=404, detail="Regel nicht gefunden") + + # Prüfe ob Zuweisung bereits existiert + existiert = db.query(OrdnerRegel).filter( + OrdnerRegel.ordner_id == ordner_id, + OrdnerRegel.regel_id == regel_id + ).first() + + if existiert: + return {"message": "Bereits zugewiesen"} + + # Neue Zuweisung erstellen + zuweisung = OrdnerRegel(ordner_id=ordner_id, regel_id=regel_id) + db.add(zuweisung) + db.commit() + return {"message": "Zugewiesen"} + + +@router.delete("/ordner/{ordner_id}/regeln/{regel_id}") +def ordner_regel_entfernen(ordner_id: int, regel_id: int, db: Session = Depends(get_db)): + """Entfernt eine Regel-Zuweisung von einem Ordner""" + zuweisung = db.query(OrdnerRegel).filter( + OrdnerRegel.ordner_id == ordner_id, + OrdnerRegel.regel_id == regel_id + ).first() + + if not zuweisung: + raise HTTPException(status_code=404, detail="Zuweisung nicht gefunden") + + db.delete(zuweisung) + db.commit() + return {"message": "Entfernt"} + + +@router.get("/regeln/{regel_id}/ordner") +def get_regel_ordner(regel_id: int, db: Session = Depends(get_db)): + """Gibt alle Ordner zurück denen diese Regel zugewiesen ist""" + # Zugewiesene Quell-Ordner (über OrdnerRegel) + zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.regel_id == regel_id).all() + ordner_ids = [z.ordner_id for z in zuweisungen] + + # Freie Ordner aus der Regel selbst + regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() + freie_ordner = regel.freie_ordner if regel and regel.freie_ordner else [] + + return {"ordner_ids": ordner_ids, "freie_ordner": freie_ordner} + + +class OrdnerZuweisungRequest(BaseModel): + ordner_ids: List[int] = [] + freie_ordner: List[str] = [] + + +@router.put("/regeln/{regel_id}/ordner") +def set_regel_ordner(regel_id: int, data: OrdnerZuweisungRequest, db: Session = Depends(get_db)): + """Setzt alle Ordner-Zuweisungen für eine Regel (ersetzt bestehende)""" + # Prüfe ob Regel existiert + regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() + if not regel: + raise HTTPException(status_code=404, detail="Regel nicht gefunden") + + # Lösche alle bestehenden Zuweisungen + db.query(OrdnerRegel).filter(OrdnerRegel.regel_id == regel_id).delete() + + # Erstelle neue Zuweisungen + for ordner_id in data.ordner_ids: + ordner = db.query(QuellOrdner).filter(QuellOrdner.id == ordner_id).first() + if ordner: + zuweisung = OrdnerRegel(ordner_id=ordner_id, regel_id=regel_id) + db.add(zuweisung) + + # Freie Ordner in der Regel speichern + regel.freie_ordner = data.freie_ordner + + db.commit() + return {"message": f"{len(data.ordner_ids)} Ordner + {len(data.freie_ordner)} freie Ordner zugewiesen"} + + +# ============ Sortierung ============ + +def sammle_dateien(ordner: QuellOrdner) -> list: + """Sammelt alle Dateien aus einem Ordner (rekursiv oder nicht)""" + pfad = Path(ordner.pfad) + if not pfad.exists(): + return [] + + dateien = [] + pattern = "**/*" if ordner.rekursiv else "*" + erlaubte = [t.lower() for t in (ordner.dateitypen or [".pdf"])] + + # Wenn ZUGFeRD oder Signiert aktiviert, PDFs auch sammeln (für Erkennung) + zugferd_aktiv = getattr(ordner, 'zugferd_behandlung', 'normal') == 'separieren' + signiert_aktiv = getattr(ordner, 'signiert_behandlung', 'normal') == 'separieren' + pdf_fuer_erkennung = (zugferd_aktiv or signiert_aktiv) and ".pdf" not in erlaubte + + for f in pfad.glob(pattern): + if f.is_file(): + suffix = f.suffix.lower() + if suffix in erlaubte: + dateien.append(f) + elif pdf_fuer_erkennung and suffix == ".pdf": + # PDF nur für ZUGFeRD/Signiert-Erkennung hinzufügen + dateien.append(f) + + return dateien + + +def sammle_dateien_aus_pfad(pfad_str: str, erlaubte_typen: List[str] = None, rekursiv: bool = True) -> list: + """Sammelt alle Dateien aus einem freien Ordner-Pfad""" + pfad = Path(pfad_str) + if not pfad.exists() or not pfad.is_dir(): + return [] + + dateien = [] + pattern = "**/*" if rekursiv else "*" + erlaubte = [t.lower() for t in (erlaubte_typen or [".pdf"])] + + for f in pfad.glob(pattern): + if f.is_file() and f.suffix.lower() in erlaubte: + dateien.append(f) + + return dateien + + +@router.post("/sortierung/starten") +def starte_sortierung(db: Session = Depends(get_db)): + ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() + + if not ordner_liste: + return {"fehler": "Keine Quell-Ordner konfiguriert", "verarbeitet": []} + + pdf_processor = PDFProcessor() + + ergebnis = { + "gesamt": 0, + "sortiert": 0, + "zugferd": 0, + "fehler": 0, + "verarbeitet": [] + } + + # Fallback-Regeln laden (gelten für alle Ordner wenn keine andere Regel passt) + 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] + + for quell_ordner in ordner_liste: + # Sortierregeln suchen im ZIEL-Ordner (wo Dateien nach Grobsortierung liegen) + pfad = Path(quell_ordner.ziel_ordner) + if not pfad.exists(): + continue + + dateien = sammle_dateien_aus_pfad(str(pfad), [".pdf"], rekursiv=True) + + # Lade nur Regeln die diesem Ordner zugewiesen sind + zuweisungen = db.query(OrdnerRegel).filter(OrdnerRegel.ordner_id == quell_ordner.id).all() + zugewiesene_regel_ids = [z.regel_id for z in zuweisungen] + + if zugewiesene_regel_ids: + regeln = db.query(SortierRegel).filter( + SortierRegel.id.in_(zugewiesene_regel_ids), + SortierRegel.aktiv == True, + SortierRegel.ist_fallback == False # Fallbacks separat behandeln + ).order_by(SortierRegel.prioritaet).all() + else: + regeln = [] + + # Regeln in Dict-Format + regeln_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 regeln] + + # Sorter mit Ordner-spezifischen Regeln + sorter = Sorter(regeln_dicts) if regeln_dicts else None + fallback_sorter = Sorter(fallback_dicts) if fallback_dicts else None + + print(f"=== Ordner '{quell_ordner.name}': {len(dateien)} Dateien, {len(regeln_dicts)} zugewiesene Regeln, {len(fallback_dicts)} Fallback-Regeln ===", flush=True) + for r in regeln_dicts: + print(f" Regel: {r.get('name')} - Muster: {r.get('muster')} - Schema: {r.get('schema')}", flush=True) + + for datei in dateien: + ergebnis["gesamt"] += 1 + try: + rel_pfad = str(datei.relative_to(pfad)) + except: + rel_pfad = datei.name + datei_info = {"original": rel_pfad, "ordner": quell_ordner.name} + + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + ocr_gemacht = False + + # Nur PDFs durch den PDF-Processor + if ist_pdf: + # OCR-Optionen aus Ordner-Einstellungen + ocr_erlaubt = getattr(quell_ordner, 'ocr_aktivieren', True) + original_backup = getattr(quell_ordner, 'original_sichern', None) + + pdf_result = pdf_processor.verarbeite( + str(datei), + ocr_erlaubt=ocr_erlaubt, + original_backup_pfad=original_backup + ) + + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + + text = pdf_result.get("text", "") + ist_zugferd = pdf_result.get("ist_zugferd", False) + ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) + + # Info über Original-Sicherung + if pdf_result.get("original_gesichert"): + datei_info["original_gesichert"] = pdf_result["original_gesichert"] + + # ZUGFeRD-Info für Anzeige merken + if ist_zugferd: + datei_info["zugferd"] = True + + # Signierte PDF-Behandlung + ist_signiert = pdf_result.get("ist_signiert", False) + if ist_signiert: + signiert_behandlung = getattr(quell_ordner, 'signiert_behandlung', 'normal') + + if signiert_behandlung == "separieren": + # Signierte PDFs bleiben im gleichen Ordner + pfad.mkdir(parents=True, exist_ok=True) + + neuer_pfad = pfad / datei.name + counter = 1 + while neuer_pfad.exists(): + neuer_pfad = pfad / f"{datei.stem}_{counter}{datei.suffix}" + counter += 1 + + datei.rename(neuer_pfad) + + ergebnis["sortiert"] += 1 + datei_info["signiert"] = True + datei_info["neuer_name"] = neuer_pfad.name + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(neuer_pfad), + neuer_name=neuer_pfad.name, + status="signiert" + )) + ergebnis["verarbeitet"].append(datei_info) + continue + elif signiert_behandlung == "ignorieren": + datei_info["uebersprungen"] = "Signierte PDF ignoriert" + ergebnis["verarbeitet"].append(datei_info) + continue + # Bei "normal": weiter mit Regel-Matching + + # Prüfe ob PDF nur für ZUGFeRD/Signiert-Erkennung gescannt wurde + # Wenn ja und keine besondere Dateiart erkannt: überspringen + erlaubte_typen = [t.lower() for t in (quell_ordner.dateitypen or [".pdf"])] + if ist_pdf and ".pdf" not in erlaubte_typen: + # PDF war nur für ZUGFeRD/Signiert-Erkennung, aber keine erkannt + datei_info["uebersprungen"] = "Kein ZUGFeRD/Signiert - PDF nicht in Dateitypen" + ergebnis["verarbeitet"].append(datei_info) + continue + + # Dokument-Info für Regel-Matching + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower() + } + + # Passende Regel finden (erst zugewiesene, dann Fallback) + regel = None + if sorter: + regel = sorter.finde_passende_regel(doc_info) + + # Fallback-Regel wenn keine zugewiesene passt + if not regel and fallback_sorter: + regel = fallback_sorter.finde_passende_regel(doc_info) + if regel: + datei_info["fallback"] = True + + if not regel: + print(f"!!! Keine passende Regel für {datei.name}", flush=True) + datei_info["fehler"] = "Keine passende Regel (keine Regeln zugewiesen)" + ergebnis["fehler"] += 1 + ergebnis["verarbeitet"].append(datei_info) + continue + + print(f">>> Regel '{regel.get('name')}' passt für {datei.name}", flush=True) + + # Felder extrahieren + extrahiert = sorter.extrahiere_felder(regel, doc_info) if sorter else \ + fallback_sorter.extrahiere_felder(regel, doc_info) + + # Dateiendung beibehalten + 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 bestimmen + if regel.get("nur_umbenennen"): + # Nur umbenennen - Datei bleibt im aktuellen Ordner + ziel = datei.parent + elif regel.get("ziel_ordner"): + # Regel hat eigenen Zielordner + ziel = Path(regel["ziel_ordner"]) + if regel.get("unterordner"): + ziel = ziel / regel["unterordner"] + else: + # Kein Zielordner - bleibt im gleichen Ordner + ziel = pfad + if regel.get("unterordner"): + ziel = ziel / regel["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + # Verschieben + print(f">>> Verschiebe: {datei.name} -> {ziel}/{neuer_name}", flush=True) + print(f" Extrahierte Felder: {extrahiert}", flush=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") + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=neuer_pfad, + neuer_name=neuer_name, + ist_zugferd=False, + ocr_durchgefuehrt=ocr_gemacht, + status="sortiert", + extrahierte_daten=extrahiert + )) + + except Exception as e: + ergebnis["fehler"] += 1 + datei_info["fehler"] = str(e) + + ergebnis["verarbeitet"].append(datei_info) + + # ============ Freie Ordner verarbeiten ============ + # Lade alle Regeln mit freien Ordnern + alle_regeln = db.query(SortierRegel).filter( + SortierRegel.aktiv == True + ).all() + + for regel in alle_regeln: + freie_ordner = regel.freie_ordner if regel.freie_ordner else [] + if not freie_ordner: + continue + + regel_dict = { + "id": regel.id, "name": regel.name, "prioritaet": regel.prioritaet, + "muster": regel.muster, "extraktion": regel.extraktion, + "schema": regel.schema, "unterordner": regel.unterordner, + "ziel_ordner": getattr(regel, 'ziel_ordner', None), + "nur_umbenennen": getattr(regel, 'nur_umbenennen', False) + } + regel_sorter = Sorter([regel_dict]) + + for freier_ordner_pfad in freie_ordner: + freier_pfad = Path(freier_ordner_pfad) + if not freier_pfad.exists() or not freier_pfad.is_dir(): + continue + + # Zielordner = der freie Ordner selbst (Dateien werden darin umbenannt) + # ODER wir brauchen einen separaten Zielordner? + # Vorerst: Dateien werden im gleichen Ordner umbenannt (in-place) + dateien = sammle_dateien_aus_pfad(freier_ordner_pfad, [".pdf"]) + + for datei in dateien: + ergebnis["gesamt"] += 1 + datei_info = {"original": datei.name, "ordner": f"Freier Ordner: {freier_ordner_pfad}"} + + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + ocr_gemacht = False + + if ist_pdf: + pdf_result = pdf_processor.verarbeite(str(datei)) + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + text = pdf_result.get("text", "") + ist_zugferd = pdf_result.get("ist_zugferd", False) + ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) + + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower() + } + + # Prüfe ob Regel passt + passend = regel_sorter.finde_passende_regel(doc_info) + if not passend: + datei_info["uebersprungen"] = "Regel passt nicht" + ergebnis["verarbeitet"].append(datei_info) + continue + + # Felder extrahieren + extrahiert = regel_sorter.extrahiere_felder(passend, doc_info) + + # Dateiname generieren + schema = passend.get("schema", "{datum} - Dokument.pdf") + if schema.endswith(".pdf"): + schema = schema[:-4] + datei.suffix + neuer_name = regel_sorter.generiere_dateinamen( + {"schema": schema, **passend}, extrahiert + ) + + # Zielordner bestimmen + if passend.get("nur_umbenennen"): + # Nur umbenennen - Datei bleibt im aktuellen Ordner + ziel = datei.parent + elif passend.get("ziel_ordner"): + # Regel hat eigenen Zielordner + ziel = Path(passend["ziel_ordner"]) + if passend.get("unterordner"): + ziel = ziel / passend["unterordner"] + else: + # Kein Zielordner - bleibt im freien Ordner + ziel = freier_pfad + if passend.get("unterordner"): + ziel = ziel / passend["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + # Verschieben/Umbenennen + neuer_pfad = regel_sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + + ergebnis["sortiert"] += 1 + datei_info["neuer_name"] = neuer_name + datei_info["regel"] = passend.get("name", "Unbekannt") + if ist_zugferd: + datei_info["zugferd"] = True + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=neuer_pfad, + neuer_name=neuer_name, + ist_zugferd=ist_zugferd, + ocr_durchgefuehrt=ocr_gemacht, + status="sortiert", + extrahierte_daten=extrahiert + )) + + except Exception as e: + ergebnis["fehler"] += 1 + datei_info["fehler"] = str(e) + + ergebnis["verarbeitet"].append(datei_info) + + db.commit() + return ergebnis + + +# ============ PDF Test / Regel-Vorschau ============ + +@router.post("/pdf/extrahieren") +async def extrahiere_pdf_text(datei: UploadFile = File(...)): + """Extrahiert Text aus einer hochgeladenen PDF für Regel-Tests""" + if not datei.filename.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") + + # Temporäre Datei erstellen + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + inhalt = await datei.read() + tmp.write(inhalt) + tmp_pfad = tmp.name + + try: + pdf_processor = PDFProcessor() + ergebnis = pdf_processor.verarbeite(tmp_pfad) + + return { + "dateiname": datei.filename, + "text": ergebnis.get("text", ""), + "ist_zugferd": ergebnis.get("ist_zugferd", False), + "ist_signiert": ergebnis.get("ist_signiert", False), + "hat_text": ergebnis.get("hat_text", False), + "ocr_durchgefuehrt": ergebnis.get("ocr_durchgefuehrt", False), + "seiten": ergebnis.get("seiten", 0) + } + finally: + # Temporäre Datei löschen + Path(tmp_pfad).unlink(missing_ok=True) + + +@router.post("/pdf/testen") +async def teste_pdf_mit_regel(datei: UploadFile = File(...), regel_json: str = "{}"): + """Testet eine Regel gegen eine hochgeladene PDF""" + if not datei.filename.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") + + # Regel parsen + try: + regel = json.loads(regel_json) + except: + regel = {} + + # Temporäre Datei erstellen + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + inhalt = await datei.read() + tmp.write(inhalt) + tmp_pfad = tmp.name + + try: + # PDF verarbeiten + pdf_processor = PDFProcessor() + pdf_ergebnis = pdf_processor.verarbeite(tmp_pfad) + text = pdf_ergebnis.get("text", "") + + # Regel testen + regel["aktiv"] = True + regel["prioritaet"] = 1 + sorter = Sorter([regel]) + + doc_info = { + "text": text, + "original_name": datei.filename, + "absender": "" + } + + passend = sorter.finde_passende_regel(doc_info) + + ergebnis = { + "dateiname": datei.filename, + "text": text, + "text_vorschau": text[:2000] if len(text) > 2000 else text, + "ist_zugferd": pdf_ergebnis.get("ist_zugferd", False), + "regel_passt": passend is not None, + "extrahiert": {}, + "vorgeschlagener_name": "" + } + + if passend: + extrahiert = sorter.extrahiere_felder(passend, doc_info) + ergebnis["extrahiert"] = extrahiert + ergebnis["vorgeschlagener_name"] = sorter.generiere_dateinamen(passend, extrahiert) + + # Muster-Matches hervorheben + muster = regel.get("muster", {}) + matches = [] + + # Keywords finden + if "keywords" in muster: + keywords = muster["keywords"] + if isinstance(keywords, str): + keywords = [k.strip() for k in keywords.split(",")] + for kw in keywords: + if kw.strip(): + for m in re.finditer(re.escape(kw.strip()), text, re.IGNORECASE): + matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "keyword"}) + + # text_match finden + if "text_match" in muster: + patterns = muster["text_match"] + if isinstance(patterns, str): + patterns = [patterns] + for p in patterns: + for m in re.finditer(re.escape(p), text, re.IGNORECASE): + matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "text_match"}) + + # text_regex finden + if "text_regex" in muster: + try: + for m in re.finditer(muster["text_regex"], text, re.IGNORECASE): + matches.append({"start": m.start(), "end": m.end(), "text": m.group(), "typ": "regex"}) + except: + pass + + ergebnis["matches"] = matches + + return ergebnis + + finally: + Path(tmp_pfad).unlink(missing_ok=True) + + +# ============ Debug Log ============ + +import logging +from collections import deque + +# In-Memory Log-Speicher (maximal 500 Einträge) +log_speicher = deque(maxlen=500) + + +class UILogHandler(logging.Handler): + """Log Handler der Einträge für die UI speichert""" + def emit(self, record): + log_speicher.append({ + "zeit": datetime.now().strftime("%H:%M:%S"), + "level": record.levelname, + "nachricht": self.format(record) + }) + + +# Handler registrieren +ui_handler = UILogHandler() +ui_handler.setLevel(logging.DEBUG) +ui_handler.setFormatter(logging.Formatter('%(message)s')) + +# Zu allen relevanten Loggern hinzufügen +for logger_name in ['backend.app', 'root', '']: + logger = logging.getLogger(logger_name) + logger.addHandler(ui_handler) + + +@router.get("/logs") +def hole_logs(level: str = None): + """Gibt Log-Einträge zurück""" + logs = list(log_speicher) + if level: + logs = [l for l in logs if l["level"] == level] + return logs + + +@router.delete("/logs") +def loesche_logs(): + """Leert den Log-Speicher""" + log_speicher.clear() + return {"message": "Logs gelöscht"} + + +@router.get("/health") +def health(): + return {"status": "ok"} + + +# ============ Auto-Regex Generator ============ + +@router.post("/pdf/auto-regex") +async def auto_regex_generator(datei: UploadFile = File(...)): + """ + Analysiert eine PDF und generiert automatisch Regex-Muster für erkannte Felder. + Erkennt: Datum, Betrag, Rechnungsnummer, Firma + """ + if not datei.filename.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") + + # Temporäre Datei erstellen + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: + inhalt = await datei.read() + tmp.write(inhalt) + tmp_pfad = tmp.name + + try: + # PDF verarbeiten + pdf_processor = PDFProcessor() + pdf_ergebnis = pdf_processor.verarbeite(tmp_pfad) + text = pdf_ergebnis.get("text", "") + + erkannte_felder = [] + + # ============ DATUM erkennen ============ + datum_patterns = [ + {"regex": r"Rechnungsdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Rechnungsdatum", "prio": 1}, + {"regex": r"Belegdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Belegdatum", "prio": 1}, + {"regex": r"Datum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "Datum", "prio": 2}, + {"regex": r"vom[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "kontext": "vom", "prio": 2}, + {"regex": r"(\d{2})\.(\d{2})\.(\d{4})", "kontext": "Format TT.MM.JJJJ", "prio": 3}, + {"regex": r"(\d{4})-(\d{2})-(\d{2})", "kontext": "ISO Format", "prio": 3}, + ] + + for pattern in datum_patterns: + match = re.search(pattern["regex"], text, re.IGNORECASE) + if match: + erkannte_felder.append({ + "feld": "datum", + "wert": match.group(0), + "position": match.start(), + "kontext": pattern["kontext"], + "regex_vorschlag": pattern["regex"], + "prioritaet": pattern["prio"] + }) + break + + # ============ BETRAG erkennen ============ + betrag_patterns = [ + {"regex": r"Gesamtbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Gesamtbetrag", "prio": 1}, + {"regex": r"Rechnungsbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Rechnungsbetrag", "prio": 1}, + {"regex": r"Endbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Endbetrag", "prio": 1}, + {"regex": r"Summe[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Summe", "prio": 2}, + {"regex": r"Total[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Total", "prio": 2}, + {"regex": r"Brutto[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "Brutto", "prio": 2}, + {"regex": r"zu zahlen[:\s]*([\d.,]+)\s*(?:EUR|€)?", "kontext": "zu zahlen", "prio": 1}, + {"regex": r"([\d.,]+)\s*(?:EUR|€)", "kontext": "Betrag mit EUR/€", "prio": 3}, + ] + + for pattern in betrag_patterns: + match = re.search(pattern["regex"], text, re.IGNORECASE) + if match: + erkannte_felder.append({ + "feld": "betrag", + "wert": match.group(0), + "extrahiert": match.group(1) if match.lastindex else match.group(0), + "position": match.start(), + "kontext": pattern["kontext"], + "regex_vorschlag": pattern["regex"], + "prioritaet": pattern["prio"] + }) + break + + # ============ RECHNUNGSNUMMER erkennen ============ + nummer_patterns = [ + {"regex": r"Rechnungsnummer[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnungsnummer", "prio": 1}, + {"regex": r"Rechnung\s*Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnung Nr.", "prio": 1}, + {"regex": r"Rechnungs-Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Rechnungs-Nr.", "prio": 1}, + {"regex": r"Invoice\s*(?:No\.?|Number)?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Invoice", "prio": 1}, + {"regex": r"Beleg-?Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Beleg-Nr.", "prio": 2}, + {"regex": r"Dokumentnummer[:\s#]*([A-Z0-9][\w\-/]+)", "kontext": "Dokumentnummer", "prio": 2}, + {"regex": r"RE-?(\d{4,})", "kontext": "RE-Nummer", "prio": 3}, + {"regex": r"INV-?(\d{4,})", "kontext": "INV-Nummer", "prio": 3}, + ] + + for pattern in nummer_patterns: + match = re.search(pattern["regex"], text, re.IGNORECASE) + if match: + erkannte_felder.append({ + "feld": "nummer", + "wert": match.group(0), + "extrahiert": match.group(1) if match.lastindex else match.group(0), + "position": match.start(), + "kontext": pattern["kontext"], + "regex_vorschlag": pattern["regex"], + "prioritaet": pattern["prio"] + }) + break + + # ============ FIRMA erkennen ============ + bekannte_firmen = [ + "Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt", + "Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", + "Telekom", "Vodafone", "O2", "1&1", + "Allianz", "HUK", "Provinzial", "DEVK", "Gothaer", + "IKEA", "Poco", "XXXLutz", "Roller", + "Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", + "DHL", "DPD", "Hermes", "UPS", "GLS", + ] + + text_lower = text.lower() + for firma in bekannte_firmen: + if firma.lower() in text_lower: + # Position im Original-Text finden + pos = text_lower.find(firma.lower()) + erkannte_felder.append({ + "feld": "firma", + "wert": firma, + "position": pos, + "kontext": "Bekannte Firma", + "regex_vorschlag": f"(?i){re.escape(firma)}", + "prioritaet": 1 + }) + break + + # Wenn keine bekannte Firma: nach GmbH, AG etc. suchen + if not any(f["feld"] == "firma" for f in erkannte_felder): + firma_match = re.search(r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", text) + if firma_match: + erkannte_felder.append({ + "feld": "firma", + "wert": firma_match.group(1).strip(), + "position": firma_match.start(), + "kontext": "Firmenname mit Rechtsform", + "regex_vorschlag": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", + "prioritaet": 2 + }) + + # ============ Keywords für Dokumenttyp extrahieren ============ + dokumenttyp_keywords = { + "rechnung": ["rechnung", "invoice", "faktura"], + "angebot": ["angebot", "quotation", "offerte"], + "gutschrift": ["gutschrift", "credit note"], + "lieferschein": ["lieferschein", "delivery note"], + } + + gefundene_keywords = [] + for typ, keywords in dokumenttyp_keywords.items(): + for kw in keywords: + if kw in text_lower: + gefundene_keywords.append(kw) + + # Regel-Vorschlag erstellen + regel_vorschlag = { + "muster": {}, + "extraktion": {}, + "schema": "{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf" + } + + if gefundene_keywords: + regel_vorschlag["muster"]["keywords"] = ", ".join(gefundene_keywords[:3]) + + for feld in erkannte_felder: + if feld["feld"] in ["datum", "betrag", "nummer"]: + regel_vorschlag["extraktion"][feld["feld"]] = { + "regex": feld["regex_vorschlag"] + } + elif feld["feld"] == "firma": + regel_vorschlag["extraktion"]["firma"] = { + "wert": feld["wert"] + } + + return { + "dateiname": datei.filename, + "text_laenge": len(text), + "erkannte_felder": erkannte_felder, + "gefundene_keywords": gefundene_keywords, + "regel_vorschlag": regel_vorschlag, + "text_vorschau": text[:3000] if len(text) > 3000 else text + } + + finally: + Path(tmp_pfad).unlink(missing_ok=True) + + +# ============ Einfache Regeln (UI-freundlich) ============ + +@router.get("/dokumenttypen") +def liste_dokumenttypen(): + """Gibt alle verfügbaren Dokumenttypen für das UI zurück""" + from ..modules.sorter import DOKUMENTTYPEN + return [ + {"id": key, "name": config["name"], "schema": config["schema"], "unterordner": config["unterordner"]} + for key, config in DOKUMENTTYPEN.items() + ] + + +class EinfacheRegelCreate(BaseModel): + name: str + dokumenttyp: str # z.B. "rechnung", "vertrag" + keywords: str # Komma-getrennt + firma: Optional[str] = None # Fester Firmenwert + unterordner: Optional[str] = None + prioritaet: int = 50 + + +@router.post("/regeln/einfach") +def erstelle_einfache_regel_api(data: EinfacheRegelCreate, db: Session = Depends(get_db)): + """Erstellt eine Regel basierend auf Dokumenttyp - für einfaches UI""" + from ..modules.sorter import DOKUMENTTYPEN + + typ_config = DOKUMENTTYPEN.get(data.dokumenttyp, DOKUMENTTYPEN["sonstiges"]) + + # Muster als Dict (keywords werden vom Sorter geparst) + muster = {"keywords": data.keywords} + + # Extraktion (nur Firma wenn angegeben) + extraktion = {} + if data.firma: + extraktion["firma"] = {"wert": data.firma} + + regel = SortierRegel( + name=data.name, + prioritaet=data.prioritaet, + aktiv=True, + muster=muster, + extraktion=extraktion, + schema=typ_config["schema"], + unterordner=data.unterordner or typ_config["unterordner"] + ) + + db.add(regel) + db.commit() + db.refresh(regel) + + return { + "id": regel.id, + "name": regel.name, + "dokumenttyp": data.dokumenttyp, + "keywords": data.keywords, + "schema": regel.schema + } + + +class ExtraktionTestRequest(BaseModel): + text: str + dateiname: Optional[str] = "test.pdf" + + +@router.post("/extraktion/test") +def teste_extraktion(data: ExtraktionTestRequest): + """Testet die automatische Extraktion auf einem Text""" + from ..modules.extraktoren import extrahiere_alle_felder, baue_dateiname + + dokument_info = { + "original_name": data.dateiname, + "absender": "" + } + + # Felder extrahieren + felder = extrahiere_alle_felder(data.text, dokument_info) + + # Beispiel-Dateinamen für verschiedene Typen generieren + beispiele = {} + from ..modules.sorter import DOKUMENTTYPEN + for typ_id, typ_config in DOKUMENTTYPEN.items(): + beispiele[typ_id] = baue_dateiname(typ_config["schema"], felder, ".pdf") + + return { + "extrahiert": felder, + "beispiel_dateinamen": beispiele + } + + +@router.post("/regeln/{id}/vorschau") +def regel_vorschau(id: int, data: ExtraktionTestRequest, db: Session = Depends(get_db)): + """Zeigt Vorschau wie eine Regel auf einen Text angewendet würde""" + regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not regel: + raise HTTPException(status_code=404, detail="Regel nicht gefunden") + + from ..modules.sorter import Sorter + + sorter = Sorter([{ + "id": regel.id, + "name": regel.name, + "prioritaet": regel.prioritaet, + "aktiv": True, + "muster": regel.muster, + "extraktion": regel.extraktion, + "schema": regel.schema, + "unterordner": regel.unterordner + }]) + + dokument_info = { + "text": data.text, + "original_name": data.dateiname or "test.pdf", + "absender": "" + } + + # Prüfen ob Regel matched + passende_regel = sorter.finde_passende_regel(dokument_info) + + if not passende_regel: + return { + "matched": False, + "grund": "Keywords nicht gefunden" + } + + # Felder extrahieren + felder = sorter.extrahiere_felder(passende_regel, dokument_info) + + # Dateiname generieren + dateiname = sorter.generiere_dateinamen(passende_regel, felder) + + return { + "matched": True, + "extrahiert": felder, + "dateiname": dateiname, + "unterordner": passende_regel.get("unterordner") + } + + +# ============ BEREICH 3: Zeitpläne / Scheduler ============ + +class ZeitplanCreate(BaseModel): + name: str + typ: str # "mail_abruf", "grobsortierung", "sortierregeln" + postfach_id: Optional[int] = None + quell_ordner_id: Optional[int] = None + regel_id: Optional[int] = None # Für typ="sortierregeln" + intervall: str # "stündlich", "täglich", "wöchentlich", "monatlich" + stunde: int = 6 + minute: int = 0 + wochentag: Optional[int] = None # 0=Montag + monatstag: Optional[int] = None + + +class ZeitplanResponse(BaseModel): + id: int + name: str + aktiv: bool + typ: str + postfach_id: Optional[int] + quell_ordner_id: Optional[int] + regel_id: Optional[int] + intervall: str + stunde: int + minute: int + wochentag: Optional[int] + monatstag: Optional[int] + letzte_ausfuehrung: Optional[datetime] + naechste_ausfuehrung: Optional[datetime] + letzter_status: Optional[str] + letzte_meldung: Optional[str] + + class Config: + from_attributes = True + + +@router.get("/zeitplaene") +def liste_zeitplaene(db: Session = Depends(get_db)): + """Listet alle Zeitpläne mit Status""" + from ..services.scheduler_service import get_scheduler_status + return get_scheduler_status() + + +@router.get("/zeitplaene/{id}", response_model=ZeitplanResponse) +def hole_zeitplan(id: int, db: Session = Depends(get_db)): + """Gibt einen einzelnen Zeitplan zurück""" + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() + if not zeitplan: + raise HTTPException(404, "Zeitplan nicht gefunden") + return zeitplan + + +@router.post("/zeitplaene", response_model=ZeitplanResponse) +def erstelle_zeitplan(data: ZeitplanCreate, db: Session = Depends(get_db)): + """Erstellt einen neuen Zeitplan""" + zeitplan = Zeitplan(**data.dict()) + db.add(zeitplan) + db.commit() + db.refresh(zeitplan) + + # Scheduler aktualisieren + from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell + sync_zeitplaene() + + # Zeitplan direkt beim Erstellen einmal ausführen + trigger_zeitplan_manuell(zeitplan.id) + + return zeitplan + + +@router.put("/zeitplaene/{id}", response_model=ZeitplanResponse) +def aktualisiere_zeitplan(id: int, data: ZeitplanCreate, db: Session = Depends(get_db)): + """Aktualisiert einen Zeitplan""" + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() + if not zeitplan: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + for key, value in data.dict().items(): + setattr(zeitplan, key, value) + + db.commit() + db.refresh(zeitplan) + + # Scheduler aktualisieren + from ..services.scheduler_service import sync_zeitplaene, trigger_zeitplan_manuell + sync_zeitplaene() + + # Zeitplan direkt beim Speichern einmal ausführen + trigger_zeitplan_manuell(zeitplan.id) + + return zeitplan + + +@router.delete("/zeitplaene/{id}") +def loesche_zeitplan(id: int, db: Session = Depends(get_db)): + """Löscht einen Zeitplan""" + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() + if not zeitplan: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + db.delete(zeitplan) + db.commit() + + # Scheduler aktualisieren + from ..services.scheduler_service import sync_zeitplaene + sync_zeitplaene() + + return {"message": "Gelöscht"} + + +@router.post("/zeitplaene/{id}/aktivieren") +def aktiviere_zeitplan(id: int, db: Session = Depends(get_db)): + """Aktiviert/Deaktiviert einen Zeitplan""" + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == id).first() + if not zeitplan: + raise HTTPException(status_code=404, detail="Nicht gefunden") + + zeitplan.aktiv = not zeitplan.aktiv + db.commit() + + # Scheduler aktualisieren + from ..services.scheduler_service import sync_zeitplaene + sync_zeitplaene() + + return {"aktiv": zeitplan.aktiv} + + +@router.post("/zeitplaene/{id}/ausfuehren") +def fuehre_zeitplan_aus(id: int, db: Session = Depends(get_db)): + """Führt einen Zeitplan sofort manuell aus""" + from ..services.scheduler_service import trigger_zeitplan_manuell + return trigger_zeitplan_manuell(id) + + +@router.get("/status/uebersicht") +def status_uebersicht(db: Session = Depends(get_db)): + """Gibt eine Übersicht über alle Services und deren Status""" + from ..services.scheduler_service import get_scheduler_status + + # Postfächer Status + postfaecher = db.query(Postfach).all() + postfach_status = [{ + "id": p.id, + "name": p.name, + "aktiv": p.aktiv, + "letzter_abruf": p.letzter_abruf.isoformat() if p.letzter_abruf else None, + "letzte_anzahl": p.letzte_anzahl + } for p in postfaecher] + + # Quellordner Status + ordner = db.query(QuellOrdner).all() + ordner_status = [{ + "id": o.id, + "name": o.name, + "aktiv": o.aktiv, + "pfad": o.pfad + } for o in ordner] + + # Scheduler Status + scheduler_status = get_scheduler_status() + + return { + "postfaecher": postfach_status, + "quell_ordner": ordner_status, + "scheduler": scheduler_status + } diff --git a/backend/app/services/__init__.py b/Source/backend/app/services/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/services/__init__.py rename to Source/backend/app/services/__init__.py diff --git a/backend/app/services/pipeline_service.py b/Source/backend/app/services/pipeline_service.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/services/pipeline_service.py rename to Source/backend/app/services/pipeline_service.py diff --git a/Source/backend/app/services/scheduler_service.py b/Source/backend/app/services/scheduler_service.py new file mode 100755 index 0000000..8637915 --- /dev/null +++ b/Source/backend/app/services/scheduler_service.py @@ -0,0 +1,909 @@ +""" +Scheduler-Service für automatische Ausführung von Aufgaben +""" +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from datetime import datetime +import logging +import os +import stat +from pathlib import Path +from typing import Optional, Dict, List +from zoneinfo import ZoneInfo + +from ..models.database import SessionLocal, Zeitplan, Postfach, QuellOrdner +from ..modules.mail_fetcher import MailFetcher +from ..modules.sorter import Sorter +from ..config import INBOX_DIR + +logger = logging.getLogger(__name__) + + +def check_folder_permissions(pfad: str, name: str = "") -> Dict: + """ + Prüft Ordner-Berechtigungen und gibt detaillierte Infos zurück. + Gibt Debug-Infos auf stdout aus für Container-Logs. + """ + result = { + "pfad": pfad, + "name": name, + "existiert": False, + "lesbar": False, + "schreibbar": False, + "ist_ordner": False, + "dateien_anzahl": 0, + "fehler": None + } + + prefix = f"[DEBUG] [{name}]" if name else "[DEBUG]" + + try: + p = Path(pfad) + result["existiert"] = p.exists() + + if not p.exists(): + print(f"{prefix} ❌ Pfad existiert NICHT: {pfad}", flush=True) + result["fehler"] = "Pfad existiert nicht" + return result + + result["ist_ordner"] = p.is_dir() + if not p.is_dir(): + print(f"{prefix} ❌ Pfad ist KEIN Ordner: {pfad}", flush=True) + result["fehler"] = "Pfad ist kein Ordner" + return result + + # Berechtigungen prüfen + result["lesbar"] = os.access(pfad, os.R_OK) + result["schreibbar"] = os.access(pfad, os.W_OK) + + # Dateien zählen + try: + dateien = list(p.glob("*")) + result["dateien_anzahl"] = len([f for f in dateien if f.is_file()]) + except PermissionError: + result["dateien_anzahl"] = -1 + + # Stat-Infos + try: + st = p.stat() + mode = stat.filemode(st.st_mode) + uid = st.st_uid + gid = st.st_gid + except Exception: + mode = "?" + uid = "?" + gid = "?" + + # Status-Symbol + if result["lesbar"] and result["schreibbar"]: + status = "✅" + elif result["lesbar"]: + status = "⚠️ NUR LESBAR" + else: + status = "❌ KEIN ZUGRIFF" + + print(f"{prefix} {status} {pfad}", flush=True) + print(f"{prefix} Rechte: {mode} | UID: {uid} | GID: {gid} | Dateien: {result['dateien_anzahl']}", flush=True) + + if not result["schreibbar"]: + result["fehler"] = "Keine Schreibrechte" + + except Exception as e: + print(f"{prefix} ❌ FEHLER beim Prüfen: {pfad} - {e}", flush=True) + result["fehler"] = str(e) + + return result + + +def check_all_folders_on_startup(): + """Prüft alle konfigurierten Ordner beim Start""" + print("", flush=True) + print("=" * 60, flush=True) + print("[DEBUG] === ORDNER-BERECHTIGUNGEN PRÜFEN ===", flush=True) + print("=" * 60, flush=True) + + # Prozess-Info + print(f"[DEBUG] Prozess läuft als UID: {os.getuid()}, GID: {os.getgid()}", flush=True) + + # Umgebungsvariablen für PUID/PGID (Unraid-Style) + puid = os.environ.get("PUID", "nicht gesetzt") + pgid = os.environ.get("PGID", "nicht gesetzt") + print(f"[DEBUG] Umgebung: PUID={puid}, PGID={pgid}", flush=True) + + # Aktueller User + try: + import pwd + import grp + user_info = pwd.getpwuid(os.getuid()) + group_info = grp.getgrgid(os.getgid()) + print(f"[DEBUG] User: {user_info.pw_name}, Gruppe: {group_info.gr_name}", flush=True) + except Exception: + print("[DEBUG] User/Gruppe konnte nicht ermittelt werden", flush=True) + + db = SessionLocal() + try: + # Quellordner prüfen + quell_ordner = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() + print(f"[DEBUG] Gefunden: {len(quell_ordner)} aktive Quellordner", flush=True) + print("", flush=True) + + for qo in quell_ordner: + print(f"[DEBUG] --- {qo.name} ---", flush=True) + # Quellpfad prüfen + check_folder_permissions(qo.pfad, f"{qo.name}/Quelle") + # Zielpfad prüfen + check_folder_permissions(qo.ziel_ordner, f"{qo.name}/Ziel") + print("", flush=True) + + # Postfächer Zielordner prüfen + postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() + print(f"[DEBUG] Gefunden: {len(postfaecher)} aktive Postfächer", flush=True) + + for pf in postfaecher: + if pf.ziel_ordner: + check_folder_permissions(pf.ziel_ordner, f"Postfach/{pf.name}") + + print("=" * 60, flush=True) + print("", flush=True) + + except Exception as e: + print(f"[DEBUG] ❌ Fehler bei Ordner-Prüfung: {e}", flush=True) + finally: + db.close() + +# Globaler Scheduler +scheduler: Optional[BackgroundScheduler] = None + +# Timezone für Scheduler (robust gegen ungültige TZ-Variablen) +def get_timezone(): + """Ermittelt eine gültige Timezone""" + tz_env = os.environ.get("TZ", "Europe/Berlin") + # Prüfen ob gültiger Timezone-String (nicht ${...} oder ähnlich) + if tz_env.startswith("$") or "/" not in tz_env: + tz_env = "Europe/Berlin" + try: + return ZoneInfo(tz_env) + except Exception: + return ZoneInfo("Europe/Berlin") + + +def get_scheduler() -> BackgroundScheduler: + """Gibt den globalen Scheduler zurück""" + global scheduler + if scheduler is None: + scheduler = BackgroundScheduler(timezone=get_timezone()) + return scheduler + + +def init_scheduler(): + """Initialisiert den Scheduler beim App-Start""" + global scheduler + scheduler = BackgroundScheduler(timezone=get_timezone()) + + # Ordner-Berechtigungen beim Start prüfen + check_all_folders_on_startup() + + # Zeitpläne aus DB laden und Jobs erstellen + sync_zeitplaene() + + scheduler.start() + logger.info("Scheduler gestartet") + + # Überfällige Zeitpläne beim Start ausführen (asynchron nach 5 Sekunden) + import threading + def delayed_overdue_check(): + import time + time.sleep(5) # Warte bis App vollständig gestartet + execute_overdue_zeitplaene() + + thread = threading.Thread(target=delayed_overdue_check, daemon=True) + thread.start() + + +def execute_overdue_zeitplaene(): + """Führt alle überfälligen Zeitpläne aus""" + from datetime import datetime + + print("Prüfe überfällige Zeitpläne...", flush=True) + db = SessionLocal() + try: + zeitplaene = db.query(Zeitplan).filter(Zeitplan.aktiv == True).all() + now = datetime.utcnow() + print(f"Gefunden: {len(zeitplaene)} aktive Zeitpläne, aktuelle Zeit (UTC): {now}", flush=True) + + for zp in zeitplaene: + print(f" Zeitplan '{zp.name}': nächste={zp.naechste_ausfuehrung}, letzte={zp.letzte_ausfuehrung}", flush=True) + # Prüfen ob überfällig (naechste_ausfuehrung liegt in der Vergangenheit) + if zp.naechste_ausfuehrung and zp.naechste_ausfuehrung < now: + print(f" -> Führe überfälligen Zeitplan aus: {zp.name}", flush=True) + try: + execute_zeitplan(zp.id) + except Exception as e: + print(f" -> Fehler bei überfälligem Zeitplan {zp.name}: {e}", flush=True) + # Oder: Noch nie ausgeführt + elif zp.letzte_ausfuehrung is None: + print(f" -> Führe noch nie ausgeführten Zeitplan aus: {zp.name}", flush=True) + try: + execute_zeitplan(zp.id) + except Exception as e: + print(f" -> Fehler bei Zeitplan {zp.name}: {e}", flush=True) + else: + print(f" -> Nicht überfällig", flush=True) + except Exception as e: + print(f"Fehler bei Zeitplan-Prüfung: {e}", flush=True) + finally: + db.close() + + +def shutdown_scheduler(): + """Beendet den Scheduler""" + global scheduler + if scheduler: + scheduler.shutdown() + logger.info("Scheduler beendet") + + +def sync_zeitplaene(): + """Synchronisiert Zeitpläne aus der DB mit dem Scheduler""" + global scheduler + if not scheduler: + return + + # Alle bestehenden Jobs entfernen + scheduler.remove_all_jobs() + + db = SessionLocal() + try: + zeitplaene = db.query(Zeitplan).filter(Zeitplan.aktiv == True).all() + + for zp in zeitplaene: + add_job_for_zeitplan(zp) + logger.info(f"Job hinzugefügt: {zp.name} ({zp.intervall})") + finally: + db.close() + + +def add_job_for_zeitplan(zp: Zeitplan): + """Fügt einen Job für einen Zeitplan hinzu""" + global scheduler + if not scheduler: + return + + job_id = f"zeitplan_{zp.id}" + + # CronTrigger basierend auf Intervall erstellen + trigger = create_trigger(zp) + if not trigger: + return + + # Job hinzufügen + scheduler.add_job( + func=execute_zeitplan, + trigger=trigger, + args=[zp.id], + id=job_id, + name=zp.name, + replace_existing=True + ) + + # Nächste Ausführungszeit berechnen und speichern + job = scheduler.get_job(job_id) + if job: + try: + # APScheduler 3.x: next_run_time als Attribut + # APScheduler 4.x: get_next_fire_time() oder scheduled_fire_time + next_time = getattr(job, 'next_run_time', None) + if next_time is None and hasattr(job, 'get_next_fire_time'): + next_time = job.get_next_fire_time() + + if next_time: + db = SessionLocal() + try: + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == zp.id).first() + if zeitplan: + zeitplan.naechste_ausfuehrung = next_time + db.commit() + finally: + db.close() + except Exception as e: + logger.warning(f"Konnte nächste Ausführungszeit nicht ermitteln: {e}") + + +def create_trigger(zp: Zeitplan) -> Optional[CronTrigger]: + """Erstellt einen CronTrigger basierend auf dem Zeitplan""" + try: + tz = get_timezone() + if zp.intervall == "stündlich": + return CronTrigger(minute=zp.minute or 0, timezone=tz) + + elif zp.intervall == "täglich": + return CronTrigger(hour=zp.stunde or 6, minute=zp.minute or 0, timezone=tz) + + elif zp.intervall == "wöchentlich": + return CronTrigger( + day_of_week=zp.wochentag or 0, + hour=zp.stunde or 6, + minute=zp.minute or 0, + timezone=tz + ) + + elif zp.intervall == "monatlich": + return CronTrigger( + day=zp.monatstag or 1, + hour=zp.stunde or 6, + minute=zp.minute or 0, + timezone=tz + ) + + return None + except Exception as e: + logger.error(f"Fehler beim Erstellen des Triggers: {e}") + return None + + +def execute_zeitplan(zeitplan_id: int): + """Führt einen Zeitplan aus""" + db = SessionLocal() + try: + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == zeitplan_id).first() + if not zeitplan: + return + + logger.info(f"Starte Zeitplan: {zeitplan.name}") + + try: + if zeitplan.typ == "mail_abruf": + result = execute_mail_abruf(db, zeitplan) + elif zeitplan.typ == "grobsortierung": + result = execute_grobsortierung(db, zeitplan) + elif zeitplan.typ == "sortierregeln": + result = execute_sortierregeln(db, zeitplan) + elif zeitplan.typ == "sortierung": + # Legacy: alte "sortierung" wird wie "grobsortierung" behandelt + result = execute_grobsortierung(db, zeitplan) + else: + result = {"erfolg": False, "meldung": f"Unbekannter Typ: {zeitplan.typ}"} + + # Status aktualisieren + zeitplan.letzte_ausfuehrung = datetime.utcnow() + zeitplan.letzter_status = "erfolg" if result.get("erfolg") else "fehler" + zeitplan.letzte_meldung = result.get("meldung", "")[:500] + + # Nächste Ausführung berechnen + job = scheduler.get_job(f"zeitplan_{zeitplan_id}") + if job: + try: + next_time = getattr(job, 'next_run_time', None) + if next_time is None and hasattr(job, 'get_next_fire_time'): + next_time = job.get_next_fire_time() + if next_time: + zeitplan.naechste_ausfuehrung = next_time + except Exception: + pass + + db.commit() + logger.info(f"Zeitplan abgeschlossen: {zeitplan.name} - {zeitplan.letzter_status}") + + except Exception as e: + zeitplan.letzte_ausfuehrung = datetime.utcnow() + zeitplan.letzter_status = "fehler" + zeitplan.letzte_meldung = str(e)[:500] + db.commit() + logger.error(f"Fehler bei Zeitplan {zeitplan.name}: {e}") + + finally: + db.close() + + +def execute_mail_abruf(db, zeitplan: Zeitplan) -> Dict: + """Führt Mail-Abruf aus""" + from ..models.database import VerarbeiteteMail + + # Postfächer bestimmen + if zeitplan.postfach_id: + postfaecher = db.query(Postfach).filter( + Postfach.id == zeitplan.postfach_id, + Postfach.aktiv == True + ).all() + else: + postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() + + if not postfaecher: + return {"erfolg": True, "meldung": "Keine aktiven Postfächer gefunden"} + + gesamt_dateien = 0 + fehler = [] + + for postfach in postfaecher: + try: + # Bereits verarbeitete Message-IDs laden + bereits_verarbeitet = set( + m.message_id for m in db.query(VerarbeiteteMail) + .filter(VerarbeiteteMail.postfach_id == postfach.id) + .all() + ) + + config = { + "imap_server": postfach.imap_server, + "imap_port": postfach.imap_port, + "email": postfach.email, + "passwort": postfach.passwort, + "ordner": postfach.ordner, + "erlaubte_typen": postfach.erlaubte_typen or [".pdf"], + "max_groesse_mb": postfach.max_groesse_mb or 25, + "min_groesse_kb": postfach.min_groesse_kb or 10 + } + + fetcher = MailFetcher(config) + if not fetcher.connect(): + fehler.append(f"{postfach.name}: Verbindung fehlgeschlagen") + continue + + from pathlib import Path + ziel = Path(postfach.ziel_ordner) if postfach.ziel_ordner else INBOX_DIR + + ergebnisse = fetcher.fetch_attachments( + ziel_ordner=ziel, + nur_ungelesen=postfach.nur_ungelesen, + markiere_gelesen=True, + alle_ordner=postfach.alle_ordner, + bereits_verarbeitet=bereits_verarbeitet + ) + + # Verarbeitete Mails speichern + for ergebnis in ergebnisse: + if ergebnis.get("message_id"): + db.add(VerarbeiteteMail( + postfach_id=postfach.id, + message_id=ergebnis["message_id"], + ordner=ergebnis.get("ordner"), + betreff=ergebnis.get("betreff", "")[:500], + absender=ergebnis.get("absender", "")[:255], + anzahl_attachments=1 + )) + + # Postfach-Status aktualisieren + postfach.letzter_abruf = datetime.utcnow() + postfach.letzte_anzahl = len(ergebnisse) + db.commit() + + gesamt_dateien += len(ergebnisse) + fetcher.disconnect() + + except Exception as e: + fehler.append(f"{postfach.name}: {str(e)[:100]}") + + if fehler: + return { + "erfolg": len(fehler) < len(postfaecher), + "meldung": f"{gesamt_dateien} Dateien geholt. Fehler: {'; '.join(fehler)}" + } + + return {"erfolg": True, "meldung": f"{gesamt_dateien} Dateien aus {len(postfaecher)} Postfächern geholt"} + + +def execute_grobsortierung(db, zeitplan: Zeitplan) -> Dict: + """Führt Grobsortierung aus (QuellOrdner verarbeiten)""" + from ..models.database import SortierRegel, VerarbeiteteDatei + from ..modules.pdf_processor import PDFProcessor + from pathlib import Path + + print("", flush=True) + print("[GROBSORTIERUNG] === START ===", flush=True) + print(f"[GROBSORTIERUNG] Zeitplan: {zeitplan.name} (ID: {zeitplan.id})", flush=True) + + # QuellOrdner bestimmen + if zeitplan.quell_ordner_id: + quell_ordner = db.query(QuellOrdner).filter( + QuellOrdner.id == zeitplan.quell_ordner_id, + QuellOrdner.aktiv == True + ).all() + else: + quell_ordner = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() + + print(f"[GROBSORTIERUNG] Gefunden: {len(quell_ordner)} aktive Quellordner", flush=True) + + if not quell_ordner: + print("[GROBSORTIERUNG] ⚠️ Keine aktiven Quellordner - Abbruch", flush=True) + return {"erfolg": True, "meldung": "Keine aktiven Quellordner gefunden"} + + # Regeln laden + 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) + return {"erfolg": False, "meldung": "Keine aktiven Regeln definiert"} + + # Regeln in Dict-Format + regeln_dicts = [{ + "id": r.id, + "name": r.name, + "prioritaet": r.prioritaet, + "muster": r.muster, + "extraktion": r.extraktion, + "schema": r.schema, + "unterordner": r.unterordner + } for r in regeln] + + sorter = Sorter(regeln_dicts) + pdf_processor = PDFProcessor() + + gesamt_sortiert = 0 + gesamt_fehler = 0 + gesamt_ohne_regel = 0 + fehler_meldungen = [] + + for qo in quell_ordner: + ordner_sortiert = 0 # Zähler pro Ordner + print("", flush=True) + print(f"[GROBSORTIERUNG] --- Verarbeite: {qo.name} ---", flush=True) + print(f"[GROBSORTIERUNG] Quelle: {qo.pfad}", flush=True) + print(f"[GROBSORTIERUNG] Ziel: {qo.ziel_ordner}", flush=True) + print(f"[GROBSORTIERUNG] Einstellungen: direkt_verschieben={qo.direkt_verschieben}, zugferd={qo.zugferd_behandlung}", flush=True) + + try: + pfad = Path(qo.pfad) + + # Debug: Ordner-Checks + quelle_check = check_folder_permissions(str(pfad), f"{qo.name}/Quelle") + ziel_check = check_folder_permissions(qo.ziel_ordner, f"{qo.name}/Ziel") + + if not pfad.exists(): + print(f"[GROBSORTIERUNG] ❌ Quellpfad existiert nicht - überspringe", flush=True) + fehler_meldungen.append(f"{qo.name}: Quellpfad existiert nicht") + continue + + if not quelle_check["lesbar"]: + print(f"[GROBSORTIERUNG] ❌ Quellpfad nicht lesbar - überspringe", flush=True) + fehler_meldungen.append(f"{qo.name}: Keine Leserechte auf Quelle") + continue + + if not ziel_check["schreibbar"]: + print(f"[GROBSORTIERUNG] ❌ Zielpfad nicht beschreibbar - überspringe", flush=True) + fehler_meldungen.append(f"{qo.name}: Keine Schreibrechte auf Ziel") + continue + + ziel_basis = Path(qo.ziel_ordner) + + # Dateien sammeln + pattern = "**/*" if qo.rekursiv else "*" + erlaubte = [t.lower() for t in (qo.dateitypen or [".pdf"])] + print(f"[GROBSORTIERUNG] Suche nach: {erlaubte} (rekursiv={qo.rekursiv})", flush=True) + + dateien = [f for f in pfad.glob(pattern) if f.is_file() and f.suffix.lower() in erlaubte] + print(f"[GROBSORTIERUNG] ✓ Gefunden: {len(dateien)} Dateien", flush=True) + + if len(dateien) == 0: + print(f"[GROBSORTIERUNG] Keine passenden Dateien im Ordner", flush=True) + + for datei in dateien: + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + + if ist_pdf: + pdf_result = pdf_processor.verarbeite(str(datei)) + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + text = pdf_result.get("text", "") + + # ZUGFeRD-Behandlung basierend auf Einstellung + # Optionen: "separieren", "regel", "normal", "ignorieren" + zugferd_behandlung = getattr(qo, 'zugferd_behandlung', 'normal') or 'normal' + ist_zugferd = pdf_result.get("ist_zugferd", False) + + if zugferd_behandlung == "separieren": + # NUR ZUGFeRD-PDFs verarbeiten + if not ist_zugferd: + # Keine ZUGFeRD-PDF -> überspringen + continue + # ZUGFeRD-PDF in separaten Ordner verschieben + zugferd_ziel = ziel_basis / "zugferd" + zugferd_ziel.mkdir(parents=True, exist_ok=True) + neuer_pfad = zugferd_ziel / datei.name + counter = 1 + while neuer_pfad.exists(): + neuer_pfad = zugferd_ziel / f"{datei.stem}_{counter}{datei.suffix}" + counter += 1 + datei.rename(neuer_pfad) + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(neuer_pfad), + neuer_name=neuer_pfad.name, + ist_zugferd=True, + status="zugferd" + )) + gesamt_sortiert += 1 + ordner_sortiert += 1 + continue + + elif zugferd_behandlung == "ignorieren": + # ZUGFeRD-PDFs überspringen, nur normale verarbeiten + if ist_zugferd: + continue + # Weiter mit Regelprüfung für normale PDFs + + # Bei "regel" oder "normal": Alle PDFs durch Regeln prüfen + + # Direkt verschieben (ohne Regelprüfung)? + direkt_verschieben = getattr(qo, 'direkt_verschieben', False) + if direkt_verschieben: + # Datei direkt in Zielordner verschieben + print(f"[GROBSORTIERUNG] → Verschiebe direkt: {datei.name}", flush=True) + try: + ziel_basis.mkdir(parents=True, exist_ok=True) + except PermissionError as pe: + print(f"[GROBSORTIERUNG] ❌ Kann Zielordner nicht erstellen: {pe}", flush=True) + raise + neuer_pfad = ziel_basis / datei.name + counter = 1 + while neuer_pfad.exists(): + neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}" + counter += 1 + try: + datei.rename(neuer_pfad) + print(f"[GROBSORTIERUNG] ✓ Verschoben nach: {neuer_pfad}", flush=True) + except PermissionError as pe: + print(f"[GROBSORTIERUNG] ❌ Keine Berechtigung zum Verschieben: {pe}", flush=True) + raise + except Exception as me: + print(f"[GROBSORTIERUNG] ❌ Fehler beim Verschieben: {me}", flush=True) + raise + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(neuer_pfad), + neuer_name=neuer_pfad.name, + status="direkt" + )) + gesamt_sortiert += 1 + ordner_sortiert += 1 + continue + + # Regel finden + doc_info = {"text": text, "original_name": datei.name, "absender": "", "dateityp": datei.suffix.lower()} + regel = sorter.finde_passende_regel(doc_info) + + if not regel: + gesamt_ohne_regel += 1 + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + status="keine_regel", + fehler="Keine passende Regel gefunden" + )) + continue + + # Felder extrahieren und verschieben + extrahiert = 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.generiere_dateinamen({"schema": schema, **regel}, extrahiert) + + ziel = ziel_basis + if regel.get("unterordner"): + ziel = ziel / regel["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + gesamt_sortiert += 1 + ordner_sortiert += 1 + + except Exception as e: + gesamt_fehler += 1 + print(f"[GROBSORTIERUNG] ❌ FEHLER bei {datei.name}: {e}", flush=True) + logger.error(f"Fehler bei Datei {datei}: {e}") + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + status="fehler", + fehler=str(e)[:500] + )) + + # Ordner-Status aktualisieren (wie bei Postfächern) + qo.letzte_verarbeitung = datetime.utcnow() + qo.letzte_anzahl = ordner_sortiert + print(f"[GROBSORTIERUNG] ✓ {qo.name} abgeschlossen: {ordner_sortiert} Dateien verschoben", flush=True) + + except Exception as e: + print(f"[GROBSORTIERUNG] ❌ FEHLER bei Ordner {qo.name}: {e}", flush=True) + fehler_meldungen.append(f"{qo.name}: {str(e)[:100]}") + + db.commit() + + meldung = f"{gesamt_sortiert} Dateien sortiert" + if gesamt_ohne_regel > 0: + meldung += f", {gesamt_ohne_regel} ohne passende Regel" + if gesamt_fehler > 0: + meldung += f", {gesamt_fehler} Fehler" + if fehler_meldungen: + meldung += f" ({'; '.join(fehler_meldungen)})" + + print("", flush=True) + print(f"[GROBSORTIERUNG] === ENDE === {meldung}", flush=True) + print("", flush=True) + + # Erfolg wenn keine echten Fehler (ohne_regel zählt nicht als Fehler) + return {"erfolg": gesamt_fehler == 0 and not fehler_meldungen, "meldung": meldung} + + +def execute_sortierregeln(db, zeitplan: Zeitplan) -> Dict: + """Führt nur Sortierregeln aus (freie Ordner von Regeln)""" + from ..models.database import SortierRegel, VerarbeiteteDatei + from ..modules.pdf_processor import PDFProcessor + from pathlib import Path + + # Regeln laden (optional spezifische Regel) + if zeitplan.regel_id: + regeln = db.query(SortierRegel).filter( + SortierRegel.id == zeitplan.regel_id, + SortierRegel.aktiv == True + ).all() + else: + regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).all() + + if not regeln: + return {"erfolg": True, "meldung": "Keine aktiven Regeln gefunden"} + + pdf_processor = PDFProcessor() + gesamt_sortiert = 0 + gesamt_fehler = 0 + fehler_meldungen = [] + + for regel in regeln: + freie_ordner = regel.freie_ordner if regel.freie_ordner else [] + if not freie_ordner: + continue + + regel_dict = { + "id": regel.id, + "name": regel.name, + "prioritaet": regel.prioritaet, + "muster": regel.muster, + "extraktion": regel.extraktion, + "schema": regel.schema, + "unterordner": regel.unterordner, + "ziel_ordner": getattr(regel, 'ziel_ordner', None), + "nur_umbenennen": getattr(regel, 'nur_umbenennen', False) + } + regel_sorter = Sorter([regel_dict]) + + for freier_ordner_pfad in freie_ordner: + freier_pfad = Path(freier_ordner_pfad) + if not freier_pfad.exists() or not freier_pfad.is_dir(): + continue + + # Dateien sammeln + dateien = [f for f in freier_pfad.glob("**/*") if f.is_file() and f.suffix.lower() == ".pdf"] + + for datei in dateien: + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + + if ist_pdf: + pdf_result = pdf_processor.verarbeite(str(datei)) + if pdf_result.get("fehler"): + raise Exception(pdf_result["fehler"]) + text = pdf_result.get("text", "") + ist_zugferd = pdf_result.get("ist_zugferd", False) + + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower() + } + + # Prüfe ob Regel passt + passend = regel_sorter.finde_passende_regel(doc_info) + if not passend: + continue + + # Felder extrahieren + extrahiert = regel_sorter.extrahiere_felder(passend, doc_info) + + # Dateiname generieren + schema = passend.get("schema", "{datum} - Dokument.pdf") + if schema.endswith(".pdf"): + schema = schema[:-4] + datei.suffix + neuer_name = regel_sorter.generiere_dateinamen( + {"schema": schema, **passend}, extrahiert + ) + + # Zielordner bestimmen + if passend.get("nur_umbenennen"): + # Nur umbenennen - Datei bleibt im aktuellen Ordner + ziel = datei.parent + elif passend.get("ziel_ordner"): + # Regel hat eigenen Zielordner + ziel = Path(passend["ziel_ordner"]) + if passend.get("unterordner"): + ziel = ziel / passend["unterordner"] + else: + # Kein Zielordner - bleibt im freien Ordner + ziel = freier_pfad + if passend.get("unterordner"): + ziel = ziel / passend["unterordner"] + ziel.mkdir(parents=True, exist_ok=True) + + # Verschieben/Umbenennen + regel_sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + gesamt_sortiert += 1 + + db.add(VerarbeiteteDatei( + original_pfad=str(datei), + original_name=datei.name, + neuer_pfad=str(ziel / neuer_name), + neuer_name=neuer_name, + ist_zugferd=ist_zugferd, + status="sortiert", + extrahierte_daten=extrahiert + )) + + except Exception as e: + gesamt_fehler += 1 + logger.error(f"Fehler bei Datei {datei}: {e}") + + db.commit() + + meldung = f"{gesamt_sortiert} Dateien mit Regeln sortiert" + if gesamt_fehler > 0: + meldung += f", {gesamt_fehler} Fehler" + if fehler_meldungen: + meldung += f" ({'; '.join(fehler_meldungen)})" + + return {"erfolg": gesamt_fehler == 0, "meldung": meldung} + + +def get_scheduler_status() -> Dict: + """Gibt den Status aller Zeitpläne zurück""" + global scheduler + + db = SessionLocal() + try: + zeitplaene = db.query(Zeitplan).all() + + result = [] + for zp in zeitplaene: + job = scheduler.get_job(f"zeitplan_{zp.id}") if scheduler else None + + result.append({ + "id": zp.id, + "name": zp.name, + "typ": zp.typ, + "intervall": zp.intervall, + "aktiv": zp.aktiv, + "letzte_ausfuehrung": zp.letzte_ausfuehrung.isoformat() if zp.letzte_ausfuehrung else None, + "naechste_ausfuehrung": zp.naechste_ausfuehrung.isoformat() if zp.naechste_ausfuehrung else None, + "letzter_status": zp.letzter_status, + "letzte_meldung": zp.letzte_meldung, + "job_aktiv": job is not None + }) + + return { + "scheduler_laeuft": scheduler is not None and scheduler.running if scheduler else False, + "zeitplaene": result + } + finally: + db.close() + + +def trigger_zeitplan_manuell(zeitplan_id: int) -> Dict: + """Löst einen Zeitplan manuell aus""" + db = SessionLocal() + try: + zeitplan = db.query(Zeitplan).filter(Zeitplan.id == zeitplan_id).first() + if not zeitplan: + return {"erfolg": False, "meldung": "Zeitplan nicht gefunden"} + + # Synchron ausführen + execute_zeitplan(zeitplan_id) + + return {"erfolg": True, "meldung": f"Zeitplan '{zeitplan.name}' wurde ausgeführt"} + finally: + db.close() diff --git a/backend/app/utils/__init__.py b/Source/backend/app/utils/__init__.py old mode 100644 new mode 100755 similarity index 100% rename from backend/app/utils/__init__.py rename to Source/backend/app/utils/__init__.py diff --git a/backend/requirements.txt b/Source/backend/requirements.txt old mode 100644 new mode 100755 similarity index 84% rename from backend/requirements.txt rename to Source/backend/requirements.txt index 9e88941..e3f4b7e --- a/backend/requirements.txt +++ b/Source/backend/requirements.txt @@ -7,6 +7,7 @@ jinja2==3.1.3 # Database sqlalchemy==2.0.25 aiosqlite==0.19.0 +pymysql==1.1.0 # PDF Processing pypdf==4.0.1 @@ -18,3 +19,6 @@ factur-x==3.0 # Utilities pydantic==2.6.1 python-dotenv==1.0.1 + +# Scheduler +apscheduler==3.10.4 diff --git a/Source/docker-compose-unraid.yml b/Source/docker-compose-unraid.yml new file mode 100755 index 0000000..5c6f484 --- /dev/null +++ b/Source/docker-compose-unraid.yml @@ -0,0 +1,56 @@ +version: '3.8' + +# Unraid Docker Compose für Dateiverwaltung +# ========================================== +# Projektpfad auf Unraid: /mnt/user/17 - Entwicklungen/20 - Projekte/Dateiverwaltung/ +# +# WICHTIG: Image muss vorher per SSH gebaut werden! +# +# Verwendung (SSH auf Unraid): +# 1. Image bauen: +# cd "/mnt/user/17 - Entwicklungen/20 - Projekte/Dateiverwaltung" +# docker build -t dateiverwaltung:local . +# +# 2. In Portainer: Stack deployen (oder per SSH): +# docker-compose -f docker-compose-unraid.yml up -d +# +# 3. Nach Code-Änderungen: Schritt 1 + 2 wiederholen + +services: + dateiverwaltung: + image: dateiverwaltung:local + container_name: dateiverwaltung + restart: unless-stopped + + ports: + - "8080:8000" + + volumes: + # Persistente Daten (Datenbank) + - /mnt/user/appdata/firma/dateiverwaltung/data:/app/data + + # Regeln-Konfiguration + - /mnt/user/appdata/firma/dateiverwaltung/regeln:/app/regeln + + # Zugriff auf alle Unraid Shares + - /mnt/user:/mnt/user + + environment: + - TZ=Europe/Berlin + - DATABASE_URL=mysql+pymysql://data:8715@192.168.155.83/dateiverwaltung + - PUID=99 + - PGID=100 + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + labels: + - "net.unraid.docker.managed=dockerman" + +networks: + default: + name: dateiverwaltung-net diff --git a/docker-compose.yml b/Source/docker-compose.yml old mode 100644 new mode 100755 similarity index 70% rename from docker-compose.yml rename to Source/docker-compose.yml index 60de501..79d4f3e --- a/docker-compose.yml +++ b/Source/docker-compose.yml @@ -6,17 +6,15 @@ services: container_name: dateiverwaltung restart: unless-stopped ports: - - "8000:8000" + - "8080:8000" volumes: - # Persistente Daten - - ./data:/app/data - # Regeln können außerhalb bearbeitet werden + # Regeln mounten - ./regeln:/app/regeln - # Archiv auf Host mounten (optional, für direkten Zugriff) - # - /mnt/user/archiv:/archiv + # Zugriff auf externe Mounts (NAS, etc.) + - /mnt:/mnt environment: - TZ=Europe/Berlin - - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + - DATABASE_URL=mysql+pymysql://data:8715@192.168.155.83/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 new file mode 100755 index 0000000..3359ef3 --- /dev/null +++ b/Source/frontend/static/css/style.css @@ -0,0 +1,1521 @@ +/* ============ Variables ============ */ +:root { + --primary: #3b82f6; + --primary-dark: #2563eb; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --bg: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text: #f1f5f9; + --text-secondary: #94a3b8; + --border: #475569; + --radius: 8px; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +/* ============ Reset & Base ============ */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +/* ============ Layout ============ */ +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + background: var(--bg-secondary); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.header h1 { + font-size: 1.25rem; + font-weight: 600; +} + +.main-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + flex: 1; + background: var(--border); +} + +@media (max-width: 1600px) { + .main-container { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 1000px) { + .main-container { + grid-template-columns: 1fr; + } +} + +/* ============ Bereiche ============ */ +.bereich { + background: var(--bg); + display: flex; + flex-direction: column; +} + +.bereich-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.bereich-header h2 { + font-size: 1.25rem; + margin-bottom: 0.25rem; +} + +.bereich-desc { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.bereich-content { + padding: 1rem; + flex: 1; + overflow-y: auto; +} + +/* ============ Buttons ============ */ +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-tertiary); + color: var(--text); +} + +.btn:hover { + filter: brightness(1.1); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.btn-large { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* ============ Cards ============ */ +.card { + background: var(--bg-secondary); + border-radius: var(--radius); + margin-bottom: 1rem; + overflow: hidden; +} + +.card-header { + padding: 0.75rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.card-header h3 { + font-size: 0.875rem; + font-weight: 500; +} + +.card-body { + padding: 1rem; +} + +/* ============ Action Bar ============ */ +.action-bar { + padding: 1rem; + text-align: center; + background: var(--bg-secondary); + border-radius: var(--radius); + margin-bottom: 1rem; +} + +/* ============ Config Items ============ */ +.config-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: var(--radius); + margin-bottom: 0.5rem; +} + +.config-item:last-child { + margin-bottom: 0; +} + +.config-item-info h4 { + font-size: 0.875rem; + margin-bottom: 0.125rem; +} + +.config-item-info small { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.config-item-actions { + display: flex; + gap: 0.5rem; +} + +/* ============ Forms ============ */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-tertiary); + color: var(--text); + font-size: 0.875rem; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +.form-group small { + display: block; + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.code-input { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.8rem; +} + +/* ============ Log Output ============ */ +.log-output { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.8rem; + max-height: 350px; + min-height: 100px; + overflow-y: auto; +} + +.log-entry { + padding: 0.5rem; + border-radius: 4px; + margin-bottom: 0.25rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.log-entry.success { + background: rgba(34, 197, 94, 0.2); + border-left: 3px solid var(--success); +} + +.log-entry.error { + background: rgba(239, 68, 68, 0.2); + border-left: 3px solid var(--danger); +} + +.log-entry.info { + background: rgba(59, 130, 246, 0.2); + border-left: 3px solid var(--primary); +} + +.empty-state { + color: var(--text-secondary); + text-align: center; + padding: 1rem; + font-size: 0.875rem; +} + +/* ============ Modals ============ */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-secondary); + border-radius: var(--radius); + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-large { + max-width: 700px; +} + +/* ============ Dialog-Modal (Alert/Confirm) ============ */ +.dialog-modal-content { + max-width: 400px; + text-align: center; +} + +.dialog-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.dialog-icon.success { color: var(--success); } +.dialog-icon.error { color: var(--danger); } +.dialog-icon.warning { color: var(--warning); } +.dialog-icon.info { color: var(--primary); } +.dialog-icon.question { color: var(--primary); } + +.dialog-message { + font-size: 1rem; + line-height: 1.5; + color: var(--text); + white-space: pre-line; + margin-bottom: 0.5rem; +} + +#dialog-footer { + justify-content: center; + gap: 1rem; +} + +#dialog-footer .btn { + min-width: 100px; +} + +.modal-content.modal-fullwidth { + max-width: 90vw !important; + width: 90vw !important; + max-height: 90vh; + overflow: hidden; +} + +/* ============ Regel-Editor 3-Spalten Layout ============ */ +.modal-body.regel-editor-body { + display: grid !important; + grid-template-columns: 200px 1fr 400px; + gap: 1rem; + padding: 0 !important; + height: calc(90vh - 120px); + overflow: hidden; +} + +.regel-spalte { + padding: 1rem; + overflow-y: auto; +} + +/* Regex-Hilfe (links) */ +.regex-hilfe { + background: var(--bg-tertiary); + border-right: 1px solid var(--border); + font-size: 0.8rem; +} + +.regex-hilfe h4 { + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.regex-cheatsheet { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.regex-gruppe { + background: var(--bg-secondary); + padding: 0.5rem; + border-radius: var(--radius); +} + +.regex-gruppe strong { + display: block; + margin-bottom: 0.5rem; + color: var(--primary); + font-size: 0.75rem; + text-transform: uppercase; +} + +.regex-item { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.regex-item code { + background: var(--bg); + padding: 0.1rem 0.3rem; + border-radius: 3px; + font-family: monospace; + color: var(--success); + min-width: 50px; +} + +.regex-beispiel { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} + +.regex-beispiel code { + display: block; + background: var(--bg); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-family: monospace; + color: var(--warning); + font-size: 0.75rem; + word-break: break-all; +} + +.regex-beispiel small { + display: block; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +/* Eingabe-Spalte (mitte) */ +.regel-eingabe { + background: var(--bg-secondary); +} + +.regel-eingabe .regel-section { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.regel-eingabe .regel-section:last-child { + border-bottom: none; +} + +.regel-eingabe h4 { + font-size: 0.85rem; + margin-bottom: 0.5rem; + color: var(--primary); +} + +.checkbox-label.compact { + font-size: 0.85rem; +} + +/* Extraktion-Tabelle - Verbessertes Layout */ +.extraktion-tabelle.compact { + font-size: 0.85rem; + width: 100%; + table-layout: fixed; +} + +.extraktion-tabelle.compact th, +.extraktion-tabelle.compact td { + padding: 0.4rem 0.5rem; + vertical-align: top; +} + +/* Spaltenbreiten */ +.extraktion-tabelle.compact th:nth-child(1), +.extraktion-tabelle.compact td:nth-child(1) { + width: 100px; +} + +.extraktion-tabelle.compact th:nth-child(2), +.extraktion-tabelle.compact td:nth-child(2) { + width: 80px; +} + +/* Auswahl-Spalte (max/min/first/last) */ +.extraktion-tabelle.compact th:nth-child(4), +.extraktion-tabelle.compact td:nth-child(4) { + width: 70px; +} + +/* Delete-Button-Spalte */ +.extraktion-tabelle.compact th:nth-child(5), +.extraktion-tabelle.compact td:nth-child(5) { + width: 35px; +} + +/* Auswahl-Select kompakt */ +.extraktion-tabelle.compact .ext-auswahl { + width: 100%; + font-size: 0.75rem; + padding: 0.25rem 0.2rem; +} + +/* Feld-Input */ +.extraktion-tabelle.compact .ext-feld { + width: 100%; + font-size: 0.85rem; + padding: 0.4rem; +} + +/* Typ-Select */ +.extraktion-tabelle.compact .ext-typ { + width: 100%; + font-size: 0.8rem; + padding: 0.35rem 0.25rem; +} + +/* Textarea für Regex/Wert */ +.extraktion-tabelle.compact textarea { + font-size: 0.8rem; + min-height: 55px; + max-height: 120px; + resize: vertical; + font-family: monospace; + line-height: 1.4; + width: 100%; + padding: 0.4rem; +} + +.extraktion-tabelle textarea { + font-family: monospace; + font-size: 0.85rem; + min-height: 55px; + resize: vertical; +} + +/* Beispiel-Zeilen hervorheben */ +.extraktion-tabelle.compact .extraktion-row.beispiel { + background: var(--bg-tertiary); + opacity: 0.7; +} + +.extraktion-tabelle.compact .extraktion-row.beispiel:hover { + opacity: 1; +} + +.ordner-checkboxen.compact { + max-height: 100px; + overflow-y: auto; + font-size: 0.85rem; +} + +.ordner-checkboxen.compact .checkbox-item { + padding: 0.25rem; +} + +/* Vorschau-Spalte (rechts) */ +.regel-vorschau { + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.regel-vorschau h4 { + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.test-controls { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.pdf-text-container { + flex: 1; + min-height: 200px; + margin-top: 0.5rem; + overflow: hidden; +} + +.pdf-text-display { + height: 100%; + overflow-y: auto; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + font-family: monospace; + font-size: 0.75rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.pdf-text-display .highlight-keyword { + background: rgba(34, 197, 94, 0.3); + border-radius: 2px; +} + +.pdf-text-display .highlight-match { + background: rgba(59, 130, 246, 0.3); + border-radius: 2px; +} + +.pdf-text-display .highlight-extracted { + background: rgba(245, 158, 11, 0.4); + border-radius: 2px; + font-weight: bold; +} + +.test-result { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.test-status-box { + padding: 0.5rem; + border-radius: var(--radius); + font-size: 0.85rem; + margin-bottom: 0.5rem; +} + +.test-status-box.success { + background: rgba(34, 197, 94, 0.2); + border: 1px solid var(--success); +} + +.test-status-box.error { + background: rgba(239, 68, 68, 0.2); + border: 1px solid var(--danger); +} + +.test-extrahiert-box { + font-size: 0.8rem; +} + +.test-extrahiert-box .feld-item { + display: flex; + justify-content: space-between; + padding: 0.25rem 0; + border-bottom: 1px solid var(--border); +} + +.test-extrahiert-box .feld-name { + color: var(--text-secondary); +} + +.test-extrahiert-box .feld-wert { + color: var(--success); + font-family: monospace; +} + +.test-dateiname-box { + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--bg-tertiary); + border-radius: var(--radius); + font-family: monospace; + font-size: 0.8rem; + color: var(--warning); +} + +@media (max-width: 1400px) { + .modal-body.regel-editor-body { + grid-template-columns: 180px 1fr 350px; + } +} + +@media (max-width: 1100px) { + .modal-body.regel-editor-body { + grid-template-columns: 1fr 350px; + } + .regex-hilfe { + display: none; + } +} + +/* ============ Ordner-Editor 3-Spalten Layout ============ */ +.modal-body.ordner-editor-body { + display: grid !important; + grid-template-columns: 1fr 1fr 1fr; + gap: 1.5rem; + padding: 1rem !important; + max-height: calc(90vh - 120px); + overflow-y: auto; +} + +.ordner-spalte { + padding: 1rem; + background: var(--bg-secondary); + border-radius: var(--radius); +} + +.ordner-spalte h4 { + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} + +.ordner-section { + margin-top: 1rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: var(--radius); +} + +.ordner-section h4 { + margin: 0 0 0.75rem 0; + padding: 0; + border: none; + font-size: 0.9rem; +} + +.dateitypen-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.25rem; +} + +.dateitypen-grid .checkbox-item { + font-size: 0.85rem; + padding: 0.25rem 0; +} + +@media (max-width: 1200px) { + .modal-body.ordner-editor-body { + grid-template-columns: 1fr 1fr; + } + .ordner-spalte:last-child { + grid-column: span 2; + } +} + +@media (max-width: 800px) { + .modal-body.ordner-editor-body { + grid-template-columns: 1fr; + } + .ordner-spalte:last-child { + grid-column: span 1; + } +} + +.modal-header { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 1.125rem; +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; +} + +.modal-body { + padding: 1rem; +} + +.modal-footer { + padding: 1rem; + display: flex; + justify-content: flex-end; + gap: 0.5rem; + border-top: 1px solid var(--border); +} + +/* ============ Test Result ============ */ +.test-result { + margin-top: 0.5rem; + padding: 0.75rem; + border-radius: var(--radius); + background: var(--bg-tertiary); + font-family: monospace; + font-size: 0.8rem; + white-space: pre-wrap; +} + +.test-result.success { + border-left: 3px solid var(--success); +} + +.test-result.error { + border-left: 3px solid var(--danger); +} + +/* ============ Status Badges ============ */ +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 500; +} + +.badge-success { background: var(--success); } +.badge-warning { background: var(--warning); color: #000; } +.badge-danger { background: var(--danger); } +.badge-info { background: var(--primary); } + +/* ============ Loading Overlay ============ */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + margin-top: 1rem; + color: var(--text); + font-size: 0.875rem; +} + +.progress-bar { + width: 200px; + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + margin-top: 1rem; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--primary); + transition: width 0.3s ease; +} + +/* ============ File Browser ============ */ +.file-browser { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 1rem; +} + +.file-browser-path { + padding: 0.5rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.file-browser-path input { + flex: 1; + font-family: monospace; + font-size: 0.8rem; + padding: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); +} + +.file-browser-path input:focus { + outline: none; + border-color: var(--primary); +} + +.file-browser-path .btn-sm { + padding: 0.4rem 0.6rem; + font-size: 0.9rem; +} + +.file-browser-list { + list-style: none; +} + +.file-browser-item { + padding: 0.5rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.file-browser-item:hover { + background: var(--bg-tertiary); +} + +.file-browser-item.selected { + background: var(--primary); +} + +.file-browser-item:last-child { + border-bottom: none; +} + +.file-icon { + font-size: 1rem; +} + +/* ============ Checkbox Group ============ */ +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; +} + +.checkbox-item input { + width: auto; + margin: 0; +} + +.checkbox-item:has(input:checked) { + background: var(--primary); +} + +/* ============ Input with Button ============ */ +.input-with-btn { + display: flex; + gap: 0.5rem; +} + +.input-with-btn input { + flex: 1; +} + +/* ============ Utilities ============ */ +.hidden { + display: none !important; +} + +/* ============ Scrollbar ============ */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* ============ Status Grid ============ */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.status-section { + background: var(--bg-tertiary); + padding: 0.75rem; + border-radius: var(--radius); +} + +.status-section h4 { + font-size: 0.875rem; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.status-item { + font-size: 0.75rem; + padding: 0.25rem 0; + color: var(--text-secondary); +} + +.status-item.success { + color: var(--success); +} + +.status-item.error { + color: var(--danger); +} + +/* ============ Größenfilter pro Dateityp ============ */ +.groessen-filter-container { + background: var(--bg-tertiary); + border-radius: var(--radius); + padding: 1rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + display: none; +} + +.groessen-filter-container.visible { + display: block; +} + +.groessen-filter-table { + width: 100%; + font-size: 0.8rem; +} + +.groessen-filter-table th { + text-align: left; + padding: 0.5rem; + color: var(--text-secondary); + font-weight: normal; + border-bottom: 1px solid var(--border); +} + +.groessen-filter-table td { + padding: 0.5rem; +} + +.groessen-filter-table input { + width: 70px; + padding: 0.4rem; + font-size: 0.8rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); +} + +.groessen-filter-table input:focus { + outline: none; + border-color: var(--primary); +} + +.groessen-filter-row { + border-bottom: 1px solid var(--border); +} + +.groessen-filter-row:last-child { + border-bottom: none; +} + +.groessen-filter-type { + font-family: monospace; + color: var(--primary); +} + +/* ============ Settings Button ============ */ +.btn-icon { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.5rem; + border-radius: var(--radius); + transition: background 0.2s; +} + +.btn-icon:hover { + background: var(--bg-tertiary); +} + +/* ============ Theme Options ============ */ +.theme-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-top: 0.5rem; +} + +@media (max-width: 600px) { + .theme-options { + grid-template-columns: repeat(2, 1fr); + } +} + +.theme-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: var(--bg-tertiary); + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.2s; + color: var(--text); +} + +.theme-option:hover { + border-color: var(--primary); +} + +.theme-option.active { + border-color: var(--primary); + background: rgba(59, 130, 246, 0.2); +} + +.theme-preview { + width: 60px; + height: 40px; + border-radius: 4px; + border: 1px solid var(--border); +} + +.theme-preview.dark { + background: linear-gradient(135deg, #0f172a 50%, #1e293b 50%); +} + +.theme-preview.light { + background: linear-gradient(135deg, #f8fafc 50%, #e2e8f0 50%); +} + +.theme-preview.blue { + background: linear-gradient(135deg, #0c1929 50%, #1e3a5f 50%); +} + +.theme-preview.green { + background: linear-gradient(135deg, #0d1f0d 50%, #1a3d1a 50%); +} + +.theme-preview.breeze { + background: linear-gradient(135deg, #141718 50%, #1a1d1f 50%); +} + +/* ============ Light Theme ============ */ +[data-theme="light"] { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --success: #16a34a; + --danger: #dc2626; + --warning: #d97706; + --bg: #f8fafc; + --bg-secondary: #e2e8f0; + --bg-tertiary: #cbd5e1; + --text: #0f172a; + --text-secondary: #475569; + --border: #94a3b8; +} + +/* ============ Blue Theme ============ */ +[data-theme="blue"] { + --primary: #60a5fa; + --primary-dark: #3b82f6; + --success: #34d399; + --danger: #f87171; + --warning: #fbbf24; + --bg: #0c1929; + --bg-secondary: #1e3a5f; + --bg-tertiary: #2d4a6f; + --text: #e0f2fe; + --text-secondary: #7dd3fc; + --border: #3b6b9e; +} + +/* ============ Green Theme ============ */ +[data-theme="green"] { + --primary: #22c55e; + --primary-dark: #16a34a; + --success: #4ade80; + --danger: #f87171; + --warning: #fbbf24; + --bg: #0d1f0d; + --bg-secondary: #1a3d1a; + --bg-tertiary: #2d5a2d; + --text: #dcfce7; + --text-secondary: #86efac; + --border: #3d7a3d; +} + +/* ============ Breeze Dark Theme (KDE) - Original System-Farben ============ */ +[data-theme="breeze"] { + --primary: #3daee9; + --primary-dark: #2980b9; + --success: #27ae60; + --danger: #da4453; + --warning: #f67400; + --bg: #232629; + --bg-secondary: #2a2e32; + --bg-tertiary: #31363b; + --text: #fcfcfc; + --text-secondary: #bdc3c7; + --border: #4d4d4d; +} + +/* ============ Debug Log ============ */ +.log-container { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem; + max-height: 400px; + overflow-y: auto; + font-family: monospace; + font-size: 0.85rem; +} + +.log-controls { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; + align-items: center; +} + +.log-controls select { + padding: 0.25rem 0.5rem; + font-size: 0.85rem; +} + +.log-entry { + display: flex; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-entry.error { + background: rgba(220, 38, 38, 0.15); +} + +.log-entry.warning { + background: rgba(217, 119, 6, 0.15); +} + +.log-time { + color: var(--text-secondary); + min-width: 60px; +} + +.log-level { + min-width: 60px; + font-weight: bold; +} + +.log-entry.error .log-level { + color: var(--danger); +} + +.log-entry.warning .log-level { + color: var(--warning); +} + +.log-entry.info .log-level { + color: var(--primary); +} + +.log-msg { + flex: 1; + word-break: break-word; +} + +/* ============ Regel Tester ============ */ +.test-options { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.test-result { + margin-top: 0.5rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.test-result.success { + border-color: var(--success); + background: rgba(34, 197, 94, 0.1); +} + +.test-result.error { + border-color: var(--danger); + background: rgba(220, 38, 38, 0.1); +} + +#regel-test-text { + font-family: monospace; + font-size: 0.8rem; +} + +/* ============ Regel-Assistent ============ */ +.assistent-section { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.assistent-section:last-of-type { + border-bottom: none; +} + +.assistent-section h4 { + margin-bottom: 0.75rem; + color: var(--primary); + font-size: 1rem; +} + +.assistent-feld { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.75rem; + padding: 0.5rem; + background: var(--bg-tertiary); + border-radius: var(--radius); +} + +.assistent-feld label { + min-width: 180px; + margin-bottom: 0; +} + +.assistent-feld select { + flex: 1; + padding: 0.4rem; +} + +.assistent-feld .checkbox-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.assistent-feld .checkbox-item input[type="checkbox"] { + width: 18px; + height: 18px; +} + +/* ============ Neues Regel-Modal ============ */ +.regel-section { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.regel-section:last-of-type { + border-bottom: none; +} + +.regel-section h4 { + margin-bottom: 0.75rem; + color: var(--primary); + font-size: 1rem; +} + +/* Extraktion-Tabelle */ +.extraktion-tabelle { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.extraktion-tabelle th { + text-align: left; + padding: 0.5rem; + background: var(--bg-tertiary); + border-bottom: 2px solid var(--border); + font-weight: 600; + color: var(--text-secondary); +} + +.extraktion-tabelle td { + padding: 0.4rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.extraktion-tabelle input, +.extraktion-tabelle select { + width: 100%; + padding: 0.4rem; + font-size: 0.85rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); +} + +.extraktion-tabelle input:focus, +.extraktion-tabelle select:focus { + outline: none; + border-color: var(--primary); +} + +.extraktion-tabelle .ext-erkannt { + font-family: monospace; + font-size: 0.8rem; +} + +.extraktion-row:hover { + background: var(--bg-secondary); +} + +/* Ordner-Checkboxen */ +.ordner-checkboxen { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 200px; + overflow-y: auto; + padding: 0.5rem; + background: var(--bg); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.ordner-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: var(--radius); + cursor: pointer; +} + +.ordner-checkbox:hover { + background: var(--bg-secondary); +} + +.ordner-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.ordner-checkbox small { + margin-left: auto; + font-size: 0.75rem; +} + +/* Radio-Gruppe für Sortier-Modus */ +.radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.radio-item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + cursor: pointer; +} + +.radio-item input[type="radio"] { + width: 18px; + height: 18px; + margin-top: 2px; +} + +/* Checkbox-Label */ +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; +} diff --git a/Source/frontend/static/js/app.js b/Source/frontend/static/js/app.js new file mode 100755 index 0000000..9c3f582 --- /dev/null +++ b/Source/frontend/static/js/app.js @@ -0,0 +1,2546 @@ +/** + * Dateiverwaltung Frontend + * Zwei getrennte Bereiche: Mail-Abruf und Datei-Sortierung + */ + +// ============ API ============ + +async function api(endpoint, options = {}) { + const response = await fetch(`/api${endpoint}`, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + // Besseres Fehler-Handling für Pydantic Validation Errors + let errorMsg = 'API Fehler'; + if (error.detail) { + if (Array.isArray(error.detail)) { + // Pydantic Validation Error Format + errorMsg = error.detail.map(e => `${e.loc?.join('.')}: ${e.msg}`).join('\n'); + } else if (typeof error.detail === 'object') { + errorMsg = JSON.stringify(error.detail); + } else { + errorMsg = error.detail; + } + } + throw new Error(errorMsg); + } + return response.json(); +} + +// ============ Loading Overlay ============ + +function zeigeLoading(text = 'Wird geladen...') { + document.getElementById('loading-text').textContent = text; + document.getElementById('loading-overlay').classList.remove('hidden'); +} + +function versteckeLoading() { + document.getElementById('loading-overlay').classList.add('hidden'); +} + +// ============ Dialog System (ersetzt alert/confirm) ============ + +let dialogResolve = null; + +const DIALOG_ICONS = { + success: '✅', + error: '❌', + warning: '⚠️', + info: 'ℹ️', + question: '❓' +}; + +/** + * Zeigt einen Dialog an (ersetzt alert/confirm) + * @param {Object} options - Dialog-Optionen + * @param {string} options.title - Dialog-Titel + * @param {string} options.message - Nachricht + * @param {string} options.type - Typ: success, error, warning, info, question + * @param {boolean} options.showCancel - Abbrechen-Button anzeigen (für Confirm) + * @param {string} options.okText - Text für OK-Button + * @param {string} options.cancelText - Text für Abbrechen-Button + * @returns {Promise} - true wenn OK, false wenn Abbrechen + */ +function showDialog(options = {}) { + const { + title = 'Hinweis', + message = '', + type = 'info', + showCancel = false, + okText = 'OK', + cancelText = 'Abbrechen' + } = options; + + return new Promise((resolve) => { + dialogResolve = resolve; + + document.getElementById('dialog-title').textContent = title; + document.getElementById('dialog-message').textContent = message; + + const iconEl = document.getElementById('dialog-icon'); + iconEl.textContent = DIALOG_ICONS[type] || DIALOG_ICONS.info; + iconEl.className = 'dialog-icon ' + type; + + document.getElementById('dialog-ok-btn').textContent = okText; + document.getElementById('dialog-cancel-btn').textContent = cancelText; + document.getElementById('dialog-cancel-btn').style.display = showCancel ? '' : 'none'; + + document.getElementById('dialog-modal').classList.remove('hidden'); + }); +} + +/** + * Schließt den Dialog + * @param {boolean} result - Ergebnis (true = OK, false = Abbrechen) + */ +function dialogSchliessen(result) { + document.getElementById('dialog-modal').classList.add('hidden'); + if (dialogResolve) { + dialogResolve(result); + dialogResolve = null; + } +} + +/** + * Zeigt eine Benachrichtigung an (ersetzt alert) + */ +function showAlert(message, type = 'info', title = null) { + const titles = { + success: 'Erfolg', + error: 'Fehler', + warning: 'Warnung', + info: 'Hinweis' + }; + return showDialog({ + title: title || titles[type] || 'Hinweis', + message: message, + type: type, + showCancel: false, + okText: 'OK' + }); +} + +/** + * Zeigt eine Bestätigung an (ersetzt confirm) + */ +function showConfirm(message, title = 'Bestätigung') { + return showDialog({ + title: title, + message: message, + type: 'question', + showCancel: true, + okText: 'Ja', + cancelText: 'Nein' + }); +} + +// ============ File Browser ============ + +let browserTargetInput = null; +let browserCurrentPath = '/'; + +function oeffneBrowser(inputId) { + browserTargetInput = inputId; + const currentValue = document.getElementById(inputId).value; + // Entferne trailing slash für die Pfadnavigation + browserCurrentPath = (currentValue || '/').replace(/\/+$/, '') || '/'; + ladeBrowserInhalt(browserCurrentPath); + document.getElementById('browser-modal').classList.remove('hidden'); +} + +// Navigiert zum Pfad aus dem Eingabefeld +function navigiereToPfad() { + const input = document.getElementById('browser-path-input'); + const path = input.value.trim() || '/'; + ladeBrowserInhalt(path); +} + +// Findet den nächsten existierenden Elternordner +function getParentPath(path) { + if (!path || path === '/') return null; + const parts = path.replace(/\/+$/, '').split('/'); + parts.pop(); + return parts.length === 0 ? '/' : parts.join('/') || '/'; +} + +async function ladeBrowserInhalt(path, versuchtesPfade = []) { + try { + const data = await api(`/browse?path=${encodeURIComponent(path)}`); + + if (data.error) { + // Versuche Elternordner wenn dieser Pfad nicht existiert + const parentPath = getParentPath(path); + + // Verhindere Endlosschleifen + if (parentPath && !versuchtesPfade.includes(parentPath)) { + versuchtesPfade.push(path); + console.log(`Pfad "${path}" existiert nicht, versuche "${parentPath}"`); + return ladeBrowserInhalt(parentPath, versuchtesPfade); + } + + // Fallback zu Root wenn nichts funktioniert + if (path !== '/') { + console.log(`Fallback zu Root-Verzeichnis`); + return ladeBrowserInhalt('/', []); + } + + // Nur anzeigen wenn wirklich nichts geht + document.getElementById('browser-list').innerHTML = + `
  • ${data.error}
  • `; + return; + } + + browserCurrentPath = data.current; + document.getElementById('browser-path-input').value = data.current; + + let html = ''; + + // Parent directory + if (data.parent) { + html += `
  • + 📁 .. +
  • `; + } + + // Directories + for (const entry of data.entries) { + html += `
  • + 📁 ${entry.name} +
  • `; + } + + if (data.entries.length === 0 && !data.parent) { + html = '
  • Keine Unterordner
  • '; + } + + document.getElementById('browser-list').innerHTML = html; + } catch (error) { + // Bei Netzwerk-/API-Fehler auch Elternordner versuchen + const parentPath = getParentPath(path); + if (parentPath && !versuchtesPfade.includes(parentPath) && path !== '/') { + versuchtesPfade.push(path); + console.log(`API-Fehler bei "${path}", versuche "${parentPath}"`); + return ladeBrowserInhalt(parentPath, versuchtesPfade); + } + + document.getElementById('browser-list').innerHTML = + `
  • Fehler: ${error.message}
  • `; + } +} + +function browserSelect(element, path) { + document.querySelectorAll('.file-browser-item.selected').forEach(el => el.classList.remove('selected')); + element.classList.add('selected'); + browserCurrentPath = path; +} + +function browserAuswahl() { + if (browserTargetInput && browserCurrentPath) { + document.getElementById(browserTargetInput).value = browserCurrentPath + '/'; + } + schliesseModal('browser-modal'); +} + +// ============ Checkbox Helpers ============ + +function getCheckedTypes(groupId) { + const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]:checked`); + return Array.from(checkboxes).map(cb => cb.value); +} + +function setCheckedTypes(groupId, types) { + const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]`); + checkboxes.forEach(cb => { + cb.checked = types.includes(cb.value); + }); +} + +// ============ Theme System ============ + +function ladeGespeichertesTheme() { + const gespeichertesTheme = localStorage.getItem('dateiverwaltung-theme') || 'dark'; + setzeTheme(gespeichertesTheme, false); +} + +function setzeTheme(theme, speichern = true) { + document.documentElement.setAttribute('data-theme', theme); + + // Aktiven Button markieren + document.querySelectorAll('.theme-option').forEach(btn => { + btn.classList.toggle('active', btn.dataset.theme === theme); + }); + + if (speichern) { + localStorage.setItem('dateiverwaltung-theme', theme); + } +} + +function zeigeEinstellungenModal() { + // Aktuelles Theme markieren + const aktuellesTheme = localStorage.getItem('dateiverwaltung-theme') || 'dark'; + document.querySelectorAll('.theme-option').forEach(btn => { + btn.classList.toggle('active', btn.dataset.theme === aktuellesTheme); + }); + + document.getElementById('einstellungen-modal').classList.remove('hidden'); +} + +// ============ Debug Log ============ + +function zeigeLogModal() { + document.getElementById('log-modal').classList.remove('hidden'); + ladeLog(); +} + +async function ladeLog() { + const container = document.getElementById('log-container'); + const filter = document.getElementById('log-filter').value; + + try { + const logs = await api(`/logs${filter ? '?level=' + filter : ''}`); + if (!logs || logs.length === 0) { + container.innerHTML = '

    Keine Log-Einträge

    '; + return; + } + + container.innerHTML = logs.map(log => { + const levelClass = log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : 'info'; + return `
    + ${log.zeit} + ${log.level} + ${escapeHtml(log.nachricht)} +
    `; + }).join(''); + + // Nach unten scrollen + container.scrollTop = container.scrollHeight; + } catch (error) { + container.innerHTML = `

    Fehler: ${error.message}

    `; + } +} + +async function leereLog() { + if (!await showConfirm('Log wirklich leeren?')) return; + try { + await api('/logs', { method: 'DELETE' }); + ladeLog(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// ============ Init ============ + +document.addEventListener('DOMContentLoaded', () => { + ladeGespeichertesTheme(); + ladePostfaecher(); + ladeOrdner(); + ladeRegeln(); + ladeZeitplaene(); + ladeStatus(); + + // Event-Listener für Dateityp-Checkboxen im Postfach-Modal + const pfTypenGruppe = document.getElementById('pf-typen-gruppe'); + if (pfTypenGruppe) { + pfTypenGruppe.addEventListener('change', () => { + const container = document.getElementById('pf-groessen-filter'); + if (container.classList.contains('visible')) { + updateGroessenFilterTable(); + } + }); + } +}); + +// ============ BEREICH 1: Mail-Abruf ============ + +async function ladePostfaecher() { + try { + const postfaecher = await api('/postfaecher'); + renderPostfaecher(postfaecher); + } catch (error) { + console.error('Fehler:', error); + } +} + +let bearbeitetesPostfachId = null; + +function renderPostfaecher(postfaecher) { + const container = document.getElementById('postfaecher-liste'); + + if (!postfaecher || postfaecher.length === 0) { + container.innerHTML = '

    Keine Postfächer konfiguriert

    '; + return; + } + + container.innerHTML = postfaecher.map(p => { + const letzterAbruf = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie'; + return ` +
    +
    +

    ${escapeHtml(p.name)}

    + ${escapeHtml(p.email)} → ${truncatePath(p.ziel_ordner)} + Letzter Abruf: ${letzterAbruf} (${p.letzte_anzahl || 0} Dateien) +
    +
    + + + + +
    +
    + `}).join(''); +} + +function zeigePostfachModal(postfach = null) { + bearbeitetesPostfachId = postfach?.id || null; + + document.getElementById('pf-name').value = postfach?.name || ''; + document.getElementById('pf-server').value = postfach?.imap_server || ''; + document.getElementById('pf-port').value = postfach?.imap_port || '993'; + document.getElementById('pf-email').value = postfach?.email || ''; + document.getElementById('pf-passwort').value = ''; // Passwort nicht vorausfüllen + document.getElementById('pf-ordner').value = postfach?.ordner || 'INBOX'; + document.getElementById('pf-alle-ordner').value = postfach?.alle_ordner ? 'true' : 'false'; + document.getElementById('pf-ziel').value = postfach?.ziel_ordner || '/srv/http/dateiverwaltung/data/inbox/'; + setCheckedTypes('pf-typen-gruppe', postfach?.erlaubte_typen || ['.pdf']); + document.getElementById('pf-max-groesse').value = postfach?.max_groesse_mb || '25'; + document.getElementById('pf-min-groesse').value = postfach?.min_groesse_kb || '10'; + // ab_datum: ISO-String in date-Input-Format (YYYY-MM-DD) + const abDatum = postfach?.ab_datum ? postfach.ab_datum.split('T')[0] : ''; + document.getElementById('pf-ab-datum').value = abDatum; + + // Größenfilter pro Dateityp initialisieren + renderGroessenFilter(postfach?.groessen_filter || {}); + + document.getElementById('postfach-modal').classList.remove('hidden'); +} + +// ============ Größenfilter pro Dateityp ============ + +function toggleGroessenFilter() { + const container = document.getElementById('pf-groessen-filter'); + container.classList.toggle('visible'); + if (container.classList.contains('visible')) { + // Tabelle aktualisieren basierend auf ausgewählten Dateitypen + updateGroessenFilterTable(); + } +} + +function updateGroessenFilterTable() { + const selectedTypes = getCheckedTypes('pf-typen-gruppe'); + const container = document.getElementById('pf-groessen-filter'); + const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10; + const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25; + + // Bestehende Werte sammeln + const existingValues = {}; + container.querySelectorAll('.groessen-filter-row').forEach(row => { + const typ = row.dataset.typ; + const minInput = row.querySelector('.groessen-min'); + const maxInput = row.querySelector('.groessen-max'); + if (minInput && maxInput) { + existingValues[typ] = { + min_kb: parseInt(minInput.value) || defaultMin, + max_mb: parseInt(maxInput.value) || defaultMax + }; + } + }); + + if (selectedTypes.length === 0) { + container.innerHTML = '

    Zuerst Dateitypen auswählen

    '; + return; + } + + let html = ` + + + + + + + + + + `; + + for (const typ of selectedTypes) { + const existing = existingValues[typ] || { min_kb: defaultMin, max_mb: defaultMax }; + html += ` + + + + + + `; + } + + html += '
    DateitypMin (KB)Max (MB)
    ${typ}
    '; + container.innerHTML = html; +} + +function renderGroessenFilter(groessenFilter) { + const container = document.getElementById('pf-groessen-filter'); + container.classList.remove('visible'); + + // Wenn Werte vorhanden, Tabelle aufbauen + if (groessenFilter && Object.keys(groessenFilter).length > 0) { + const selectedTypes = getCheckedTypes('pf-typen-gruppe'); + const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10; + const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25; + + let html = ` + + + + + + + + + + `; + + for (const typ of selectedTypes) { + const filter = groessenFilter[typ] || { min_kb: defaultMin, max_mb: defaultMax }; + html += ` + + + + + + `; + } + + html += '
    DateitypMin (KB)Max (MB)
    ${typ}
    '; + container.innerHTML = html; + } else { + container.innerHTML = ''; + } +} + +function getGroessenFilter() { + const container = document.getElementById('pf-groessen-filter'); + const rows = container.querySelectorAll('.groessen-filter-row'); + const defaultMin = parseInt(document.getElementById('pf-min-groesse').value) || 10; + const defaultMax = parseInt(document.getElementById('pf-max-groesse').value) || 25; + + const filter = {}; + rows.forEach(row => { + const typ = row.dataset.typ; + const minKb = parseInt(row.querySelector('.groessen-min')?.value); + const maxMb = parseInt(row.querySelector('.groessen-max')?.value); + + // Nur speichern wenn unterschiedlich vom Default + if ((minKb && minKb !== defaultMin) || (maxMb && maxMb !== defaultMax)) { + filter[typ] = {}; + if (minKb && minKb !== defaultMin) filter[typ].min_kb = minKb; + if (maxMb && maxMb !== defaultMax) filter[typ].max_mb = maxMb; + } + }); + + return Object.keys(filter).length > 0 ? filter : null; +} + +async function postfachBearbeiten(id) { + try { + const postfaecher = await api('/postfaecher'); + const postfach = postfaecher.find(p => p.id === id); + if (postfach) { + zeigePostfachModal(postfach); + } + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function speicherePostfach() { + const erlaubteTypen = getCheckedTypes('pf-typen-gruppe'); + if (erlaubteTypen.length === 0) { + showAlert('Bitte mindestens einen Dateityp auswählen', 'warning'); + return; + } + + const abDatumValue = document.getElementById('pf-ab-datum').value; + const data = { + name: document.getElementById('pf-name').value.trim(), + imap_server: document.getElementById('pf-server').value.trim(), + imap_port: parseInt(document.getElementById('pf-port').value), + email: document.getElementById('pf-email').value.trim(), + passwort: document.getElementById('pf-passwort').value, + ordner: document.getElementById('pf-ordner').value.trim(), + alle_ordner: document.getElementById('pf-alle-ordner').value === 'true', + ziel_ordner: document.getElementById('pf-ziel').value.trim(), + erlaubte_typen: erlaubteTypen, + max_groesse_mb: parseInt(document.getElementById('pf-max-groesse').value), + min_groesse_kb: parseInt(document.getElementById('pf-min-groesse').value), + ab_datum: abDatumValue ? abDatumValue + 'T00:00:00' : null, + groessen_filter: getGroessenFilter() + }; + + if (!data.name || !data.imap_server || !data.email || !data.ziel_ordner) { + showAlert('Bitte alle Pflichtfelder ausfüllen', 'warning'); + return; + } + + // Bei Bearbeitung: Passwort nur senden wenn eingegeben + if (bearbeitetesPostfachId && !data.passwort) { + delete data.passwort; + } else if (!data.passwort) { + showAlert('Passwort ist erforderlich', 'warning'); + return; + } + + try { + if (bearbeitetesPostfachId) { + await api(`/postfaecher/${bearbeitetesPostfachId}`, { method: 'PUT', body: JSON.stringify(data) }); + } else { + await api('/postfaecher', { method: 'POST', body: JSON.stringify(data) }); + } + schliesseModal('postfach-modal'); + ladePostfaecher(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function postfachTesten(id) { + try { + const result = await api(`/postfaecher/${id}/test`, { method: 'POST' }); + showAlert(result.erfolg ? 'Verbindung erfolgreich!' : result.nachricht, result.erfolg ? 'success' : 'error'); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function postfachAbrufen(id) { + const logContainer = document.getElementById('abruf-log'); + logContainer.innerHTML = '
    Verbinde...
    '; + + // EventSource für Server-Sent Events + const eventSource = new EventSource(`/api/postfaecher/${id}/abrufen/stream`); + let dateiCount = 0; + let currentOrdner = ''; + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'start': + logContainer.innerHTML = `
    + Starte Abruf: ${escapeHtml(data.postfach)} + ${data.bereits_verarbeitet} bereits verarbeitet +
    `; + break; + + case 'info': + logContainer.innerHTML += `
    + ${escapeHtml(data.nachricht)} +
    `; + break; + + case 'ordner': + currentOrdner = data.name; + logContainer.innerHTML += `
    + 📁 ${escapeHtml(data.name)} +
    `; + break; + + case 'mails': + const ordnerStatus = document.getElementById('ordner-status'); + if (ordnerStatus) { + ordnerStatus.innerHTML = `📁 ${escapeHtml(data.ordner)}: ${data.anzahl} Mails`; + ordnerStatus.id = ''; // ID entfernen für nächsten Ordner + } + break; + + case 'datei': + dateiCount++; + logContainer.innerHTML += `
    + ✓ ${escapeHtml(data.original_name)} + ${formatBytes(data.groesse)} +
    `; + // Scroll nach unten + logContainer.scrollTop = logContainer.scrollHeight; + break; + + case 'skip': + logContainer.innerHTML += `
    + ⊘ ${escapeHtml(data.datei)}: ${data.grund} +
    `; + break; + + case 'fehler': + logContainer.innerHTML += `
    + ✗ ${escapeHtml(data.nachricht)} +
    `; + break; + + case 'fertig': + logContainer.innerHTML += `
    + ✓ Fertig: ${data.anzahl} Dateien gespeichert +
    `; + eventSource.close(); + ladePostfaecher(); + break; + } + }; + + eventSource.onerror = (error) => { + logContainer.innerHTML += `
    + ✗ Verbindung unterbrochen +
    `; + eventSource.close(); + }; +} + +function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +async function allePostfaecherAbrufen() { + const container = document.getElementById('abruf-log'); + container.innerHTML = '
    Starte Abruf...
    '; + + try { + const response = await fetch('/api/postfaecher/abrufen-alle/stream'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + let currentPostfach = ''; + + while (true) { + const {done, value} = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const event = JSON.parse(line.slice(6)); + renderStreamEvent(container, event); + } catch (e) {} + } + } + } + + ladePostfaecher(); + ladeStatus(); + } catch (error) { + container.innerHTML += `
    Fehler: ${escapeHtml(error.message)}
    `; + } +} + +function renderStreamEvent(container, event) { + let html = ''; + + switch (event.type) { + case 'init': + html = `
    📧 ${event.anzahl_postfaecher} Postfächer werden abgerufen...
    `; + break; + case 'postfach_start': + html = `
    📬 ${escapeHtml(event.name)} (${event.bereits_verarbeitet} bereits verarbeitet)
    `; + break; + case 'ordner': + html = `
    📁 Ordner: ${escapeHtml(event.name)}
    `; + break; + case 'mails': + html = `
    ${event.anzahl} Mails gefunden
    `; + break; + case 'datei': + html = `
    ✓ ${escapeHtml(event.datei)} (${formatBytes(event.groesse)})
    `; + break; + case 'skip': + html = `
    → ${escapeHtml(event.datei)} - ${event.grund}
    `; + break; + case 'postfach_done': + html = `
    ✓ ${escapeHtml(event.name)}: ${event.anzahl} Dateien
    `; + break; + case 'postfach_error': + html = `
    ✗ ${escapeHtml(event.name)}: ${escapeHtml(event.fehler)}
    `; + break; + case 'done': + html = `
    Fertig!
    `; + break; + } + + if (html) { + container.innerHTML += html; + container.scrollTop = container.scrollHeight; + } +} + +async function postfachLoeschen(id) { + if (!await showConfirm('Postfach wirklich löschen?')) return; + try { + await api(`/postfaecher/${id}`, { method: 'DELETE' }); + ladePostfaecher(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +function zeigeAbrufLog(result) { + const container = document.getElementById('abruf-log'); + + if (!result.ergebnisse || result.ergebnisse.length === 0) { + container.innerHTML = '

    Keine neuen Attachments gefunden

    '; + return; + } + + let html = ''; + for (const r of result.ergebnisse) { + const status = r.fehler ? 'error' : 'success'; + const icon = r.fehler ? '✗' : '✓'; + html += `
    + ${icon} ${escapeHtml(r.postfach)}: ${r.anzahl || 0} Dateien + ${r.fehler ? `${escapeHtml(r.fehler)}` : ''} +
    `; + + if (r.dateien) { + for (const d of r.dateien) { + html += `
    + → ${escapeHtml(d)} +
    `; + } + } + } + + container.innerHTML = html; +} + +// ============ BEREICH 2: Datei-Sortierung ============ + +async function ladeOrdner() { + try { + const ordner = await api('/ordner'); + renderOrdner(ordner); + } catch (error) { + console.error('Fehler:', error); + } +} + +function renderOrdner(ordner) { + const container = document.getElementById('ordner-liste'); + + if (!ordner || ordner.length === 0) { + container.innerHTML = '

    Keine Ordner konfiguriert

    '; + return; + } + + container.innerHTML = ordner.map(o => { + const aktivClass = o.aktiv ? '' : 'opacity: 0.5;'; + const aktivBadge = o.aktiv ? 'Aktiv' : 'Inaktiv'; + const letzte = o.letzte_verarbeitung ? formatDatum(o.letzte_verarbeitung) : 'Nie'; + return ` +
    +
    +

    ${escapeHtml(o.name)} ${aktivBadge} ${o.rekursiv ? 'rekursiv' : ''}

    + ${truncatePath(o.pfad)} → ${truncatePath(o.ziel_ordner)} + ${(o.dateitypen || []).join(', ')} | Letzte: ${letzte} (${o.letzte_anzahl || 0} Dateien) +
    +
    + + + + + + +
    +
    + `}).join(''); +} + +let bearbeitetesOrdnerId = null; + +function zeigeOrdnerModal(ordner = null) { + bearbeitetesOrdnerId = ordner?.id || null; + document.getElementById('ordner-modal-title').textContent = ordner ? 'Grobsortierung bearbeiten' : 'Grobsortierung hinzufügen'; + + document.getElementById('ord-name').value = ordner?.name || ''; + document.getElementById('ord-pfad').value = ordner?.pfad || '/mnt/user/'; + document.getElementById('ord-ziel').value = ordner?.ziel_ordner || '/mnt/user/'; + setCheckedTypes('ord-typen-gruppe', ordner?.dateitypen || ['.pdf', '.jpg', '.jpeg', '.png', '.tiff']); + document.getElementById('ord-rekursiv').value = ordner?.rekursiv !== false ? 'true' : 'false'; + document.getElementById('ord-zugferd-sep').checked = ordner?.zugferd_behandlung === 'separieren' || !ordner; + document.getElementById('ord-signiert-sep').checked = ordner?.signiert_behandlung === 'separieren'; + document.getElementById('ord-ocr').checked = ordner?.ocr_aktivieren !== false; + document.getElementById('ord-original-sichern').value = ordner?.original_sichern || ''; + + // Sortier-Modus + const modus = ordner?.direkt_verschieben ? 'direkt' : 'regeln'; + document.querySelector(`input[name="ord-modus"][value="${modus}"]`).checked = true; + + document.getElementById('ordner-modal').classList.remove('hidden'); +} + +async function ordnerBearbeiten(id) { + try { + const ordnerListe = await api('/ordner'); + const ordner = ordnerListe.find(o => o.id === id); + if (ordner) { + zeigeOrdnerModal(ordner); + } + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function speichereOrdner() { + const dateitypen = getCheckedTypes('ord-typen-gruppe'); + if (dateitypen.length === 0) { + showAlert('Bitte mindestens einen Dateityp auswählen', 'warning'); + return; + } + + // NEU: Sortier-Modus auslesen + const modusRadio = document.querySelector('input[name="ord-modus"]:checked'); + const direktVerschieben = modusRadio?.value === 'direkt'; + + const data = { + name: document.getElementById('ord-name').value.trim(), + pfad: document.getElementById('ord-pfad').value.trim(), + ziel_ordner: document.getElementById('ord-ziel').value.trim(), + rekursiv: document.getElementById('ord-rekursiv').value === 'true', + dateitypen: dateitypen, + zugferd_behandlung: document.getElementById('ord-zugferd-sep').checked ? 'separieren' : 'normal', + signiert_behandlung: document.getElementById('ord-signiert-sep').checked ? 'separieren' : 'normal', + direkt_verschieben: direktVerschieben, + ocr_aktivieren: document.getElementById('ord-ocr').checked, + original_sichern: document.getElementById('ord-original-sichern').value.trim() || null + }; + + if (!data.name || !data.pfad || !data.ziel_ordner) { + showAlert('Bitte alle Felder ausfüllen', 'warning'); + return; + } + + try { + zeigeLoading('Speichere Ordner...'); + if (bearbeitetesOrdnerId) { + await api(`/ordner/${bearbeitetesOrdnerId}`, { method: 'PUT', body: JSON.stringify(data) }); + } else { + await api('/ordner', { method: 'POST', body: JSON.stringify(data) }); + } + schliesseModal('ordner-modal'); + ladeOrdner(); + } catch (error) { + showAlert(error.message, 'error'); + } finally { + versteckeLoading(); + } +} + +async function ordnerAktivieren(id) { + try { + await api(`/ordner/${id}/aktivieren`, { method: 'POST' }); + ladeOrdner(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function ordnerLoeschen(id) { + if (!await showConfirm('Ordner wirklich löschen?')) return; + try { + await api(`/ordner/${id}`, { method: 'DELETE' }); + ladeOrdner(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function kopiereOrdner(id) { + if (!await showConfirm('Grobsortierung kopieren?')) return; + try { + const result = await api(`/ordner/${id}/kopieren`, { method: 'POST' }); + showAlert(`Grobsortierung kopiert: "${result.name}"`, 'success'); + ladeOrdner(); + } catch (error) { + showAlert('Fehler beim Kopieren: ' + error.message, 'error'); + } +} + +async function ordnerVorschau(id) { + try { + const result = await api(`/ordner/${id}/scannen`); + let msg = `${result.anzahl} Dateien gefunden`; + if (result.dateien && result.dateien.length > 0) { + msg += `:\n\n${result.dateien.slice(0, 10).join('\n')}`; + if (result.anzahl > 10) { + msg += `\n... und ${result.anzahl - 10} weitere`; + } + } + showAlert(msg, 'info', 'Ordner-Vorschau'); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function ordnerVerarbeiten(id) { + if (!await showConfirm('Dateien jetzt verarbeiten und sortieren?')) return; + + try { + zeigeLoading('Verarbeite Dateien...'); + const result = await api(`/ordner/${id}/verarbeiten`, { method: 'POST' }); + + let msg = `Verarbeitung abgeschlossen:\n\n`; + msg += `• Gesamt: ${result.gesamt}\n`; + msg += `• Sortiert: ${result.sortiert}\n`; + msg += `• ZUGFeRD: ${result.zugferd}\n`; + msg += `• Keine Regel: ${result.keine_regel || 0}\n`; + msg += `• Fehler: ${result.fehler}`; + + if (result.fehler && result.fehler > 0) { + showAlert(msg, 'warning', 'Verarbeitung mit Warnungen'); + } else { + showAlert(msg, 'success', 'Verarbeitung abgeschlossen'); + } + } catch (error) { + showAlert(error.message, 'error'); + } finally { + versteckeLoading(); + } +} + +// ============ Regeln ============ + +let editierteRegelId = null; + +async function ladeRegeln() { + try { + const regeln = await api('/regeln'); + renderRegeln(regeln); + } catch (error) { + console.error('Fehler:', error); + } +} + +function renderRegeln(regeln) { + const container = document.getElementById('regeln-liste'); + + if (!regeln || regeln.length === 0) { + container.innerHTML = '

    Keine Regeln definiert

    '; + return; + } + + container.innerHTML = regeln.map(r => { + const aktivClass = r.aktiv ? '' : 'opacity: 0.5;'; + const aktivBadge = r.aktiv ? 'Aktiv' : 'Inaktiv'; + return ` +
    +
    +

    ${escapeHtml(r.name)} ${aktivBadge} Prio ${r.prioritaet}

    + ${escapeHtml(r.schema)} +
    +
    + + + + +
    +
    + `}).join(''); +} + +async function kopiereRegel(id) { + if (!await showConfirm('Regel kopieren?')) return; + try { + const result = await api(`/regeln/${id}/kopieren`, { method: 'POST' }); + showAlert(`Regel kopiert: "${result.name}"`, 'success'); + ladeRegeln(); + } catch (error) { + showAlert('Fehler beim Kopieren: ' + error.message, 'error'); + } +} + +// ============ Regel-Modal (NEU) ============ + +let alleOrdner = []; // Cache für Ordner-Liste + +// Toggle Ziel-Ordner Feld basierend auf "Nur umbenennen" Checkbox +function toggleZielOrdnerGruppe() { + const nurUmbenennen = document.getElementById('regel-nur-umbenennen').checked; + const zielGruppe = document.getElementById('ziel-ordner-gruppe'); + if (zielGruppe) { + zielGruppe.style.display = nurUmbenennen ? 'none' : 'block'; + } +} + +async function zeigeRegelModal(regel = null) { + editierteRegelId = regel?.id || null; + document.getElementById('regel-modal-title').textContent = regel ? 'Regel bearbeiten' : 'Regel hinzufügen'; + + // Grundeinstellungen + document.getElementById('regel-name').value = regel?.name || ''; + document.getElementById('regel-prioritaet').value = regel?.prioritaet || 100; + document.getElementById('regel-ist-fallback').checked = regel?.ist_fallback || false; + document.getElementById('regel-nur-umbenennen').checked = regel?.nur_umbenennen || false; + document.getElementById('regel-ziel-ordner').value = regel?.ziel_ordner || ''; + toggleZielOrdnerGruppe(); // Ziel-Ordner Feld ein/ausblenden + document.getElementById('regel-schema').value = regel?.schema || '{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf'; + document.getElementById('regel-unterordner').value = regel?.unterordner || ''; + + // Erkennungsmuster aus JSON extrahieren + const muster = regel?.muster || {}; + document.getElementById('regel-keywords').value = muster.keywords || ''; + document.getElementById('regel-keywords-nicht').value = muster.keywords_nicht || ''; + document.getElementById('regel-text-regex').value = muster.text_regex || ''; + + // Extraktion-Tabelle befüllen + const extraktion = regel?.extraktion || {}; + befuelleExtraktionTabelle(extraktion); + + // Ordner-Checkboxen laden + await ladeOrdnerCheckboxen(editierteRegelId); + + // Test-Bereich zurücksetzen + document.getElementById('regel-test-text').value = ''; + document.getElementById('regel-test-ergebnis').classList.add('hidden'); + document.getElementById('test-datei-name').textContent = ''; + const displayEl = document.getElementById('regel-test-text-display'); + if (displayEl) { + displayEl.innerHTML = '

    PDF hochladen um Text anzuzeigen

    '; + } + + document.getElementById('regel-modal').classList.remove('hidden'); +} + +function befuelleExtraktionTabelle(extraktion) { + const tbody = document.getElementById('extraktion-tbody'); + tbody.innerHTML = ''; + + // Standard-Felder mit Beispiel-Regex + const standardFelder = [ + {name: 'datum', beispiel: 'Datum[:\\s]*(\\d{1,2}\\.\\d{1,2}\\.\\d{4})'}, + {name: 'nummer', beispiel: 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)\nBeleg-?Nr[:\\s]*(\\S+)'}, + {name: 'betrag', beispiel: 'Gesamt[:\\s]*([\\d.,]+)\\s*€'}, + {name: 'firma', beispiel: 'Auto = aus Absender/Text'} + ]; + const vorhandeneFelder = new Set(standardFelder.map(f => f.name)); + + // Zuerst Standard-Felder mit Beispielen als Placeholder + for (const {name, beispiel} of standardFelder) { + const config = extraktion[name]; + fuegeExtraktionsZeileHinzuMitBeispiel(name, config, false, beispiel); + } + + // Dann zusätzliche Felder + for (const [feld, config] of Object.entries(extraktion)) { + if (!vorhandeneFelder.has(feld)) { + fuegeExtraktionsZeileHinzu(feld, config, true); + } + } +} + +function fuegeExtraktionsZeileHinzuMitBeispiel(feld, config, removable, beispielPlaceholder) { + const tbody = document.getElementById('extraktion-tbody'); + const row = document.createElement('tr'); + row.className = 'extraktion-row'; + + const istWert = config && 'wert' in config; + const istRegex = config && 'regex' in config; + let wert = ''; + if (istWert) { + wert = config.wert; + } else if (istRegex) { + wert = Array.isArray(config.regex) ? config.regex.join('\n') : config.regex; + } + + // Auswahl-Modus (max/min/first/last) + const auswahl = config?.auswahl || 'first'; + + // Placeholder: Beispiel oder Standard + let placeholder = beispielPlaceholder || 'Regex-Muster mit (Gruppe)'; + if (istWert) { + placeholder = 'Fester Wert eingeben'; + } + + row.innerHTML = ` + + + + + + + + + + + + + + ${removable ? '' : ''} + + `; + + tbody.appendChild(row); +} + +// Beispiel-Placeholders für verschiedene Feldtypen +const REGEX_BEISPIELE = { + 'datum': 'Datum[:\\s]*(\\d{1,2}\\.\\d{1,2}\\.\\d{4})', + 'nummer': 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)', + 'betrag': 'Gesamt[:\\s]*([\\d.,]+)\\s*€', + 'firma': 'z.B. Sonepar|ACME GmbH', + 'default': 'Muster[:\\s]*(\\S+)' +}; + +function fuegeExtraktionsZeileHinzu(feld = '', config = null, removable = true, istBeispiel = false) { + const tbody = document.getElementById('extraktion-tbody'); + const row = document.createElement('tr'); + row.className = 'extraktion-row' + (istBeispiel ? ' beispiel' : ''); + + const istWert = config && 'wert' in config; + const istRegex = config && 'regex' in config; + // Bei mehreren Regex-Patterns: Array zu Zeilenumbrüchen + let wert = ''; + if (istWert) { + wert = config.wert; + } else if (istRegex) { + wert = Array.isArray(config.regex) ? config.regex.join('\n') : config.regex; + } + + // Auswahl-Modus (max/min/first/last) + const auswahl = config?.auswahl || 'first'; + + // Placeholder basierend auf Feldname + let placeholder = 'Regex-Muster mit (Gruppe)'; + if (feld) { + placeholder = REGEX_BEISPIELE[feld.toLowerCase()] || REGEX_BEISPIELE['default']; + } + if (istWert) { + placeholder = 'Fester Wert eingeben'; + } + + row.innerHTML = ` + + + + + + + + + + + + + + ${removable ? '' : ''} + + `; + + tbody.appendChild(row); +} + +function updateExtPlaceholder(selectEl) { + const row = selectEl.closest('tr'); + const textarea = row.querySelector('.ext-wert'); + const typ = selectEl.value; + + if (typ === 'wert') { + textarea.placeholder = 'Fester Wert eingeben'; + } else if (typ === 'regex') { + textarea.placeholder = 'Regex-Muster mit (Gruppe)\nZeile 2 = Alternative'; + } else { + textarea.placeholder = 'Leer = globale Extraktoren'; + } +} + +function fuegeExtraktionsFeldHinzu() { + fuegeExtraktionsZeileHinzu('', null, true); +} + +function fuegeBeispieleHinzu() { + // Beispiele für neue Regeln + const tbody = document.getElementById('extraktion-tbody'); + if (tbody.children.length === 0) { + // Beispiel 1: Einfache Regex + fuegeExtraktionsZeileHinzu('nummer', {regex: 'Rechnungs-?Nr\\.?[:\\s]*(\\S+)'}, true, true); + // Beispiel 2: Mehrere Regex-Alternativen + fuegeExtraktionsZeileHinzu('betrag', {regex: ['Gesamt[:\\s]*([\\d.,]+)\\s*€', 'Summe[:\\s]*([\\d.,]+)']}, true, true); + } +} + +function sammleExtraktionAusTabelle() { + const extraktion = {}; + const rows = document.querySelectorAll('#extraktion-tbody .extraktion-row'); + + for (const row of rows) { + const feld = row.querySelector('.ext-feld').value.trim(); + const typ = row.querySelector('.ext-typ').value; + const wertElement = row.querySelector('.ext-wert'); + const wert = wertElement ? wertElement.value.trim() : ''; + const auswahlElement = row.querySelector('.ext-auswahl'); + const auswahl = auswahlElement ? auswahlElement.value : 'first'; + + if (!feld) continue; + + if (typ === 'wert' && wert) { + extraktion[feld] = { wert: wert }; + } else if (typ === 'regex' && wert) { + // Mehrere Zeilen = Mehrere Regex-Alternativen + const zeilen = wert.split('\n').map(z => z.trim()).filter(z => z); + if (zeilen.length === 1) { + extraktion[feld] = { regex: zeilen[0] }; + } else if (zeilen.length > 1) { + extraktion[feld] = { regex: zeilen }; + } + // Auswahl hinzufügen wenn nicht "first" (Standard) + if (auswahl && auswahl !== 'first' && extraktion[feld]) { + extraktion[feld].auswahl = auswahl; + } + } + // 'auto' = nichts eintragen, globale Extraktoren werden genutzt + } + + return extraktion; +} + +// Cache für freie Ordner der aktuellen Regel +let aktuelleFreieOrdner = []; + +async function ladeOrdnerCheckboxen(regelId) { + const container = document.getElementById('regel-ordner-liste'); + const freieOrdnerContainer = document.getElementById('regel-freie-ordner'); + + try { + // Alle Ordner laden + alleOrdner = await api('/ordner'); + + // Zugewiesene Ordner und freie Ordner laden (wenn Regel existiert) + let zugewieseneIds = []; + aktuelleFreieOrdner = []; + + if (regelId) { + const result = await api(`/regeln/${regelId}/ordner`); + zugewieseneIds = result.ordner_ids || []; + aktuelleFreieOrdner = result.freie_ordner || []; + } + + // Ziel-Ordner aus Grobsortierung (dedupliziert) + const zielOrdnerMap = new Map(); + for (const ordner of alleOrdner) { + if (!zielOrdnerMap.has(ordner.ziel_ordner)) { + zielOrdnerMap.set(ordner.ziel_ordner, { + id: ordner.id, + name: ordner.name, + ziel_ordner: ordner.ziel_ordner + }); + } + } + + if (zielOrdnerMap.size === 0) { + container.innerHTML = '

    Keine Grobsortierung vorhanden. Bitte zuerst Ordner anlegen.

    '; + } else { + container.innerHTML = Array.from(zielOrdnerMap.values()).map(ordner => ` + + `).join(''); + } + + // Freie Ordner anzeigen + renderFreieOrdner(); + + } catch (error) { + container.innerHTML = `

    Fehler: ${error.message}

    `; + } +} + +function renderFreieOrdner() { + const container = document.getElementById('regel-freie-ordner'); + if (!container) return; + + if (aktuelleFreieOrdner.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = aktuelleFreieOrdner.map((pfad, index) => ` +
    + ${escapeHtml(pfad)} + +
    + `).join(''); +} + +function fuegeFreienOrdnerHinzu() { + const input = document.getElementById('regel-neuer-ordner'); + const pfad = input.value.trim(); + + if (!pfad) { + showAlert('Bitte einen Ordner-Pfad eingeben oder per Filebrowser wählen', 'warning'); + return; + } + + // Prüfen ob bereits vorhanden + if (aktuelleFreieOrdner.includes(pfad)) { + showAlert('Dieser Ordner ist bereits hinzugefügt', 'warning'); + return; + } + + aktuelleFreieOrdner.push(pfad); + input.value = ''; + renderFreieOrdner(); +} + +function entferneFreienOrdner(index) { + aktuelleFreieOrdner.splice(index, 1); + renderFreieOrdner(); +} + +function sammleZugewieseneOrdner() { + const checkboxen = document.querySelectorAll('#regel-ordner-liste input[type="checkbox"]:checked'); + return Array.from(checkboxen).map(cb => parseInt(cb.value)); +} + +async function bearbeiteRegel(id) { + try { + const regeln = await api('/regeln'); + const regel = regeln.find(r => r.id === id); + if (regel) zeigeRegelModal(regel); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function speichereRegel() { + // Muster aus UI-Feldern zusammenbauen + const muster = {}; + const keywords = document.getElementById('regel-keywords').value.trim(); + const keywordsNicht = document.getElementById('regel-keywords-nicht').value.trim(); + const textRegex = document.getElementById('regel-text-regex').value.trim(); + + if (keywords) muster.keywords = keywords; + if (keywordsNicht) muster.keywords_nicht = keywordsNicht; + if (textRegex) muster.text_regex = textRegex; + + // Extraktion aus Tabelle sammeln + const extraktion = sammleExtraktionAusTabelle(); + + // Versteckte Felder für Kompatibilität aktualisieren + document.getElementById('regel-muster').value = JSON.stringify(muster); + document.getElementById('regel-extraktion').value = JSON.stringify(extraktion); + + const data = { + name: document.getElementById('regel-name').value.trim(), + prioritaet: parseInt(document.getElementById('regel-prioritaet').value), + muster, + extraktion, + nur_umbenennen: document.getElementById('regel-nur-umbenennen').checked, + ziel_ordner: document.getElementById('regel-ziel-ordner').value.trim() || null, + schema: document.getElementById('regel-schema').value.trim(), + unterordner: document.getElementById('regel-unterordner').value.trim() || null, + ist_fallback: document.getElementById('regel-ist-fallback').checked + }; + + if (!data.name) { + showAlert('Bitte einen Namen eingeben', 'warning'); + return; + } + + try { + let regelId = editierteRegelId; + + if (regelId) { + await api(`/regeln/${regelId}`, { method: 'PUT', body: JSON.stringify(data) }); + } else { + const result = await api('/regeln', { method: 'POST', body: JSON.stringify(data) }); + regelId = result.id; + } + + // Ordner-Zuweisungen speichern (inkl. freie Ordner) + const zugewieseneOrdner = sammleZugewieseneOrdner(); + await api(`/regeln/${regelId}/ordner`, { + method: 'PUT', + body: JSON.stringify({ + ordner_ids: zugewieseneOrdner, + freie_ordner: aktuelleFreieOrdner + }) + }); + + schliesseModal('regel-modal'); + ladeRegeln(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function regelAktivieren(id) { + try { + await api(`/regeln/${id}/aktivieren`, { method: 'POST' }); + ladeRegeln(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function regelLoeschen(id) { + if (!await showConfirm('Regel wirklich löschen?')) return; + try { + await api(`/regeln/${id}`, { method: 'DELETE' }); + ladeRegeln(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// PDF für Regel-Test hochladen +let testPdfDatei = null; + +async function ladeTestPDF() { + const input = document.getElementById('regel-test-datei'); + if (!input.files || !input.files[0]) return; + + testPdfDatei = input.files[0]; + document.getElementById('test-datei-name').textContent = `📄 ${testPdfDatei.name}`; + + // PDF-Text extrahieren + const formData = new FormData(); + formData.append('datei', testPdfDatei); + + try { + zeigeLoading('Extrahiere PDF-Text...'); + const response = await fetch('/api/pdf/extrahieren', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Fehler beim Extrahieren'); + } + + const result = await response.json(); + + // Text in beide Elemente (verstecktes textarea + sichtbares div) + document.getElementById('regel-test-text').value = result.text; + + // Text im Display-Bereich anzeigen (mit kompakter Info-Zeile) + const displayEl = document.getElementById('regel-test-text-display'); + let badges = []; + if (result.seiten) badges.push(`${result.seiten}S`); + if (result.ist_zugferd) badges.push('ZUGFeRD'); + if (result.ist_signiert) badges.push('Signiert'); + if (result.ocr_durchgefuehrt) badges.push('OCR'); + + const infoHtml = badges.length > 0 + ? `${badges.join(' · ')}
    ` + : ''; + displayEl.innerHTML = infoHtml + `
    ${escapeHtml(result.text)}
    `; + + // Automatisch testen + testeRegelLive(); + } catch (error) { + showAlert(error.message, 'error'); + } finally { + versteckeLoading(); + } +} + +async function testeRegelLive() { + console.log('testeRegelLive() gestartet'); + + const textElement = document.getElementById('regel-test-text'); + const text = textElement ? textElement.value : ''; + + if (!text) { + // Kein Alert - einfach nichts tun wenn kein Text + return; + } + + // Muster aus UI-Feldern sammeln + const muster = {}; + const keywordsStr = document.getElementById('regel-keywords')?.value.trim(); + const keywordsNicht = document.getElementById('regel-keywords-nicht')?.value.trim(); + const textRegex = document.getElementById('regel-text-regex')?.value.trim(); + + if (keywordsStr) muster.keywords = keywordsStr; + if (keywordsNicht) muster.keywords_nicht = keywordsNicht; + if (textRegex) muster.text_regex = textRegex; + + // Extraktion aus Tabelle sammeln + const extraktion = sammleExtraktionAusTabelle(); + + const regel = { + name: 'Test', + muster, + extraktion, + schema: document.getElementById('regel-schema').value.trim() || '{datum} - Dokument.pdf' + }; + + try { + const result = await api('/regeln/test', { + method: 'POST', + body: JSON.stringify({ regel, text }) + }); + + const container = document.getElementById('regel-test-ergebnis'); + container.classList.remove('hidden'); + + // Status Box mit Keyword-Info + const statusDiv = document.getElementById('test-status'); + let statusHtml = ''; + + if (result.passt) { + statusDiv.className = 'test-status-box success'; + statusHtml = '✓ Regel passt!'; + } else { + statusDiv.className = 'test-status-box error'; + statusHtml = '✗ Regel passt nicht'; + } + + // Keyword-Matching anzeigen + if (keywordsStr) { + const keywords = keywordsStr.split(',').map(k => k.trim()).filter(k => k); + const gefunden = keywords.filter(kw => text.toLowerCase().includes(kw.toLowerCase())); + const nichtGefunden = keywords.filter(kw => !text.toLowerCase().includes(kw.toLowerCase())); + + if (gefunden.length > 0 || nichtGefunden.length > 0) { + statusHtml += '
    '; + if (gefunden.length > 0) { + statusHtml += `✓ ${gefunden.join(', ')}`; + } + if (nichtGefunden.length > 0) { + statusHtml += ` ✗ ${nichtGefunden.join(', ')}`; + } + statusHtml += '
    '; + } + } + statusDiv.innerHTML = statusHtml; + + // Extrahierte Felder anzeigen + const extrahiertDiv = document.getElementById('test-extrahiert'); + if (result.extrahiert && Object.keys(result.extrahiert).length > 0) { + let html = ''; + for (const [key, value] of Object.entries(result.extrahiert)) { + html += `
    + {${key}} + ${escapeHtml(String(value))} +
    `; + } + extrahiertDiv.innerHTML = html; + } else { + extrahiertDiv.innerHTML = 'Keine Felder extrahiert'; + } + + // Vorgeschlagener Dateiname + const dateinameDiv = document.getElementById('test-dateiname'); + if (result.dateiname) { + dateinameDiv.innerHTML = `📁 ${escapeHtml(result.dateiname)}`; + dateinameDiv.style.display = 'block'; + } else { + dateinameDiv.style.display = 'none'; + } + + // Text-Highlighting im PDF-Display + highlightPdfText(text, muster, result.extrahiert || {}); + + } catch (error) { + console.error('testeRegelLive Fehler:', error); + } +} + +// Text-Highlighting für Keywords und extrahierte Werte +function highlightPdfText(text, muster, extrahiert) { + const contentEl = document.getElementById('pdf-text-content'); + if (!contentEl) return; + + let html = escapeHtml(text); + + // Keywords highlighten (grün) + if (muster.keywords) { + const keywords = typeof muster.keywords === 'string' + ? muster.keywords.split(',').map(k => k.trim()) + : muster.keywords; + + keywords.forEach(kw => { + if (kw) { + const regex = new RegExp(`(${escapeRegex(kw)})`, 'gi'); + html = html.replace(regex, '$1'); + } + }); + } + + // Extrahierte Werte highlighten (orange) + if (extrahiert) { + Object.values(extrahiert).forEach(wert => { + if (wert && typeof wert === 'string' && wert.length > 2) { + const regex = new RegExp(`(${escapeRegex(wert)})`, 'g'); + html = html.replace(regex, '$1'); + } + }); + } + + contentEl.innerHTML = html; +} + +// Hilfsfunktion: Regex-Sonderzeichen escapen +function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ============ Regex-Helfer: Aus Markierung Regex erstellen ============ + +function regexAusMarkierung() { + // Markierten Text aus dem PDF-Content-Bereich holen + const selection = window.getSelection(); + const selectedText = selection.toString().trim(); + + if (!selectedText) { + showAlert('Bitte zuerst einen Text im PDF-Inhalt markieren', 'warning'); + return; + } + + // Kontext vor und nach der Markierung holen (wenn möglich) + const pdfContent = document.getElementById('pdf-text-content'); + if (!pdfContent) { + showAlert('Bitte zuerst eine PDF laden', 'warning'); + return; + } + + const fullText = pdfContent.textContent || ''; + const pos = fullText.indexOf(selectedText); + + let regexVorschlag = ''; + let beschreibung = ''; + + // Prüfen ob es ein Muster ist (Datum, Betrag, Nummer, Label+Nummer) + if (/^\d{1,2}[.\/]\d{1,2}[.\/]\d{2,4}$/.test(selectedText)) { + // Datum erkannt + regexVorschlag = erstelleDatumRegex(fullText, selectedText, pos); + beschreibung = 'Datum erkannt'; + } else if (/^[\d.,]+\s*(€|EUR)?$/.test(selectedText)) { + // Betrag erkannt + regexVorschlag = erstelleBetragRegex(fullText, selectedText, pos); + beschreibung = 'Betrag erkannt'; + } else if (/^[A-Z0-9][\w\-\/]+$/i.test(selectedText)) { + // Nummer/ID erkannt (nur Buchstaben/Zahlen ohne Leerzeichen) + regexVorschlag = erstelleNummerRegex(fullText, selectedText, pos); + beschreibung = 'Nummer/ID erkannt'; + } else if (/^[A-Za-zäöüÄÖÜß]+[:\s.\-#]+\d+$/i.test(selectedText)) { + // Label + Nummer erkannt (z.B. "Rechnung 2493150", "Invoice: 12345") + regexVorschlag = erstelleLabelNummerRegex(selectedText); + beschreibung = 'Label + Nummer erkannt'; + } else if (/^[A-Za-zäöüÄÖÜß]+[:\s.\-#]+[\w\-\/]+$/i.test(selectedText)) { + // Label + ID erkannt (z.B. "Beleg RE-2024-001") + regexVorschlag = erstelleLabelIdRegex(selectedText); + beschreibung = 'Label + ID erkannt'; + } else { + // Allgemeiner Text - Kontext-basiertes Regex + regexVorschlag = erstelleKontextRegex(fullText, selectedText, pos); + beschreibung = 'Text mit Kontext'; + } + + // Ergebnis anzeigen + zeigeRegexHelferErgebnis(selectedText, regexVorschlag, beschreibung); +} + +function erstelleDatumRegex(fullText, selected, pos) { + // Kontext vor dem Datum finden (z.B. "Rechnungsdatum:", "Datum:") + const kontextVorher = fullText.substring(Math.max(0, pos - 30), pos); + const labelMatch = kontextVorher.match(/(\w+[-\s]?\w*)[:\s]*$/); + + if (labelMatch) { + const label = labelMatch[1].trim(); + return `${escapeRegex(label)}[:\\s]*(\\d{1,2}[./]\\d{1,2}[./]\\d{2,4})`; + } + return '(\\d{1,2}[./]\\d{1,2}[./]\\d{2,4})'; +} + +function erstelleBetragRegex(fullText, selected, pos) { + const kontextVorher = fullText.substring(Math.max(0, pos - 30), pos); + const labelMatch = kontextVorher.match(/(\w+[-\s]?\w*)[:\s]*$/); + + if (labelMatch) { + const label = labelMatch[1].trim(); + return `${escapeRegex(label)}[:\\s]*([\\d.,]+)\\s*€?`; + } + return '([\\d.,]+)\\s*€'; +} + +function erstelleNummerRegex(fullText, selected, pos) { + const kontextVorher = fullText.substring(Math.max(0, pos - 40), pos); + const labelMatch = kontextVorher.match(/([A-Za-zäöüÄÖÜß]+(?:[-\s]?[A-Za-zäöüÄÖÜß]+)?)[:\s#]*$/); + + if (labelMatch) { + const label = labelMatch[1].trim(); + return `${escapeRegex(label)}[:\\s#]*([A-Z0-9][\\w\\-/]+)`; + } + return '([A-Z0-9][\\w\\-/]+)'; +} + +function erstelleLabelNummerRegex(selected) { + // Für Texte wie "Rechnung 2493150", "Invoice: 12345", "Beleg-Nr. 789" + // Splittet in Label und Nummer, erstellt Regex das die Nummer captured + const match = selected.match(/^([A-Za-zäöüÄÖÜß]+)[:\s.\-#]+(\d+)$/i); + if (match) { + const label = match[1]; + return `${escapeRegex(label)}[:\\s.\\-#]*(\\d+)`; + } + // Fallback: ganzer Text escaped mit Gruppe + return `(${escapeRegex(selected)})`; +} + +function erstelleLabelIdRegex(selected) { + // Für Texte wie "Beleg RE-2024-001", "Order ABC123" + // Splittet in Label und ID, erstellt Regex das die ID captured + const match = selected.match(/^([A-Za-zäöüÄÖÜß]+)[:\s.\-#]+([\w\-\/]+)$/i); + if (match) { + const label = match[1]; + return `${escapeRegex(label)}[:\\s.\\-#]*([\\w\\-/]+)`; + } + // Fallback: ganzer Text escaped mit Gruppe + return `(${escapeRegex(selected)})`; +} + +function erstelleKontextRegex(fullText, selected, pos) { + // Kontext vor dem Text holen (max 50 Zeichen) + const kontextVorher = fullText.substring(Math.max(0, pos - 50), pos); + + // Versuche ein Label zu finden (z.B. "Rechnungsnummer:", "Rechnung Nr.", "Beleg:") + // Suche nach Wort(en) gefolgt von : oder Leerzeichen direkt vor dem markierten Text + const labelMatch = kontextVorher.match(/([A-Za-zäöüÄÖÜß]+(?:[-\s]?[A-Za-zäöüÄÖÜß]+)?)\s*[:\s#.]*\s*$/); + + if (labelMatch) { + const label = labelMatch[1].trim(); + if (label.length >= 2) { + // Label gefunden - baue Regex: Label gefolgt von : oder Whitespace, dann der Wert in einer Gruppe + return `${escapeRegex(label)}[:\\s#.]*\\s*(${escapeRegex(selected)})`; + } + } + + // Kein brauchbares Label gefunden - nimm die letzten 1-2 Wörter als Kontext + const worte = kontextVorher.trim().split(/\s+/).filter(w => w.length > 0); + if (worte.length >= 1) { + // Nimm das letzte Wort als Kontext (escapen, dann \\s+ anhängen) + const letztesWort = worte[worte.length - 1]; + if (letztesWort.length >= 2 && /[A-Za-zäöüÄÖÜß]/.test(letztesWort)) { + return `${escapeRegex(letztesWort)}\\s+(${escapeRegex(selected)})`; + } + } + + // Fallback: nur den markierten Text escapen + return `(${escapeRegex(selected)})`; +} + +async function zeigeRegexHelferErgebnis(originalText, regex, beschreibung) { + // Modal oder Inline-Anzeige + const container = document.getElementById('regex-helfer-ergebnis'); + if (container) { + container.classList.remove('hidden'); + container.innerHTML = ` +
    +
    ${beschreibung}
    +
    + Markierter Text: ${escapeHtml(originalText)} +
    +
    + Regex-Vorschlag: +
    + +
    + + +
    +
    + `; + } else { + // Fallback: Dialog + Clipboard + if (await showConfirm(`${beschreibung}\n\nRegex-Vorschlag:\n${regex}\n\nIn Zwischenablage kopieren?`, 'Regex-Vorschlag')) { + navigator.clipboard.writeText(regex).then(() => { + showAlert('Regex in Zwischenablage kopiert!', 'success'); + }); + } + } +} + +function kopieRegelRegex() { + const input = document.getElementById('regex-vorschlag-input'); + if (input) { + navigator.clipboard.writeText(input.value).then(() => { + // Kurzes Feedback + const btn = event.target; + const originalText = btn.textContent; + btn.textContent = '✓ Kopiert!'; + setTimeout(() => btn.textContent = originalText, 1500); + }); + } +} + +// Alte Funktion für Kompatibilität +async function testeRegel() { + console.log('testeRegel() aufgerufen -> testeRegelLive()'); + testeRegelLive(); +} + +// ============ Regel-Assistent ============ + +const REGEX_VORLAGEN = { + datum: { + auto: null, // Nutzt globale Extraktoren + rechnungsdatum: "Rechnungsdatum[:\\s]*(\\d{2}[./]\\d{2}[./]\\d{4})", + datum: "Datum[:\\s]*(\\d{2}[./]\\d{2}[./]\\d{4})", + beliebig: "(\\d{2}[./]\\d{2}[./]\\d{4})" + }, + betrag: { + auto: null, + gesamtbetrag: "Gesamtbetrag[:\\s]*([\\d.,]+)", + summe: "Summe[:\\s]*([\\d.,]+)", + brutto: "Brutto[:\\s]*([\\d.,]+)" + }, + nummer: { + auto: null, + rechnungsnummer: "Rechnungsnummer[:\\s#]*([A-Z0-9][\\w\\-/]+)", + belegnr: "Beleg-?Nr\\.?[:\\s#]*([A-Z0-9][\\w\\-/]+)", + invoice: "Invoice[:\\s#]*([A-Z0-9][\\w\\-/]+)" + } +}; + +function zeigeRegelAssistent() { + // Modal öffnen + document.getElementById('assistent-modal').classList.remove('hidden'); + + // Event-Listener für Live-Vorschau + ['ass-keywords', 'ass-firma', 'ass-datum-typ', 'ass-betrag-typ', 'ass-nummer-typ', 'ass-schema', 'ass-unterordner'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', aktualisiereAssistentVorschau); + if (el) el.addEventListener('change', aktualisiereAssistentVorschau); + }); + ['ass-datum-aktiv', 'ass-betrag-aktiv', 'ass-nummer-aktiv'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('change', aktualisiereAssistentVorschau); + }); + + aktualisiereAssistentVorschau(); +} + +function aktualisiereAssistentVorschau() { + const vorschauDiv = document.getElementById('ass-vorschau'); + const regel = baueRegelAusAssistent(); + + let html = ''; + + // Keywords + const keywords = document.getElementById('ass-keywords').value.trim(); + if (keywords) { + html += `
    Erkennung: Dokument muss "${keywords}" enthalten
    `; + } else { + html += `
    Erkennung: Passt auf ALLE Dateien (keine Keywords)
    `; + } + + // Firma + const firma = document.getElementById('ass-firma').value.trim(); + if (firma) { + html += `
    Firma: ${escapeHtml(firma)}
    `; + } + + // Felder + html += `
    Extrahiere: `; + const felder = []; + if (document.getElementById('ass-datum-aktiv').checked) felder.push('📅 Datum'); + if (document.getElementById('ass-betrag-aktiv').checked) felder.push('💰 Betrag'); + if (document.getElementById('ass-nummer-aktiv').checked) felder.push('🔢 Nummer'); + html += felder.join(', ') || 'nichts'; + html += '
    '; + + // Beispiel-Dateiname + const schema = document.getElementById('ass-schema').value; + let beispiel = schema + .replace('{datum}', '2024-01-15') + .replace('{firma}', firma || 'Firma') + .replace('{nummer}', 'RE-12345') + .replace('{betrag}', '199,99'); + html += `
    Beispiel-Dateiname:
    ${escapeHtml(beispiel)}
    `; + + vorschauDiv.innerHTML = html; +} + +function baueRegelAusAssistent() { + const keywords = document.getElementById('ass-keywords').value.trim(); + const firma = document.getElementById('ass-firma').value.trim(); + const schema = document.getElementById('ass-schema').value; + const unterordner = document.getElementById('ass-unterordner').value.trim(); + + // Muster + const muster = {}; + if (keywords) { + muster.keywords = keywords; + } + + // Extraktion + const extraktion = {}; + + // Firma + if (firma) { + extraktion.firma = { wert: firma }; + } + + // Datum + if (document.getElementById('ass-datum-aktiv').checked) { + const datumTyp = document.getElementById('ass-datum-typ').value; + if (datumTyp !== 'auto' && REGEX_VORLAGEN.datum[datumTyp]) { + extraktion.datum = { regex: REGEX_VORLAGEN.datum[datumTyp] }; + } + // Bei "auto" wird nichts eingetragen - globale Extraktoren werden genutzt + } + + // Betrag + if (document.getElementById('ass-betrag-aktiv').checked) { + const betragTyp = document.getElementById('ass-betrag-typ').value; + if (betragTyp !== 'auto' && REGEX_VORLAGEN.betrag[betragTyp]) { + extraktion.betrag = { regex: REGEX_VORLAGEN.betrag[betragTyp], typ: "betrag" }; + } + } + + // Nummer + if (document.getElementById('ass-nummer-aktiv').checked) { + const nummerTyp = document.getElementById('ass-nummer-typ').value; + if (nummerTyp !== 'auto' && REGEX_VORLAGEN.nummer[nummerTyp]) { + extraktion.nummer = { regex: REGEX_VORLAGEN.nummer[nummerTyp] }; + } + } + + return { muster, extraktion, schema, unterordner }; +} + +function assistentUebernehmen() { + const regel = baueRegelAusAssistent(); + + // In die Regel-Felder übernehmen + document.getElementById('regel-muster').value = JSON.stringify(regel.muster, null, 2); + document.getElementById('regel-extraktion').value = JSON.stringify(regel.extraktion, null, 2); + document.getElementById('regel-schema').value = regel.schema; + document.getElementById('regel-unterordner').value = regel.unterordner || ''; + + // Regelname vorschlagen wenn leer + const nameField = document.getElementById('regel-name'); + if (!nameField.value.trim()) { + const firma = document.getElementById('ass-firma').value.trim(); + const keywords = document.getElementById('ass-keywords').value.trim(); + if (firma) { + nameField.value = firma + ' Rechnung'; + } else if (keywords) { + nameField.value = keywords.split(',')[0].trim() + ' Rechnung'; + } + } + + schliesseModal('assistent-modal'); + + // Wenn PDF-Text vorhanden, automatisch testen + if (document.getElementById('regel-test-text').value.trim()) { + setTimeout(() => testeRegelLive(), 300); + } +} + +// ============ Auto-Regex Generator ============ + +let letzteAutoRegexErgebnis = null; + +async function autoRegexGenerieren() { + const input = document.getElementById('regel-test-datei'); + if (!input.files || !input.files[0]) { + showAlert('Bitte zuerst eine PDF-Datei hochladen', 'warning'); + return; + } + + const formData = new FormData(); + formData.append('datei', input.files[0]); + + try { + zeigeLoading('Analysiere PDF...'); + const response = await fetch('/api/pdf/auto-regex', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Fehler bei der Analyse'); + } + + const result = await response.json(); + letzteAutoRegexErgebnis = result; + + // Ergebnis anzeigen + const container = document.getElementById('auto-regex-ergebnis'); + const felderDiv = document.getElementById('auto-regex-felder'); + const keywordsDiv = document.getElementById('auto-regex-keywords'); + + container.classList.remove('hidden'); + + // Erkannte Felder anzeigen + if (result.erkannte_felder && result.erkannte_felder.length > 0) { + let html = ''; + html += ''; + + for (const feld of result.erkannte_felder) { + const feldName = { + 'datum': '📅 Datum', + 'betrag': '💰 Betrag', + 'nummer': '🔢 Nummer', + 'firma': '🏢 Firma' + }[feld.feld] || feld.feld; + + html += ` + + + + `; + } + html += '
    FeldWertKontext
    ${feldName}${escapeHtml(feld.extrahiert || feld.wert)}${escapeHtml(feld.kontext)}
    '; + felderDiv.innerHTML = html; + } else { + felderDiv.innerHTML = '

    Keine Felder automatisch erkannt

    '; + } + + // Keywords anzeigen + if (result.gefundene_keywords && result.gefundene_keywords.length > 0) { + keywordsDiv.innerHTML = `
    + 🏷️ Keywords: + ${result.gefundene_keywords.join(', ')} +
    `; + } else { + keywordsDiv.innerHTML = ''; + } + + // Text auch anzeigen + if (result.text_vorschau) { + document.getElementById('regel-test-text').value = result.text_vorschau; + } + + // DIREKT die Regel-Felder ausfüllen (nicht nur anzeigen) + wendeAutoRegexAn(); + + } catch (error) { + showAlert(error.message, 'error'); + } finally { + versteckeLoading(); + } +} + +function wendeAutoRegexAn() { + if (!letzteAutoRegexErgebnis || !letzteAutoRegexErgebnis.regel_vorschlag) { + // Stille Rückkehr wenn keine Ergebnisse (wird automatisch aufgerufen) + return; + } + + const vorschlag = letzteAutoRegexErgebnis.regel_vorschlag; + const erkannteFelder = letzteAutoRegexErgebnis.erkannte_felder || []; + + // Muster immer übernehmen (auch wenn leer = passt auf alles) + const muster = vorschlag.muster || {}; + document.getElementById('regel-muster').value = JSON.stringify(muster, null, 2); + + // Extraktion immer übernehmen + const extraktion = vorschlag.extraktion || {}; + document.getElementById('regel-extraktion').value = JSON.stringify(extraktion, null, 2); + + // Schema übernehmen + if (vorschlag.schema) { + document.getElementById('regel-schema').value = vorschlag.schema; + } + + // Auch Assistenten-Felder vorausfüllen (für spätere Nutzung) + if (letzteAutoRegexErgebnis.gefundene_keywords) { + document.getElementById('ass-keywords').value = letzteAutoRegexErgebnis.gefundene_keywords.join(', '); + } + + // Firma aus erkannten Feldern + const firmaFeld = erkannteFelder.find(f => f.feld === 'firma'); + if (firmaFeld) { + document.getElementById('ass-firma').value = firmaFeld.wert; + } + + // Regelname vorschlagen + const nameField = document.getElementById('regel-name'); + if (!nameField.value.trim() && firmaFeld) { + nameField.value = firmaFeld.wert + ' Rechnung'; + } + + // Hinweis anzeigen + const container = document.getElementById('auto-regex-ergebnis'); + if (container) { + container.classList.remove('hidden'); + container.innerHTML = `
    + ✓ Erkannte Muster wurden übernommen!
    + Die Felder wurden automatisch ausgefüllt. +
    `; + } + + // Auto-Test starten + setTimeout(() => testeRegelLive(), 500); +} + +// ============ Sortierung starten ============ + +async function sortierungStarten() { + const container = document.getElementById('sortierung-log'); + container.innerHTML = '

    Sortierung läuft...

    '; + + try { + const result = await api('/sortierung/starten', { method: 'POST' }); + zeigeSortierungLog(result); + } catch (error) { + container.innerHTML = `
    ✗ Fehler: ${escapeHtml(error.message)}
    `; + } +} + +function zeigeSortierungLog(result) { + const container = document.getElementById('sortierung-log'); + + if (!result.verarbeitet || result.verarbeitet.length === 0) { + container.innerHTML = '

    Keine Dateien verarbeitet

    '; + return; + } + + let html = `
    + Gesamt: ${result.gesamt} | Sortiert: ${result.sortiert} | ZUGFeRD: ${result.zugferd} | Fehler: ${result.fehler} +
    `; + + for (const d of result.verarbeitet) { + const status = d.fehler ? 'error' : (d.zugferd ? 'info' : 'success'); + const icon = d.fehler ? '✗' : (d.zugferd ? '🧾' : '✓'); + html += `
    + ${icon} ${escapeHtml(d.neuer_name || d.original)} + ${d.fehler ? `${escapeHtml(d.fehler)}` : ''} +
    `; + } + + container.innerHTML = html; +} + +// ============ BEREICH 3: Zeitpläne ============ + +let editierterZeitplanId = null; + +async function ladeZeitplaene() { + try { + const result = await api('/zeitplaene'); + renderZeitplaene(result.zeitplaene || []); + } catch (error) { + console.error('Fehler:', error); + } +} + +function renderZeitplaene(zeitplaene) { + const container = document.getElementById('zeitplaene-liste'); + + if (!zeitplaene || zeitplaene.length === 0) { + container.innerHTML = '

    Keine Zeitpläne konfiguriert

    '; + return; + } + + container.innerHTML = zeitplaene.map(zp => { + const statusClass = zp.letzter_status === 'erfolg' ? 'success' : (zp.letzter_status === 'fehler' ? 'error' : ''); + const aktivClass = zp.aktiv ? '' : 'opacity: 0.5;'; + const typIcon = zp.typ === 'mail_abruf' ? '📧' : (zp.typ === 'grobsortierung' ? '📁' : '📋'); + const naechste = zp.naechste_ausfuehrung ? formatDatum(zp.naechste_ausfuehrung) : '-'; + const letzte = zp.letzte_ausfuehrung ? formatDatum(zp.letzte_ausfuehrung) : 'Noch nie'; + + return ` +
    +
    +

    ${typIcon} ${escapeHtml(zp.name)} + ${zp.aktiv ? 'Aktiv' : 'Inaktiv'} + ${zp.intervall} +

    + Nächste: ${naechste} | Letzte: ${letzte} + ${zp.letzter_status ? `Status: ${zp.letzter_status}${zp.letzte_meldung ? ' - ' + escapeHtml(zp.letzte_meldung.substring(0, 80)) : ''}` : ''} +
    +
    + + + + +
    +
    + `}).join(''); +} + +async function ladeStatus() { + try { + const result = await api('/status/uebersicht'); + renderStatusUebersicht(result); + } catch (error) { + console.error('Fehler:', error); + } +} + +function renderStatusUebersicht(status) { + const container = document.getElementById('status-uebersicht'); + + let html = '
    '; + + // Postfächer + html += '

    📧 Postfächer

    '; + if (status.postfaecher && status.postfaecher.length > 0) { + for (const p of status.postfaecher) { + const aktiv = p.aktiv ? '🟢' : '⚪'; + const letzte = p.letzter_abruf ? formatDatum(p.letzter_abruf) : 'Nie'; + html += `
    ${aktiv} ${escapeHtml(p.name)}: ${letzte} (${p.letzte_anzahl || 0} Dateien)
    `; + } + } else { + html += '
    Keine Postfächer
    '; + } + html += '
    '; + + // Grobsortierung + html += '

    📁 Grobsortierung

    '; + if (status.quell_ordner && status.quell_ordner.length > 0) { + for (const o of status.quell_ordner) { + const aktiv = o.aktiv ? '🟢' : '⚪'; + html += `
    ${aktiv} ${escapeHtml(o.name)}
    `; + } + } else { + html += '
    Keine Ordner
    '; + } + html += '
    '; + + // Scheduler + html += '

    ⏰ Scheduler

    '; + const schedulerStatus = status.scheduler?.scheduler_laeuft ? '🟢 Läuft' : '🔴 Gestoppt'; + html += `
    ${schedulerStatus}
    `; + html += '
    '; + + html += '
    '; + container.innerHTML = html; +} + +function formatDatum(isoString) { + if (!isoString) return '-'; + const d = new Date(isoString); + // Explizite Formatierung mit führenden Nullen + const tag = String(d.getDate()).padStart(2, '0'); + const monat = String(d.getMonth() + 1).padStart(2, '0'); + const jahr = d.getFullYear(); + const stunde = String(d.getHours()).padStart(2, '0'); + const minute = String(d.getMinutes()).padStart(2, '0'); + return `${tag}.${monat}.${jahr} ${stunde}:${minute}`; +} + +async function zeigeZeitplanModal(zeitplan = null) { + editierterZeitplanId = zeitplan?.id || null; + document.getElementById('zeitplan-modal-title').textContent = zeitplan ? 'Zeitplan bearbeiten' : 'Zeitplan hinzufügen'; + + document.getElementById('zp-name').value = zeitplan?.name || ''; + document.getElementById('zp-typ').value = zeitplan?.typ || 'mail_abruf'; + document.getElementById('zp-intervall').value = zeitplan?.intervall || 'täglich'; + document.getElementById('zp-stunde').value = zeitplan?.stunde ?? 6; + document.getElementById('zp-minute').value = zeitplan?.minute ?? 0; + document.getElementById('zp-wochentag').value = zeitplan?.wochentag ?? 0; + document.getElementById('zp-monatstag').value = zeitplan?.monatstag ?? 1; + + // Postfächer, Ordner und Regeln laden für Dropdowns + await ladeZeitplanOptionen(); + + document.getElementById('zp-postfach').value = zeitplan?.postfach_id || ''; + document.getElementById('zp-ordner').value = zeitplan?.quell_ordner_id || ''; + document.getElementById('zp-regel').value = zeitplan?.regel_id || ''; + + zeitplanTypChanged(); + zeitplanIntervallChanged(); + + document.getElementById('zeitplan-modal').classList.remove('hidden'); +} + +async function ladeZeitplanOptionen() { + try { + // Postfächer laden + const postfaecher = await api('/postfaecher'); + const pfSelect = document.getElementById('zp-postfach'); + pfSelect.innerHTML = '' + + postfaecher.map(p => ``).join(''); + + // Ordner laden + const ordner = await api('/ordner'); + const ordSelect = document.getElementById('zp-ordner'); + ordSelect.innerHTML = '' + + ordner.map(o => ``).join(''); + + // Regeln laden + const regeln = await api('/regeln'); + const regelSelect = document.getElementById('zp-regel'); + regelSelect.innerHTML = '' + + regeln.map(r => ``).join(''); + } catch (error) { + console.error('Fehler:', error); + } +} + +function zeitplanTypChanged() { + const typ = document.getElementById('zp-typ').value; + document.getElementById('zp-postfach-gruppe').classList.toggle('hidden', typ !== 'mail_abruf'); + document.getElementById('zp-ordner-gruppe').classList.toggle('hidden', typ !== 'grobsortierung'); + document.getElementById('zp-regel-gruppe').classList.toggle('hidden', typ !== 'sortierregeln'); +} + +function zeitplanIntervallChanged() { + const intervall = document.getElementById('zp-intervall').value; + document.getElementById('zp-zeit-gruppe').classList.toggle('hidden', intervall === 'stündlich'); + document.getElementById('zp-wochentag-gruppe').classList.toggle('hidden', intervall !== 'wöchentlich'); + document.getElementById('zp-monatstag-gruppe').classList.toggle('hidden', intervall !== 'monatlich'); + + // Info-Text aktualisieren + const info = document.getElementById('zp-intervall-info'); + if (info) { + const infos = { + 'stündlich': 'Wird jede Stunde zur angegebenen Minute ausgeführt', + 'täglich': 'Wird jeden Tag einmal zur angegebenen Uhrzeit ausgeführt', + 'wöchentlich': 'Wird einmal pro Woche am angegebenen Tag und Uhrzeit ausgeführt', + 'monatlich': 'Wird einmal pro Monat am angegebenen Tag und Uhrzeit ausgeführt' + }; + info.textContent = infos[intervall] || ''; + } +} + +async function speichereZeitplan() { + const data = { + name: document.getElementById('zp-name').value.trim(), + typ: document.getElementById('zp-typ').value, + intervall: document.getElementById('zp-intervall').value, + stunde: parseInt(document.getElementById('zp-stunde').value) || 0, + minute: parseInt(document.getElementById('zp-minute').value) || 0, + wochentag: document.getElementById('zp-intervall').value === 'wöchentlich' ? + parseInt(document.getElementById('zp-wochentag').value) : null, + monatstag: document.getElementById('zp-intervall').value === 'monatlich' ? + parseInt(document.getElementById('zp-monatstag').value) : null + }; + + // Optionale IDs + const postfachId = document.getElementById('zp-postfach').value; + const ordnerId = document.getElementById('zp-ordner').value; + const regelId = document.getElementById('zp-regel').value; + + if (data.typ === 'mail_abruf' && postfachId) { + data.postfach_id = parseInt(postfachId); + } + if (data.typ === 'grobsortierung' && ordnerId) { + data.quell_ordner_id = parseInt(ordnerId); + } + if (data.typ === 'sortierregeln' && regelId) { + data.regel_id = parseInt(regelId); + } + + if (!data.name) { + showAlert('Bitte einen Namen eingeben', 'warning'); + return; + } + + try { + if (editierterZeitplanId) { + await api(`/zeitplaene/${editierterZeitplanId}`, { method: 'PUT', body: JSON.stringify(data) }); + } else { + await api('/zeitplaene', { method: 'POST', body: JSON.stringify(data) }); + } + schliesseModal('zeitplan-modal'); + ladeZeitplaene(); + ladeStatus(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function zeitplanAktivieren(id) { + try { + await api(`/zeitplaene/${id}/aktivieren`, { method: 'POST' }); + ladeZeitplaene(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +async function zeitplanAusfuehren(id) { + try { + zeigeLoading('Führe Zeitplan aus...'); + const result = await api(`/zeitplaene/${id}/ausfuehren`, { method: 'POST' }); + showAlert(result.meldung || 'Ausgeführt', 'success', 'Zeitplan ausgeführt'); + ladeZeitplaene(); + ladeStatus(); + } catch (error) { + showAlert(error.message, 'error'); + } finally { + versteckeLoading(); + } +} + +async function zeitplanBearbeiten(id) { + try { + const zeitplan = await api(`/zeitplaene/${id}`); + zeigeZeitplanModal(zeitplan); + } catch (error) { + showAlert('Fehler beim Laden: ' + error.message, 'error'); + } +} + +async function zeitplanLoeschen(id) { + if (!await showConfirm('Zeitplan wirklich löschen?')) return; + try { + await api(`/zeitplaene/${id}`, { method: 'DELETE' }); + ladeZeitplaene(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// ============ Utilities ============ + +function schliesseModal(id) { + document.getElementById(id).classList.add('hidden'); +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function truncatePath(path, maxLength = 35) { + if (!path) return ''; + const escaped = escapeHtml(path); + if (path.length <= maxLength) return escaped; + // Pfad kürzen: Anfang ... Ende + const start = path.substring(0, 15); + const end = path.substring(path.length - 17); + return `${escapeHtml(start)}...${escapeHtml(end)}`; +} + +document.addEventListener('click', (e) => { + if (e.target.classList.contains('modal')) { + e.target.classList.add('hidden'); + } +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden')); + } +}); diff --git a/Source/frontend/templates/index.html b/Source/frontend/templates/index.html new file mode 100755 index 0000000..6dd0692 --- /dev/null +++ b/Source/frontend/templates/index.html @@ -0,0 +1,937 @@ + + + + + + Dateiverwaltung + + + +
    + +
    +
    +

    Dateiverwaltung

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

    📧 Mail-Abruf

    +

    Attachments aus Postfächern in Ordner speichern

    +
    + +
    + +
    +
    +

    Postfächer

    + +
    +
    +
    +

    Keine Postfächer konfiguriert

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

    Letzter Abruf

    +
    +
    +
    +

    Noch kein Abruf durchgeführt

    +
    +
    +
    +
    +
    + + +
    +
    +

    📁 Datei-Sortierung

    +

    Dateien nach Regeln umbenennen und verschieben

    +
    + +
    + +
    +
    +

    Grobsortierung

    + +
    +
    +
    +

    Keine Ordner konfiguriert

    +
    +
    +
    + + +
    +
    +

    Sortier-Regeln

    + +
    +
    +
    +

    Keine Regeln definiert

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

    Verarbeitete Dateien

    +
    +
    +
    +

    Noch keine Dateien verarbeitet

    +
    +
    +
    +
    +
    + + +
    +
    +

    ⏰ Zeitpläne

    +

    Automatische Ausführung von Mail-Abruf und Sortierung

    +
    + +
    + +
    +
    +

    Status-Übersicht

    + +
    +
    +
    +

    Status wird geladen...

    +
    +
    +
    + + +
    +
    +

    Zeitpläne

    + +
    +
    +
    +

    Keine Zeitpläne konfiguriert

    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/regeln/beispiel_regeln.yaml b/Source/regeln/beispiel_regeln.yaml old mode 100644 new mode 100755 similarity index 100% rename from regeln/beispiel_regeln.yaml rename to Source/regeln/beispiel_regeln.yaml diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e5a0a9f..0000000 Binary files a/backend/app/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc deleted file mode 100644 index d5fac33..0000000 Binary files a/backend/app/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 029c69d..0000000 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index c3abe37..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Zentrale Konfiguration""" -import os -from pathlib import Path - -# Basis-Pfade -BASE_DIR = Path(__file__).parent.parent.parent -DATA_DIR = BASE_DIR / "data" -CONFIG_DIR = BASE_DIR / "config" -REGELN_DIR = BASE_DIR / "regeln" - -# Datenbank -DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR}/dateiverwaltung.db") - -# Ordner-Struktur -INBOX_DIR = DATA_DIR / "inbox" -PROCESSED_DIR = DATA_DIR / "processed" -ARCHIVE_DIR = DATA_DIR / "archive" -ZUGFERD_DIR = DATA_DIR / "zugferd" - -# OCR Einstellungen -OCR_LANGUAGE = "deu" # Deutsch -OCR_DPI = 300 - -# Erstelle Ordner falls nicht vorhanden -for dir_path in [INBOX_DIR, PROCESSED_DIR, ARCHIVE_DIR, ZUGFERD_DIR, REGELN_DIR]: - dir_path.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index e3c8c15..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .database import ( - Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, - init_db, get_db, SessionLocal -) diff --git a/backend/app/models/__pycache__/__init__.cpython-313.pyc b/backend/app/models/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a1b042c..0000000 Binary files a/backend/app/models/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/models/__pycache__/database.cpython-313.pyc b/backend/app/models/__pycache__/database.cpython-313.pyc deleted file mode 100644 index becbbab..0000000 Binary files a/backend/app/models/__pycache__/database.cpython-313.pyc and /dev/null differ diff --git a/backend/app/models/database.py b/backend/app/models/database.py deleted file mode 100644 index 140af15..0000000 --- a/backend/app/models/database.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Datenbank-Modelle - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung""" -from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, JSON -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from datetime import datetime - -from ..config import DATABASE_URL - -engine = create_engine(DATABASE_URL, echo=False) -SessionLocal = sessionmaker(bind=engine) -Base = declarative_base() - - -# ============ BEREICH 1: Mail-Abruf ============ - -class Postfach(Base): - """IMAP-Postfach Konfiguration""" - __tablename__ = "postfaecher" - - id = Column(Integer, primary_key=True) - name = Column(String(100), nullable=False) - - # IMAP - imap_server = Column(String(255), nullable=False) - imap_port = Column(Integer, default=993) - email = Column(String(255), nullable=False) - passwort = Column(String(255), nullable=False) - ordner = Column(String(100), default="INBOX") - alle_ordner = Column(Boolean, default=False) # Alle IMAP-Ordner durchsuchen - nur_ungelesen = Column(Boolean, default=False) # Nur ungelesene Mails (False = alle) - - # Ziel - ziel_ordner = Column(String(500), nullable=False) - - # Filter - erlaubte_typen = Column(JSON, default=lambda: [".pdf"]) - max_groesse_mb = Column(Integer, default=25) - - # Status - aktiv = Column(Boolean, default=True) - letzter_abruf = Column(DateTime) - letzte_anzahl = Column(Integer, default=0) - - -# ============ BEREICH 2: Datei-Sortierung ============ - -class QuellOrdner(Base): - """Ordner der nach Dateien gescannt wird""" - __tablename__ = "quell_ordner" - - id = Column(Integer, primary_key=True) - name = Column(String(100), nullable=False) - pfad = Column(String(500), nullable=False) - ziel_ordner = Column(String(500), nullable=False) - rekursiv = Column(Boolean, default=True) # Unterordner einschließen - dateitypen = Column(JSON, default=lambda: [".pdf", ".jpg", ".jpeg", ".png", ".tiff"]) - aktiv = Column(Boolean, default=True) - - -class SortierRegel(Base): - """Regeln für Datei-Erkennung und Benennung""" - __tablename__ = "sortier_regeln" - - id = Column(Integer, primary_key=True) - name = Column(String(100), nullable=False) - prioritaet = Column(Integer, default=100) - aktiv = Column(Boolean, default=True) - - # Erkennungsmuster - muster = Column(JSON, default=dict) - - # Extraktion - extraktion = Column(JSON, default=dict) - - # Ausgabe - schema = Column(String(500), default="{datum} - Dokument.pdf") - unterordner = Column(String(100)) # Optional: Unterordner im Ziel - - -class VerarbeiteteMail(Base): - """Tracking welche Mails bereits verarbeitet wurden""" - __tablename__ = "verarbeitete_mails" - - id = Column(Integer, primary_key=True) - postfach_id = Column(Integer, nullable=False) - message_id = Column(String(500), nullable=False) # Email Message-ID Header - ordner = Column(String(200)) # IMAP Ordner - betreff = Column(String(500)) - absender = Column(String(255)) - anzahl_attachments = Column(Integer, default=0) - verarbeitet_am = Column(DateTime, default=datetime.utcnow) - - -class VerarbeiteteDatei(Base): - """Log verarbeiteter Dateien""" - __tablename__ = "verarbeitete_dateien" - - id = Column(Integer, primary_key=True) - original_pfad = Column(String(1000)) - original_name = Column(String(500)) - neuer_pfad = Column(String(1000)) - neuer_name = Column(String(500)) - - ist_zugferd = Column(Boolean, default=False) - ocr_durchgefuehrt = Column(Boolean, default=False) - - status = Column(String(50)) # sortiert, zugferd, fehler, keine_regel - fehler = Column(Text) - - extrahierte_daten = Column(JSON) - verarbeitet_am = Column(DateTime, default=datetime.utcnow) - - -def migrate_db(): - """Fügt fehlende Spalten hinzu ohne Daten zu löschen""" - from sqlalchemy import inspect, text - - inspector = inspect(engine) - - # Migrations-Definitionen: {tabelle: {spalte: sql_typ}} - migrations = { - "postfaecher": { - "alle_ordner": "BOOLEAN DEFAULT 0", - "nur_ungelesen": "BOOLEAN DEFAULT 0" - }, - "quell_ordner": { - "rekursiv": "BOOLEAN DEFAULT 1", - "dateitypen": "JSON" - } - } - - with engine.connect() as conn: - for table, columns in migrations.items(): - if table not in inspector.get_table_names(): - continue - - existing = [col["name"] for col in inspector.get_columns(table)] - - for col_name, col_type in columns.items(): - if col_name not in existing: - try: - conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_type}")) - conn.commit() - print(f"Migration: {table}.{col_name} hinzugefügt") - except Exception as e: - print(f"Migration übersprungen: {table}.{col_name} - {e}") - - -def init_db(): - """Datenbank initialisieren""" - Base.metadata.create_all(engine) - migrate_db() - - -def get_db(): - """Database Session Generator""" - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/backend/app/modules/__pycache__/__init__.cpython-313.pyc b/backend/app/modules/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9466f0d..0000000 Binary files a/backend/app/modules/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/modules/__pycache__/extraktoren.cpython-313.pyc b/backend/app/modules/__pycache__/extraktoren.cpython-313.pyc deleted file mode 100644 index dd21ca7..0000000 Binary files a/backend/app/modules/__pycache__/extraktoren.cpython-313.pyc and /dev/null differ diff --git a/backend/app/modules/__pycache__/mail_fetcher.cpython-313.pyc b/backend/app/modules/__pycache__/mail_fetcher.cpython-313.pyc deleted file mode 100644 index 0e0b838..0000000 Binary files a/backend/app/modules/__pycache__/mail_fetcher.cpython-313.pyc and /dev/null differ diff --git a/backend/app/modules/__pycache__/pdf_processor.cpython-313.pyc b/backend/app/modules/__pycache__/pdf_processor.cpython-313.pyc deleted file mode 100644 index dbe27c8..0000000 Binary files a/backend/app/modules/__pycache__/pdf_processor.cpython-313.pyc and /dev/null differ diff --git a/backend/app/modules/__pycache__/sorter.cpython-313.pyc b/backend/app/modules/__pycache__/sorter.cpython-313.pyc deleted file mode 100644 index 5639931..0000000 Binary files a/backend/app/modules/__pycache__/sorter.cpython-313.pyc and /dev/null differ diff --git a/backend/app/modules/pdf_processor.py b/backend/app/modules/pdf_processor.py deleted file mode 100644 index 650c564..0000000 --- a/backend/app/modules/pdf_processor.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -PDF-Processor Modul -Text-Extraktion, OCR und ZUGFeRD-Erkennung -""" -import subprocess -from pathlib import Path -from typing import Dict, Optional, Tuple -import logging -import re - -logger = logging.getLogger(__name__) - -# Versuche Libraries zu importieren -try: - import pdfplumber - PDFPLUMBER_AVAILABLE = True -except ImportError: - PDFPLUMBER_AVAILABLE = False - logger.warning("pdfplumber nicht installiert") - -try: - from pypdf import PdfReader - PYPDF_AVAILABLE = True -except ImportError: - PYPDF_AVAILABLE = False - logger.warning("pypdf nicht installiert") - - -class PDFProcessor: - """Verarbeitet PDFs: Text-Extraktion, OCR, ZUGFeRD-Erkennung""" - - def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300): - self.ocr_language = ocr_language - self.ocr_dpi = ocr_dpi - - def verarbeite(self, pdf_pfad: str) -> Dict: - """ - Vollständige PDF-Verarbeitung - - Returns: - Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt - """ - pfad = Path(pdf_pfad) - if not pfad.exists(): - return {"fehler": f"Datei nicht gefunden: {pdf_pfad}"} - - ergebnis = { - "pfad": str(pfad), - "text": "", - "ist_zugferd": False, - "zugferd_xml": None, - "hat_text": False, - "ocr_durchgefuehrt": False, - "seiten": 0 - } - - # 1. ZUGFeRD prüfen - zugferd_result = self.pruefe_zugferd(pdf_pfad) - ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"] - ergebnis["zugferd_xml"] = zugferd_result.get("xml") - - # 2. Text extrahieren - text, seiten = self.extrahiere_text(pdf_pfad) - ergebnis["text"] = text - ergebnis["seiten"] = seiten - ergebnis["hat_text"] = bool(text and len(text.strip()) > 50) - - # 3. OCR falls kein Text (aber NICHT bei ZUGFeRD!) - if not ergebnis["hat_text"] and not ergebnis["ist_zugferd"]: - logger.info(f"Kein Text gefunden, starte OCR für {pfad.name}") - ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad) - if ocr_erfolg: - ergebnis["text"] = ocr_text - ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50) - ergebnis["ocr_durchgefuehrt"] = True - - return ergebnis - - def extrahiere_text(self, pdf_pfad: str) -> Tuple[str, int]: - """ - Extrahiert Text aus PDF - - Returns: - Tuple von (text, seitenanzahl) - """ - text_parts = [] - seiten = 0 - - # Methode 1: pdfplumber (besser für Tabellen) - if PDFPLUMBER_AVAILABLE: - try: - with pdfplumber.open(pdf_pfad) as pdf: - seiten = len(pdf.pages) - for page in pdf.pages: - page_text = page.extract_text() - if page_text: - text_parts.append(page_text) - if text_parts: - return "\n\n".join(text_parts), seiten - except Exception as e: - logger.debug(f"pdfplumber Fehler: {e}") - - # Methode 2: pypdf (Fallback) - if PYPDF_AVAILABLE: - try: - reader = PdfReader(pdf_pfad) - seiten = len(reader.pages) - for page in reader.pages: - page_text = page.extract_text() - if page_text: - text_parts.append(page_text) - if text_parts: - return "\n\n".join(text_parts), seiten - except Exception as e: - logger.debug(f"pypdf Fehler: {e}") - - # Methode 3: pdftotext CLI (Fallback) - try: - result = subprocess.run( - ["pdftotext", "-layout", pdf_pfad, "-"], - capture_output=True, - text=True, - timeout=30 - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout, seiten - except Exception as e: - logger.debug(f"pdftotext Fehler: {e}") - - return "", seiten - - def pruefe_zugferd(self, pdf_pfad: str) -> Dict: - """ - Prüft ob PDF eine ZUGFeRD/Factur-X Rechnung ist - - Returns: - Dict mit: ist_zugferd, xml (falls vorhanden) - """ - ergebnis = {"ist_zugferd": False, "xml": None} - - # Methode 1: factur-x Library - try: - from facturx import get_facturx_xml_from_pdf - xml_bytes = get_facturx_xml_from_pdf(pdf_pfad) - if xml_bytes: - ergebnis["ist_zugferd"] = True - ergebnis["xml"] = xml_bytes.decode("utf-8") if isinstance(xml_bytes, bytes) else xml_bytes - logger.info(f"ZUGFeRD erkannt: {Path(pdf_pfad).name}") - return ergebnis - except ImportError: - logger.debug("factur-x nicht installiert") - except Exception as e: - logger.debug(f"factur-x Fehler: {e}") - - # Methode 2: Manuell nach XML-Attachment suchen - if PYPDF_AVAILABLE: - try: - reader = PdfReader(pdf_pfad) - if "/Names" in reader.trailer.get("/Root", {}): - # Embedded Files prüfen - pass # Komplexere Logik hier - - # Alternativ: Im Text nach ZUGFeRD-Markern suchen - for page in reader.pages[:1]: # Nur erste Seite - text = page.extract_text() or "" - if any(marker in text.upper() for marker in ["ZUGFERD", "FACTUR-X", "EN 16931"]): - ergebnis["ist_zugferd"] = True - logger.info(f"ZUGFeRD-Marker gefunden: {Path(pdf_pfad).name}") - break - except Exception as e: - logger.debug(f"ZUGFeRD-Prüfung Fehler: {e}") - - return ergebnis - - def fuehre_ocr_aus(self, pdf_pfad: str) -> Tuple[str, bool]: - """ - Führt OCR mit ocrmypdf durch - - Returns: - Tuple von (text, erfolg) - """ - pfad = Path(pdf_pfad) - temp_pfad = pfad.with_suffix(".ocr.pdf") - - try: - # ocrmypdf ausführen - result = subprocess.run( - [ - "ocrmypdf", - "--language", self.ocr_language, - "--deskew", # Schräge Scans korrigieren - "--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 - ) - - if result.returncode == 0 and temp_pfad.exists(): - # Original mit OCR-Version ersetzen - pfad.unlink() - temp_pfad.rename(pfad) - - # Text aus OCR-PDF extrahieren - text, _ = self.extrahiere_text(str(pfad)) - return text, True - else: - logger.error(f"OCR Fehler: {result.stderr}") - if temp_pfad.exists(): - temp_pfad.unlink() - return "", False - - except subprocess.TimeoutExpired: - logger.error(f"OCR Timeout für {pfad.name}") - if temp_pfad.exists(): - temp_pfad.unlink() - return "", False - except FileNotFoundError: - logger.error("ocrmypdf nicht installiert") - return "", False - except Exception as e: - logger.error(f"OCR Fehler: {e}") - if temp_pfad.exists(): - temp_pfad.unlink() - return "", False - - def extrahiere_metadaten(self, pdf_pfad: str) -> Dict: - """Extrahiert PDF-Metadaten""" - metadaten = {} - - if PYPDF_AVAILABLE: - try: - reader = PdfReader(pdf_pfad) - if reader.metadata: - metadaten = { - "titel": reader.metadata.get("/Title", ""), - "autor": reader.metadata.get("/Author", ""), - "ersteller": reader.metadata.get("/Creator", ""), - "erstellt": reader.metadata.get("/CreationDate", ""), - } - except Exception as e: - logger.debug(f"Metadaten-Fehler: {e}") - - return metadaten diff --git a/backend/app/routes/__pycache__/__init__.cpython-313.pyc b/backend/app/routes/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 77db079..0000000 Binary files a/backend/app/routes/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/routes/__pycache__/api.cpython-313.pyc b/backend/app/routes/__pycache__/api.cpython-313.pyc deleted file mode 100644 index ef00120..0000000 Binary files a/backend/app/routes/__pycache__/api.cpython-313.pyc and /dev/null differ diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py deleted file mode 100644 index cffea4c..0000000 --- a/backend/app/routes/api.py +++ /dev/null @@ -1,851 +0,0 @@ -""" -API Routes - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung -""" -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session -from typing import List, Optional -from pydantic import BaseModel -from datetime import datetime -from pathlib import Path -import json -import asyncio - -from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail -from ..modules.mail_fetcher import MailFetcher -from ..modules.pdf_processor import PDFProcessor -from ..modules.sorter import Sorter - -router = APIRouter(prefix="/api", tags=["api"]) - - -# ============ Pydantic Models ============ - -class PostfachCreate(BaseModel): - name: str - imap_server: str - imap_port: int = 993 - email: str - passwort: str - ordner: str = "INBOX" - alle_ordner: bool = False # Alle IMAP-Ordner durchsuchen - nur_ungelesen: bool = False # Nur ungelesene Mails (False = alle) - ziel_ordner: str - erlaubte_typen: List[str] = [".pdf"] - max_groesse_mb: int = 25 - - -class PostfachResponse(BaseModel): - id: int - name: str - imap_server: str - email: str - ordner: str - alle_ordner: bool - nur_ungelesen: bool - ziel_ordner: str - erlaubte_typen: List[str] - max_groesse_mb: int - letzter_abruf: Optional[datetime] - letzte_anzahl: int - - class Config: - from_attributes = True - - -class OrdnerCreate(BaseModel): - name: str - pfad: str - ziel_ordner: str - rekursiv: bool = True - dateitypen: List[str] = [".pdf", ".jpg", ".jpeg", ".png", ".tiff"] - - -class OrdnerResponse(BaseModel): - id: int - name: str - pfad: str - ziel_ordner: str - rekursiv: bool - dateitypen: List[str] - aktiv: bool - - class Config: - from_attributes = True - - -class RegelCreate(BaseModel): - name: str - prioritaet: int = 100 - muster: dict = {} - extraktion: dict = {} - schema: str = "{datum} - Dokument.pdf" - unterordner: Optional[str] = None - - -class RegelResponse(BaseModel): - id: int - name: str - prioritaet: int - aktiv: bool - muster: dict - extraktion: dict - schema: str - unterordner: Optional[str] - - class Config: - from_attributes = True - - -class RegelTestRequest(BaseModel): - regel: dict - text: str - - -# ============ Verzeichnis-Browser ============ - -@router.get("/browse") -def browse_directory(path: str = "/"): - """Listet Verzeichnisse für File-Browser""" - import os - - # Sicherheit: Nur bestimmte Basispfade erlauben - allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] - path = os.path.abspath(path) - - # Prüfen ob Pfad erlaubt - is_allowed = any(path.startswith(base) for base in allowed_bases) or path == "/" - if not is_allowed: - return {"error": "Pfad nicht erlaubt", "entries": []} - - if not os.path.exists(path): - return {"error": "Pfad existiert nicht", "entries": []} - - if not os.path.isdir(path): - return {"error": "Kein Verzeichnis", "entries": []} - - try: - entries = [] - for entry in sorted(os.listdir(path)): - full_path = os.path.join(path, entry) - if os.path.isdir(full_path): - entries.append({ - "name": entry, - "path": full_path, - "type": "directory" - }) - - return { - "current": path, - "parent": os.path.dirname(path) if path != "/" else None, - "entries": entries - } - except PermissionError: - return {"error": "Zugriff verweigert", "entries": []} - - -# ============ BEREICH 1: Postfächer ============ - -@router.get("/postfaecher", response_model=List[PostfachResponse]) -def liste_postfaecher(db: Session = Depends(get_db)): - return db.query(Postfach).all() - - -@router.post("/postfaecher", response_model=PostfachResponse) -def erstelle_postfach(data: PostfachCreate, db: Session = Depends(get_db)): - postfach = Postfach(**data.dict()) - db.add(postfach) - db.commit() - db.refresh(postfach) - return postfach - - -@router.put("/postfaecher/{id}", response_model=PostfachResponse) -def aktualisiere_postfach(id: int, data: PostfachCreate, db: Session = Depends(get_db)): - postfach = db.query(Postfach).filter(Postfach.id == id).first() - if not postfach: - raise HTTPException(status_code=404, detail="Nicht gefunden") - - update_data = data.dict() - # Passwort nur aktualisieren wenn nicht leer - if not update_data.get("passwort"): - del update_data["passwort"] - - for key, value in update_data.items(): - setattr(postfach, key, value) - - db.commit() - db.refresh(postfach) - return postfach - - -@router.delete("/postfaecher/{id}") -def loesche_postfach(id: int, db: Session = Depends(get_db)): - postfach = db.query(Postfach).filter(Postfach.id == id).first() - if not postfach: - raise HTTPException(status_code=404, detail="Nicht gefunden") - db.delete(postfach) - db.commit() - return {"message": "Gelöscht"} - - -@router.post("/postfaecher/{id}/test") -def teste_postfach(id: int, db: Session = Depends(get_db)): - postfach = db.query(Postfach).filter(Postfach.id == id).first() - if not postfach: - raise HTTPException(status_code=404, detail="Nicht gefunden") - - fetcher = MailFetcher({ - "imap_server": postfach.imap_server, - "imap_port": postfach.imap_port, - "email": postfach.email, - "passwort": postfach.passwort, - "ordner": postfach.ordner - }) - return fetcher.test_connection() - - -@router.get("/postfaecher/{id}/abrufen/stream") -def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): - """Streaming-Endpoint für Mail-Abruf mit Live-Updates""" - postfach = db.query(Postfach).filter(Postfach.id == id).first() - if not postfach: - raise HTTPException(status_code=404, detail="Nicht gefunden") - - # Daten kopieren für Generator (Session ist nach return nicht mehr verfügbar) - pf_data = { - "id": postfach.id, - "name": postfach.name, - "imap_server": postfach.imap_server, - "imap_port": postfach.imap_port, - "email": postfach.email, - "passwort": postfach.passwort, - "ordner": postfach.ordner, - "alle_ordner": postfach.alle_ordner, - "erlaubte_typen": postfach.erlaubte_typen, - "max_groesse_mb": postfach.max_groesse_mb, - "ziel_ordner": postfach.ziel_ordner - } - - # Bereits verarbeitete Message-IDs laden - bereits_verarbeitet = set( - row.message_id for row in - db.query(VerarbeiteteMail.message_id) - .filter(VerarbeiteteMail.postfach_id == id) - .all() - ) - - def event_generator(): - from ..models.database import SessionLocal - - def send_event(data): - return f"data: {json.dumps(data)}\n\n" - - yield send_event({"type": "start", "postfach": pf_data["name"], "bereits_verarbeitet": len(bereits_verarbeitet)}) - - # Zielordner erstellen - ziel = Path(pf_data["ziel_ordner"]) - ziel.mkdir(parents=True, exist_ok=True) - - fetcher = MailFetcher({ - "imap_server": pf_data["imap_server"], - "imap_port": pf_data["imap_port"], - "email": pf_data["email"], - "passwort": pf_data["passwort"], - "ordner": pf_data["ordner"], - "erlaubte_typen": pf_data["erlaubte_typen"], - "max_groesse_mb": pf_data["max_groesse_mb"] - }) - - attachments = [] - - try: - # Generator für streaming - for event in fetcher.fetch_attachments_generator( - ziel, - nur_ungelesen=False, - alle_ordner=pf_data["alle_ordner"], - bereits_verarbeitet=bereits_verarbeitet - ): - yield send_event(event) - - if event.get("type") == "datei": - attachments.append(event) - - # DB-Session für Speicherung - session = SessionLocal() - try: - verarbeitete_msg_ids = set() - for att in attachments: - msg_id = att.get("message_id") - if msg_id and msg_id not in verarbeitete_msg_ids: - verarbeitete_msg_ids.add(msg_id) - session.add(VerarbeiteteMail( - postfach_id=pf_data["id"], - message_id=msg_id, - ordner=att.get("ordner", ""), - betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, - absender=att.get("absender", "")[:255] if att.get("absender") else None, - anzahl_attachments=1 - )) - - # Postfach aktualisieren - pf = session.query(Postfach).filter(Postfach.id == pf_data["id"]).first() - if pf: - pf.letzter_abruf = datetime.utcnow() - pf.letzte_anzahl = len(attachments) - session.commit() - finally: - session.close() - - yield send_event({"type": "fertig", "anzahl": len(attachments)}) - - except Exception as e: - yield send_event({"type": "fehler", "nachricht": str(e)}) - finally: - fetcher.disconnect() - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no" - } - ) - - -@router.post("/postfaecher/{id}/abrufen") -def rufe_postfach_ab(id: int, db: Session = Depends(get_db)): - postfach = db.query(Postfach).filter(Postfach.id == id).first() - if not postfach: - raise HTTPException(status_code=404, detail="Nicht gefunden") - - # Bereits verarbeitete Message-IDs laden - bereits_verarbeitet = set( - row.message_id for row in - db.query(VerarbeiteteMail.message_id) - .filter(VerarbeiteteMail.postfach_id == id) - .all() - ) - - # Zielordner erstellen - ziel = Path(postfach.ziel_ordner) - ziel.mkdir(parents=True, exist_ok=True) - - fetcher = MailFetcher({ - "imap_server": postfach.imap_server, - "imap_port": postfach.imap_port, - "email": postfach.email, - "passwort": postfach.passwort, - "ordner": postfach.ordner, - "erlaubte_typen": postfach.erlaubte_typen, - "max_groesse_mb": postfach.max_groesse_mb - }) - - try: - attachments = fetcher.fetch_attachments( - ziel, - nur_ungelesen=False, # Alle Mails durchsuchen - alle_ordner=postfach.alle_ordner, - bereits_verarbeitet=bereits_verarbeitet - ) - - # Verarbeitete Mails in DB speichern - verarbeitete_msg_ids = set() - for att in attachments: - msg_id = att.get("message_id") - if msg_id and msg_id not in verarbeitete_msg_ids: - verarbeitete_msg_ids.add(msg_id) - db.add(VerarbeiteteMail( - postfach_id=id, - message_id=msg_id, - ordner=att.get("ordner", ""), - betreff=att.get("betreff", "")[:500] if att.get("betreff") else None, - absender=att.get("absender", "")[:255] if att.get("absender") else None, - anzahl_attachments=1 - )) - - postfach.letzter_abruf = datetime.utcnow() - postfach.letzte_anzahl = len(attachments) - db.commit() - - return { - "ergebnisse": [{ - "postfach": postfach.name, - "anzahl": len(attachments), - "dateien": [a["original_name"] for a in attachments], - "bereits_verarbeitet": len(bereits_verarbeitet) - }] - } - except Exception as e: - return { - "ergebnisse": [{ - "postfach": postfach.name, - "fehler": str(e) - }] - } - finally: - fetcher.disconnect() - - -@router.post("/postfaecher/abrufen-alle") -def rufe_alle_postfaecher_ab(db: Session = Depends(get_db)): - postfaecher = db.query(Postfach).filter(Postfach.aktiv == True).all() - ergebnisse = [] - - for postfach in postfaecher: - ziel = Path(postfach.ziel_ordner) - ziel.mkdir(parents=True, exist_ok=True) - - fetcher = MailFetcher({ - "imap_server": postfach.imap_server, - "imap_port": postfach.imap_port, - "email": postfach.email, - "passwort": postfach.passwort, - "ordner": postfach.ordner, - "erlaubte_typen": postfach.erlaubte_typen, - "max_groesse_mb": postfach.max_groesse_mb - }) - - try: - attachments = fetcher.fetch_attachments(ziel) - postfach.letzter_abruf = datetime.utcnow() - postfach.letzte_anzahl = len(attachments) - - ergebnisse.append({ - "postfach": postfach.name, - "anzahl": len(attachments), - "dateien": [a["original_name"] for a in attachments] - }) - except Exception as e: - ergebnisse.append({ - "postfach": postfach.name, - "fehler": str(e) - }) - finally: - fetcher.disconnect() - - db.commit() - return {"ergebnisse": ergebnisse} - - -# ============ BEREICH 2: Quell-Ordner ============ - -@router.get("/ordner", response_model=List[OrdnerResponse]) -def liste_ordner(db: Session = Depends(get_db)): - return db.query(QuellOrdner).all() - - -@router.post("/ordner", response_model=OrdnerResponse) -def erstelle_ordner(data: OrdnerCreate, db: Session = Depends(get_db)): - ordner = QuellOrdner(**data.dict()) - db.add(ordner) - db.commit() - db.refresh(ordner) - return ordner - - -@router.delete("/ordner/{id}") -def loesche_ordner(id: int, db: Session = Depends(get_db)): - ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() - if not ordner: - raise HTTPException(status_code=404, detail="Nicht gefunden") - db.delete(ordner) - db.commit() - return {"message": "Gelöscht"} - - -@router.get("/ordner/{id}/scannen") -def scanne_ordner(id: int, db: Session = Depends(get_db)): - ordner = db.query(QuellOrdner).filter(QuellOrdner.id == id).first() - if not ordner: - raise HTTPException(status_code=404, detail="Nicht gefunden") - - pfad = Path(ordner.pfad) - if not pfad.exists(): - return {"anzahl": 0, "fehler": "Ordner existiert nicht"} - - # Dateien sammeln (rekursiv oder nicht) - dateien = [] - pattern = "**/*" if ordner.rekursiv else "*" - for f in pfad.glob(pattern): - if f.is_file() and f.suffix.lower() in [t.lower() for t in ordner.dateitypen]: - dateien.append(f) - - return {"anzahl": len(dateien), "dateien": [str(f.relative_to(pfad)) for f in dateien[:30]]} - - -# ============ Regeln ============ - -@router.get("/regeln", response_model=List[RegelResponse]) -def liste_regeln(db: Session = Depends(get_db)): - return db.query(SortierRegel).order_by(SortierRegel.prioritaet).all() - - -@router.post("/regeln", response_model=RegelResponse) -def erstelle_regel(data: RegelCreate, db: Session = Depends(get_db)): - regel = SortierRegel(**data.dict()) - db.add(regel) - db.commit() - db.refresh(regel) - return regel - - -@router.put("/regeln/{id}", response_model=RegelResponse) -def aktualisiere_regel(id: int, data: RegelCreate, db: Session = Depends(get_db)): - regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() - if not regel: - raise HTTPException(status_code=404, detail="Nicht gefunden") - for key, value in data.dict().items(): - setattr(regel, key, value) - db.commit() - db.refresh(regel) - return regel - - -@router.delete("/regeln/{id}") -def loesche_regel(id: int, db: Session = Depends(get_db)): - regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() - if not regel: - raise HTTPException(status_code=404, detail="Nicht gefunden") - db.delete(regel) - db.commit() - return {"message": "Gelöscht"} - - -@router.post("/regeln/test") -def teste_regel(data: RegelTestRequest): - regel = data.regel - regel["aktiv"] = True - regel["prioritaet"] = 1 - - sorter = Sorter([regel]) - doc_info = {"text": data.text, "original_name": "test.pdf", "absender": ""} - - passend = sorter.finde_passende_regel(doc_info) - - if passend: - extrahiert = sorter.extrahiere_felder(passend, doc_info) - dateiname = sorter.generiere_dateinamen(passend, extrahiert) - return {"passt": True, "extrahiert": extrahiert, "dateiname": dateiname} - - return {"passt": False} - - -# ============ Sortierung ============ - -def sammle_dateien(ordner: QuellOrdner) -> list: - """Sammelt alle Dateien aus einem Ordner (rekursiv oder nicht)""" - pfad = Path(ordner.pfad) - if not pfad.exists(): - return [] - - dateien = [] - pattern = "**/*" if ordner.rekursiv else "*" - erlaubte = [t.lower() for t in (ordner.dateitypen or [".pdf"])] - - for f in pfad.glob(pattern): - if f.is_file() and f.suffix.lower() in erlaubte: - dateien.append(f) - - return dateien - - -@router.post("/sortierung/starten") -def starte_sortierung(db: Session = Depends(get_db)): - ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() - regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all() - - if not ordner_liste: - return {"fehler": "Keine Quell-Ordner konfiguriert", "verarbeitet": []} - if not regeln: - return {"fehler": "Keine Regeln definiert", "verarbeitet": []} - - # Regeln in Dict-Format - regeln_dicts = [] - for r in regeln: - regeln_dicts.append({ - "id": r.id, - "name": r.name, - "prioritaet": r.prioritaet, - "muster": r.muster, - "extraktion": r.extraktion, - "schema": r.schema, - "unterordner": r.unterordner - }) - - sorter = Sorter(regeln_dicts) - pdf_processor = PDFProcessor() - - ergebnis = { - "gesamt": 0, - "sortiert": 0, - "zugferd": 0, - "fehler": 0, - "verarbeitet": [] - } - - for quell_ordner in ordner_liste: - pfad = Path(quell_ordner.pfad) - if not pfad.exists(): - continue - - ziel_basis = Path(quell_ordner.ziel_ordner) - dateien = sammle_dateien(quell_ordner) - - for datei in dateien: - ergebnis["gesamt"] += 1 - # Relativer Pfad für Anzeige - try: - rel_pfad = str(datei.relative_to(pfad)) - except: - rel_pfad = datei.name - datei_info = {"original": rel_pfad} - - try: - ist_pdf = datei.suffix.lower() == ".pdf" - text = "" - ist_zugferd = False - ocr_gemacht = False - - # Nur PDFs durch den PDF-Processor - if ist_pdf: - pdf_result = pdf_processor.verarbeite(str(datei)) - - if pdf_result.get("fehler"): - raise Exception(pdf_result["fehler"]) - - text = pdf_result.get("text", "") - ist_zugferd = pdf_result.get("ist_zugferd", False) - ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) - - # ZUGFeRD separat behandeln - if ist_zugferd: - zugferd_ziel = ziel_basis / "zugferd" - zugferd_ziel.mkdir(parents=True, exist_ok=True) - - neuer_pfad = zugferd_ziel / datei.name - counter = 1 - while neuer_pfad.exists(): - neuer_pfad = zugferd_ziel / f"{datei.stem}_{counter}{datei.suffix}" - counter += 1 - - datei.rename(neuer_pfad) - - ergebnis["zugferd"] += 1 - datei_info["zugferd"] = True - datei_info["neuer_name"] = neuer_pfad.name - - db.add(VerarbeiteteDatei( - original_pfad=str(datei), - original_name=datei.name, - neuer_pfad=str(neuer_pfad), - neuer_name=neuer_pfad.name, - ist_zugferd=True, - status="zugferd" - )) - ergebnis["verarbeitet"].append(datei_info) - continue - - # Regel finden (für PDFs mit Text, für andere nur Dateiname) - doc_info = { - "text": text, - "original_name": datei.name, - "absender": "", - "dateityp": datei.suffix.lower() - } - - regel = sorter.finde_passende_regel(doc_info) - - if not regel: - datei_info["fehler"] = "Keine passende Regel" - ergebnis["fehler"] += 1 - ergebnis["verarbeitet"].append(datei_info) - continue - - # Felder extrahieren - extrahiert = sorter.extrahiere_felder(regel, doc_info) - - # Dateiendung beibehalten - schema = regel.get("schema", "{datum} - Dokument.pdf") - # Endung aus Schema entfernen und Original-Endung anhängen - if schema.endswith(".pdf"): - schema = schema[:-4] + datei.suffix - neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert) - - # Zielordner - ziel = ziel_basis - if regel.get("unterordner"): - ziel = ziel / regel["unterordner"] - ziel.mkdir(parents=True, exist_ok=True) - - # Verschieben - neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) - - ergebnis["sortiert"] += 1 - datei_info["neuer_name"] = neuer_name - - db.add(VerarbeiteteDatei( - original_pfad=str(datei), - original_name=datei.name, - neuer_pfad=neuer_pfad, - neuer_name=neuer_name, - ist_zugferd=False, - ocr_durchgefuehrt=ocr_gemacht, - status="sortiert", - extrahierte_daten=extrahiert - )) - - except Exception as e: - ergebnis["fehler"] += 1 - datei_info["fehler"] = str(e) - - ergebnis["verarbeitet"].append(datei_info) - - db.commit() - return ergebnis - - -@router.get("/health") -def health(): - return {"status": "ok"} - - -# ============ Einfache Regeln (UI-freundlich) ============ - -@router.get("/dokumenttypen") -def liste_dokumenttypen(): - """Gibt alle verfügbaren Dokumenttypen für das UI zurück""" - from ..modules.sorter import DOKUMENTTYPEN - return [ - {"id": key, "name": config["name"], "schema": config["schema"], "unterordner": config["unterordner"]} - for key, config in DOKUMENTTYPEN.items() - ] - - -class EinfacheRegelCreate(BaseModel): - name: str - dokumenttyp: str # z.B. "rechnung", "vertrag" - keywords: str # Komma-getrennt - firma: Optional[str] = None # Fester Firmenwert - unterordner: Optional[str] = None - prioritaet: int = 50 - - -@router.post("/regeln/einfach") -def erstelle_einfache_regel_api(data: EinfacheRegelCreate, db: Session = Depends(get_db)): - """Erstellt eine Regel basierend auf Dokumenttyp - für einfaches UI""" - from ..modules.sorter import DOKUMENTTYPEN - - typ_config = DOKUMENTTYPEN.get(data.dokumenttyp, DOKUMENTTYPEN["sonstiges"]) - - # Muster als Dict (keywords werden vom Sorter geparst) - muster = {"keywords": data.keywords} - - # Extraktion (nur Firma wenn angegeben) - extraktion = {} - if data.firma: - extraktion["firma"] = {"wert": data.firma} - - regel = SortierRegel( - name=data.name, - prioritaet=data.prioritaet, - aktiv=True, - muster=muster, - extraktion=extraktion, - schema=typ_config["schema"], - unterordner=data.unterordner or typ_config["unterordner"] - ) - - db.add(regel) - db.commit() - db.refresh(regel) - - return { - "id": regel.id, - "name": regel.name, - "dokumenttyp": data.dokumenttyp, - "keywords": data.keywords, - "schema": regel.schema - } - - -class ExtraktionTestRequest(BaseModel): - text: str - dateiname: Optional[str] = "test.pdf" - - -@router.post("/extraktion/test") -def teste_extraktion(data: ExtraktionTestRequest): - """Testet die automatische Extraktion auf einem Text""" - from ..modules.extraktoren import extrahiere_alle_felder, baue_dateiname - - dokument_info = { - "original_name": data.dateiname, - "absender": "" - } - - # Felder extrahieren - felder = extrahiere_alle_felder(data.text, dokument_info) - - # Beispiel-Dateinamen für verschiedene Typen generieren - beispiele = {} - from ..modules.sorter import DOKUMENTTYPEN - for typ_id, typ_config in DOKUMENTTYPEN.items(): - beispiele[typ_id] = baue_dateiname(typ_config["schema"], felder, ".pdf") - - return { - "extrahiert": felder, - "beispiel_dateinamen": beispiele - } - - -@router.post("/regeln/{id}/vorschau") -def regel_vorschau(id: int, data: ExtraktionTestRequest, db: Session = Depends(get_db)): - """Zeigt Vorschau wie eine Regel auf einen Text angewendet würde""" - regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() - if not regel: - raise HTTPException(status_code=404, detail="Regel nicht gefunden") - - from ..modules.sorter import Sorter - - sorter = Sorter([{ - "id": regel.id, - "name": regel.name, - "prioritaet": regel.prioritaet, - "aktiv": True, - "muster": regel.muster, - "extraktion": regel.extraktion, - "schema": regel.schema, - "unterordner": regel.unterordner - }]) - - dokument_info = { - "text": data.text, - "original_name": data.dateiname or "test.pdf", - "absender": "" - } - - # Prüfen ob Regel matched - passende_regel = sorter.finde_passende_regel(dokument_info) - - if not passende_regel: - return { - "matched": False, - "grund": "Keywords nicht gefunden" - } - - # Felder extrahieren - felder = sorter.extrahiere_felder(passende_regel, dokument_info) - - # Dateiname generieren - dateiname = sorter.generiere_dateinamen(passende_regel, felder) - - return { - "matched": True, - "extrahiert": felder, - "dateiname": dateiname, - "unterordner": passende_regel.get("unterordner") - } diff --git a/backend/app/services/__pycache__/__init__.cpython-313.pyc b/backend/app/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 626bd46..0000000 Binary files a/backend/app/services/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/app/services/__pycache__/pipeline_service.cpython-313.pyc b/backend/app/services/__pycache__/pipeline_service.cpython-313.pyc deleted file mode 100644 index b814ece..0000000 Binary files a/backend/app/services/__pycache__/pipeline_service.cpython-313.pyc and /dev/null differ diff --git a/data/dateiverwaltung.db b/data/dateiverwaltung.db deleted file mode 100644 index b17d598..0000000 Binary files a/data/dateiverwaltung.db and /dev/null differ diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css deleted file mode 100644 index cf22316..0000000 --- a/frontend/static/css/style.css +++ /dev/null @@ -1,543 +0,0 @@ -/* ============ Variables ============ */ -:root { - --primary: #3b82f6; - --primary-dark: #2563eb; - --success: #22c55e; - --danger: #ef4444; - --warning: #f59e0b; - --bg: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --text: #f1f5f9; - --text-secondary: #94a3b8; - --border: #475569; - --radius: 8px; - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); -} - -/* ============ Reset & Base ============ */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.6; -} - -/* ============ Layout ============ */ -#app { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.header { - background: var(--bg-secondary); - padding: 1rem 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); -} - -.header h1 { - font-size: 1.25rem; - font-weight: 600; -} - -.main-container { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1px; - flex: 1; - background: var(--border); -} - -@media (max-width: 1200px) { - .main-container { - grid-template-columns: 1fr; - } -} - -/* ============ Bereiche ============ */ -.bereich { - background: var(--bg); - display: flex; - flex-direction: column; -} - -.bereich-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border); -} - -.bereich-header h2 { - font-size: 1.25rem; - margin-bottom: 0.25rem; -} - -.bereich-desc { - color: var(--text-secondary); - font-size: 0.875rem; -} - -.bereich-content { - padding: 1rem; - flex: 1; - overflow-y: auto; -} - -/* ============ Buttons ============ */ -.btn { - padding: 0.5rem 1rem; - border: none; - border-radius: var(--radius); - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s; - background: var(--bg-tertiary); - color: var(--text); -} - -.btn:hover { - filter: brightness(1.1); -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn-primary { - background: var(--primary); - color: white; -} - -.btn-success { - background: var(--success); - color: white; -} - -.btn-danger { - background: var(--danger); - color: white; -} - -.btn-sm { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; -} - -.btn-large { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* ============ Cards ============ */ -.card { - background: var(--bg-secondary); - border-radius: var(--radius); - margin-bottom: 1rem; - overflow: hidden; -} - -.card-header { - padding: 0.75rem 1rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); - background: var(--bg-tertiary); -} - -.card-header h3 { - font-size: 0.875rem; - font-weight: 500; -} - -.card-body { - padding: 1rem; -} - -/* ============ Action Bar ============ */ -.action-bar { - padding: 1rem; - text-align: center; - background: var(--bg-secondary); - border-radius: var(--radius); - margin-bottom: 1rem; -} - -/* ============ Config Items ============ */ -.config-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem; - background: var(--bg-tertiary); - border-radius: var(--radius); - margin-bottom: 0.5rem; -} - -.config-item:last-child { - margin-bottom: 0; -} - -.config-item-info h4 { - font-size: 0.875rem; - margin-bottom: 0.125rem; -} - -.config-item-info small { - color: var(--text-secondary); - font-size: 0.75rem; -} - -.config-item-actions { - display: flex; - gap: 0.5rem; -} - -/* ============ Forms ============ */ -.form-group { - margin-bottom: 1rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.form-group input, -.form-group textarea, -.form-group select { - width: 100%; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-tertiary); - color: var(--text); - font-size: 0.875rem; -} - -.form-group input:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--primary); -} - -.form-group small { - display: block; - margin-top: 0.25rem; - color: var(--text-secondary); - font-size: 0.75rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -.code-input { - font-family: 'Consolas', 'Monaco', monospace; - font-size: 0.8rem; -} - -/* ============ Log Output ============ */ -.log-output { - font-family: 'Consolas', 'Monaco', monospace; - font-size: 0.8rem; - max-height: 350px; - min-height: 100px; - overflow-y: auto; -} - -.log-entry { - padding: 0.5rem; - border-radius: 4px; - margin-bottom: 0.25rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.log-entry.success { - background: rgba(34, 197, 94, 0.2); - border-left: 3px solid var(--success); -} - -.log-entry.error { - background: rgba(239, 68, 68, 0.2); - border-left: 3px solid var(--danger); -} - -.log-entry.info { - background: rgba(59, 130, 246, 0.2); - border-left: 3px solid var(--primary); -} - -.empty-state { - color: var(--text-secondary); - text-align: center; - padding: 1rem; - font-size: 0.875rem; -} - -/* ============ Modals ============ */ -.modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-content { - background: var(--bg-secondary); - border-radius: var(--radius); - width: 90%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; -} - -.modal-large { - max-width: 700px; -} - -.modal-header { - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); -} - -.modal-header h3 { - font-size: 1.125rem; -} - -.modal-close { - background: none; - border: none; - color: var(--text-secondary); - font-size: 1.5rem; - cursor: pointer; -} - -.modal-body { - padding: 1rem; -} - -.modal-footer { - padding: 1rem; - display: flex; - justify-content: flex-end; - gap: 0.5rem; - border-top: 1px solid var(--border); -} - -/* ============ Test Result ============ */ -.test-result { - margin-top: 0.5rem; - padding: 0.75rem; - border-radius: var(--radius); - background: var(--bg-tertiary); - font-family: monospace; - font-size: 0.8rem; - white-space: pre-wrap; -} - -.test-result.success { - border-left: 3px solid var(--success); -} - -.test-result.error { - border-left: 3px solid var(--danger); -} - -/* ============ Status Badges ============ */ -.badge { - display: inline-block; - padding: 0.125rem 0.5rem; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 500; -} - -.badge-success { background: var(--success); } -.badge-warning { background: var(--warning); color: #000; } -.badge-danger { background: var(--danger); } -.badge-info { background: var(--primary); } - -/* ============ Loading Overlay ============ */ -.loading-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - z-index: 2000; -} - -.spinner { - width: 50px; - height: 50px; - border: 4px solid var(--border); - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.loading-text { - margin-top: 1rem; - color: var(--text); - font-size: 0.875rem; -} - -.progress-bar { - width: 200px; - height: 6px; - background: var(--bg-tertiary); - border-radius: 3px; - margin-top: 1rem; - overflow: hidden; -} - -.progress-bar-fill { - height: 100%; - background: var(--primary); - transition: width 0.3s ease; -} - -/* ============ File Browser ============ */ -.file-browser { - max-height: 300px; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: var(--radius); - margin-bottom: 1rem; -} - -.file-browser-path { - padding: 0.75rem; - background: var(--bg-tertiary); - border-bottom: 1px solid var(--border); - font-family: monospace; - font-size: 0.8rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.file-browser-list { - list-style: none; -} - -.file-browser-item { - padding: 0.5rem 1rem; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.5rem; - border-bottom: 1px solid var(--border); -} - -.file-browser-item:hover { - background: var(--bg-tertiary); -} - -.file-browser-item.selected { - background: var(--primary); -} - -.file-browser-item:last-child { - border-bottom: none; -} - -.file-icon { - font-size: 1rem; -} - -/* ============ Checkbox Group ============ */ -.checkbox-group { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.5rem; -} - -.checkbox-item { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - background: var(--bg-tertiary); - border-radius: 4px; - font-size: 0.75rem; - cursor: pointer; -} - -.checkbox-item input { - width: auto; - margin: 0; -} - -.checkbox-item:has(input:checked) { - background: var(--primary); -} - -/* ============ Input with Button ============ */ -.input-with-btn { - display: flex; - gap: 0.5rem; -} - -.input-with-btn input { - flex: 1; -} - -/* ============ Utilities ============ */ -.hidden { - display: none !important; -} - -/* ============ Scrollbar ============ */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: var(--bg); -} - -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); -} diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js deleted file mode 100644 index 77422d0..0000000 --- a/frontend/static/js/app.js +++ /dev/null @@ -1,693 +0,0 @@ -/** - * Dateiverwaltung Frontend - * Zwei getrennte Bereiche: Mail-Abruf und Datei-Sortierung - */ - -// ============ API ============ - -async function api(endpoint, options = {}) { - const response = await fetch(`/api${endpoint}`, { - headers: { 'Content-Type': 'application/json', ...options.headers }, - ...options - }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error.detail || 'API Fehler'); - } - return response.json(); -} - -// ============ Loading Overlay ============ - -function zeigeLoading(text = 'Wird geladen...') { - document.getElementById('loading-text').textContent = text; - document.getElementById('loading-overlay').classList.remove('hidden'); -} - -function versteckeLoading() { - document.getElementById('loading-overlay').classList.add('hidden'); -} - -// ============ File Browser ============ - -let browserTargetInput = null; -let browserCurrentPath = '/srv/http/dateiverwaltung/data'; - -function oeffneBrowser(inputId) { - browserTargetInput = inputId; - const currentValue = document.getElementById(inputId).value; - browserCurrentPath = currentValue || '/srv/http/dateiverwaltung/data'; - ladeBrowserInhalt(browserCurrentPath); - document.getElementById('browser-modal').classList.remove('hidden'); -} - -async function ladeBrowserInhalt(path) { - try { - const data = await api(`/browse?path=${encodeURIComponent(path)}`); - - if (data.error) { - document.getElementById('browser-list').innerHTML = - `
  • ${data.error}
  • `; - return; - } - - browserCurrentPath = data.current; - document.getElementById('browser-current-path').textContent = data.current; - - let html = ''; - - // Parent directory - if (data.parent) { - html += `
  • - 📁 .. -
  • `; - } - - // Directories - for (const entry of data.entries) { - html += `
  • - 📁 ${entry.name} -
  • `; - } - - if (data.entries.length === 0 && !data.parent) { - html = '
  • Keine Unterordner
  • '; - } - - document.getElementById('browser-list').innerHTML = html; - } catch (error) { - document.getElementById('browser-list').innerHTML = - `
  • Fehler: ${error.message}
  • `; - } -} - -function browserSelect(element, path) { - document.querySelectorAll('.file-browser-item.selected').forEach(el => el.classList.remove('selected')); - element.classList.add('selected'); - browserCurrentPath = path; -} - -function browserAuswahl() { - if (browserTargetInput && browserCurrentPath) { - document.getElementById(browserTargetInput).value = browserCurrentPath + '/'; - } - schliesseModal('browser-modal'); -} - -// ============ Checkbox Helpers ============ - -function getCheckedTypes(groupId) { - const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]:checked`); - return Array.from(checkboxes).map(cb => cb.value); -} - -function setCheckedTypes(groupId, types) { - const checkboxes = document.querySelectorAll(`#${groupId} input[type="checkbox"]`); - checkboxes.forEach(cb => { - cb.checked = types.includes(cb.value); - }); -} - -// ============ Init ============ - -document.addEventListener('DOMContentLoaded', () => { - ladePostfaecher(); - ladeOrdner(); - ladeRegeln(); -}); - -// ============ BEREICH 1: Mail-Abruf ============ - -async function ladePostfaecher() { - try { - const postfaecher = await api('/postfaecher'); - renderPostfaecher(postfaecher); - } catch (error) { - console.error('Fehler:', error); - } -} - -let bearbeitetesPostfachId = null; - -function renderPostfaecher(postfaecher) { - const container = document.getElementById('postfaecher-liste'); - - if (!postfaecher || postfaecher.length === 0) { - container.innerHTML = '

    Keine Postfächer konfiguriert

    '; - return; - } - - container.innerHTML = postfaecher.map(p => ` -
    -
    -

    ${escapeHtml(p.name)}

    - ${escapeHtml(p.email)} → ${escapeHtml(p.ziel_ordner)} -
    -
    - - - - -
    -
    - `).join(''); -} - -function zeigePostfachModal(postfach = null) { - bearbeitetesPostfachId = postfach?.id || null; - - document.getElementById('pf-name').value = postfach?.name || ''; - document.getElementById('pf-server').value = postfach?.imap_server || ''; - document.getElementById('pf-port').value = postfach?.imap_port || '993'; - document.getElementById('pf-email').value = postfach?.email || ''; - document.getElementById('pf-passwort').value = ''; // Passwort nicht vorausfüllen - document.getElementById('pf-ordner').value = postfach?.ordner || 'INBOX'; - document.getElementById('pf-alle-ordner').value = postfach?.alle_ordner ? 'true' : 'false'; - document.getElementById('pf-ziel').value = postfach?.ziel_ordner || '/srv/http/dateiverwaltung/data/inbox/'; - setCheckedTypes('pf-typen-gruppe', postfach?.erlaubte_typen || ['.pdf']); - document.getElementById('pf-max-groesse').value = postfach?.max_groesse_mb || '25'; - - document.getElementById('postfach-modal').classList.remove('hidden'); -} - -async function postfachBearbeiten(id) { - try { - const postfaecher = await api('/postfaecher'); - const postfach = postfaecher.find(p => p.id === id); - if (postfach) { - zeigePostfachModal(postfach); - } - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function speicherePostfach() { - const erlaubteTypen = getCheckedTypes('pf-typen-gruppe'); - if (erlaubteTypen.length === 0) { - alert('Bitte mindestens einen Dateityp auswählen'); - return; - } - - const data = { - name: document.getElementById('pf-name').value.trim(), - imap_server: document.getElementById('pf-server').value.trim(), - imap_port: parseInt(document.getElementById('pf-port').value), - email: document.getElementById('pf-email').value.trim(), - passwort: document.getElementById('pf-passwort').value, - ordner: document.getElementById('pf-ordner').value.trim(), - alle_ordner: document.getElementById('pf-alle-ordner').value === 'true', - ziel_ordner: document.getElementById('pf-ziel').value.trim(), - erlaubte_typen: erlaubteTypen, - max_groesse_mb: parseInt(document.getElementById('pf-max-groesse').value) - }; - - if (!data.name || !data.imap_server || !data.email || !data.ziel_ordner) { - alert('Bitte alle Pflichtfelder ausfüllen'); - return; - } - - // Bei Bearbeitung: Passwort nur senden wenn eingegeben - if (bearbeitetesPostfachId && !data.passwort) { - delete data.passwort; - } else if (!data.passwort) { - alert('Passwort ist erforderlich'); - return; - } - - try { - if (bearbeitetesPostfachId) { - await api(`/postfaecher/${bearbeitetesPostfachId}`, { method: 'PUT', body: JSON.stringify(data) }); - } else { - await api('/postfaecher', { method: 'POST', body: JSON.stringify(data) }); - } - schliesseModal('postfach-modal'); - ladePostfaecher(); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function postfachTesten(id) { - try { - const result = await api(`/postfaecher/${id}/test`, { method: 'POST' }); - alert(result.erfolg ? 'Verbindung erfolgreich!' : 'Fehler: ' + result.nachricht); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function postfachAbrufen(id) { - const logContainer = document.getElementById('abruf-log'); - logContainer.innerHTML = '
    Verbinde...
    '; - - // EventSource für Server-Sent Events - const eventSource = new EventSource(`/api/postfaecher/${id}/abrufen/stream`); - let dateiCount = 0; - let currentOrdner = ''; - - eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - - switch (data.type) { - case 'start': - logContainer.innerHTML = `
    - Starte Abruf: ${escapeHtml(data.postfach)} - ${data.bereits_verarbeitet} bereits verarbeitet -
    `; - break; - - case 'info': - logContainer.innerHTML += `
    - ${escapeHtml(data.nachricht)} -
    `; - break; - - case 'ordner': - currentOrdner = data.name; - logContainer.innerHTML += `
    - 📁 ${escapeHtml(data.name)} -
    `; - break; - - case 'mails': - const ordnerStatus = document.getElementById('ordner-status'); - if (ordnerStatus) { - ordnerStatus.innerHTML = `📁 ${escapeHtml(data.ordner)}: ${data.anzahl} Mails`; - ordnerStatus.id = ''; // ID entfernen für nächsten Ordner - } - break; - - case 'datei': - dateiCount++; - logContainer.innerHTML += `
    - ✓ ${escapeHtml(data.original_name)} - ${formatBytes(data.groesse)} -
    `; - // Scroll nach unten - logContainer.scrollTop = logContainer.scrollHeight; - break; - - case 'skip': - logContainer.innerHTML += `
    - ⊘ ${escapeHtml(data.datei)}: ${data.grund} -
    `; - break; - - case 'fehler': - logContainer.innerHTML += `
    - ✗ ${escapeHtml(data.nachricht)} -
    `; - break; - - case 'fertig': - logContainer.innerHTML += `
    - ✓ Fertig: ${data.anzahl} Dateien gespeichert -
    `; - eventSource.close(); - ladePostfaecher(); - break; - } - }; - - eventSource.onerror = (error) => { - logContainer.innerHTML += `
    - ✗ Verbindung unterbrochen -
    `; - eventSource.close(); - }; -} - -function formatBytes(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; -} - -async function allePostfaecherAbrufen() { - try { - zeigeLoading('Rufe alle Postfächer ab...'); - const result = await api('/postfaecher/abrufen-alle', { method: 'POST' }); - zeigeAbrufLog(result); - ladePostfaecher(); - } catch (error) { - alert('Fehler: ' + error.message); - } finally { - versteckeLoading(); - } -} - -async function postfachLoeschen(id) { - if (!confirm('Postfach wirklich löschen?')) return; - try { - await api(`/postfaecher/${id}`, { method: 'DELETE' }); - ladePostfaecher(); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -function zeigeAbrufLog(result) { - const container = document.getElementById('abruf-log'); - - if (!result.ergebnisse || result.ergebnisse.length === 0) { - container.innerHTML = '

    Keine neuen Attachments gefunden

    '; - return; - } - - let html = ''; - for (const r of result.ergebnisse) { - const status = r.fehler ? 'error' : 'success'; - const icon = r.fehler ? '✗' : '✓'; - html += `
    - ${icon} ${escapeHtml(r.postfach)}: ${r.anzahl || 0} Dateien - ${r.fehler ? `${escapeHtml(r.fehler)}` : ''} -
    `; - - if (r.dateien) { - for (const d of r.dateien) { - html += `
    - → ${escapeHtml(d)} -
    `; - } - } - } - - container.innerHTML = html; -} - -// ============ BEREICH 2: Datei-Sortierung ============ - -async function ladeOrdner() { - try { - const ordner = await api('/ordner'); - renderOrdner(ordner); - } catch (error) { - console.error('Fehler:', error); - } -} - -function renderOrdner(ordner) { - const container = document.getElementById('ordner-liste'); - - if (!ordner || ordner.length === 0) { - container.innerHTML = '

    Keine Ordner konfiguriert

    '; - return; - } - - container.innerHTML = ordner.map(o => ` -
    -
    -

    ${escapeHtml(o.name)} ${o.rekursiv ? 'rekursiv' : ''}

    - ${escapeHtml(o.pfad)} → ${escapeHtml(o.ziel_ordner)} - ${(o.dateitypen || []).join(', ')} -
    -
    - - -
    -
    - `).join(''); -} - -function zeigeOrdnerModal() { - document.getElementById('ord-name').value = ''; - document.getElementById('ord-pfad').value = '/srv/http/dateiverwaltung/data/inbox/'; - document.getElementById('ord-ziel').value = '/srv/http/dateiverwaltung/data/archiv/'; - setCheckedTypes('ord-typen-gruppe', ['.pdf', '.jpg', '.jpeg', '.png', '.tiff']); - document.getElementById('ord-rekursiv').value = 'true'; - document.getElementById('ordner-modal').classList.remove('hidden'); -} - -async function speichereOrdner() { - const dateitypen = getCheckedTypes('ord-typen-gruppe'); - if (dateitypen.length === 0) { - alert('Bitte mindestens einen Dateityp auswählen'); - return; - } - - const data = { - name: document.getElementById('ord-name').value.trim(), - pfad: document.getElementById('ord-pfad').value.trim(), - ziel_ordner: document.getElementById('ord-ziel').value.trim(), - rekursiv: document.getElementById('ord-rekursiv').value === 'true', - dateitypen: dateitypen - }; - - if (!data.name || !data.pfad || !data.ziel_ordner) { - alert('Bitte alle Felder ausfüllen'); - return; - } - - try { - zeigeLoading('Speichere Ordner...'); - await api('/ordner', { method: 'POST', body: JSON.stringify(data) }); - schliesseModal('ordner-modal'); - ladeOrdner(); - } catch (error) { - alert('Fehler: ' + error.message); - } finally { - versteckeLoading(); - } -} - -async function ordnerLoeschen(id) { - if (!confirm('Ordner wirklich löschen?')) return; - try { - await api(`/ordner/${id}`, { method: 'DELETE' }); - ladeOrdner(); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function ordnerScannen(id) { - try { - const result = await api(`/ordner/${id}/scannen`); - alert(`${result.anzahl} Dateien im Ordner gefunden`); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -// ============ Regeln ============ - -let editierteRegelId = null; - -async function ladeRegeln() { - try { - const regeln = await api('/regeln'); - renderRegeln(regeln); - } catch (error) { - console.error('Fehler:', error); - } -} - -function renderRegeln(regeln) { - const container = document.getElementById('regeln-liste'); - - if (!regeln || regeln.length === 0) { - container.innerHTML = '

    Keine Regeln definiert

    '; - return; - } - - container.innerHTML = regeln.map(r => ` -
    -
    -

    ${escapeHtml(r.name)} Prio ${r.prioritaet}

    - ${escapeHtml(r.schema)} -
    -
    - - -
    -
    - `).join(''); -} - -function zeigeRegelModal(regel = null) { - editierteRegelId = regel?.id || null; - document.getElementById('regel-modal-title').textContent = regel ? 'Regel bearbeiten' : 'Regel hinzufügen'; - - document.getElementById('regel-name').value = regel?.name || ''; - document.getElementById('regel-prioritaet').value = regel?.prioritaet || 100; - document.getElementById('regel-muster').value = JSON.stringify(regel?.muster || {"text_match_any": [], "text_match": []}, null, 2); - document.getElementById('regel-extraktion').value = JSON.stringify(regel?.extraktion || {}, null, 2); - document.getElementById('regel-schema').value = regel?.schema || '{datum} - Dokument.pdf'; - document.getElementById('regel-unterordner').value = regel?.unterordner || ''; - document.getElementById('regel-test-text').value = ''; - document.getElementById('regel-test-ergebnis').classList.add('hidden'); - - document.getElementById('regel-modal').classList.remove('hidden'); -} - -async function bearbeiteRegel(id) { - try { - const regeln = await api('/regeln'); - const regel = regeln.find(r => r.id === id); - if (regel) zeigeRegelModal(regel); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function speichereRegel() { - let muster, extraktion; - - try { - muster = JSON.parse(document.getElementById('regel-muster').value); - } catch (e) { - alert('Ungültiges JSON im Muster-Feld'); - return; - } - - try { - extraktion = JSON.parse(document.getElementById('regel-extraktion').value); - } catch (e) { - alert('Ungültiges JSON im Extraktion-Feld'); - return; - } - - const data = { - name: document.getElementById('regel-name').value.trim(), - prioritaet: parseInt(document.getElementById('regel-prioritaet').value), - muster, - extraktion, - schema: document.getElementById('regel-schema').value.trim(), - unterordner: document.getElementById('regel-unterordner').value.trim() || null - }; - - if (!data.name) { - alert('Bitte einen Namen eingeben'); - return; - } - - try { - if (editierteRegelId) { - await api(`/regeln/${editierteRegelId}`, { method: 'PUT', body: JSON.stringify(data) }); - } else { - await api('/regeln', { method: 'POST', body: JSON.stringify(data) }); - } - schliesseModal('regel-modal'); - ladeRegeln(); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function regelLoeschen(id) { - if (!confirm('Regel wirklich löschen?')) return; - try { - await api(`/regeln/${id}`, { method: 'DELETE' }); - ladeRegeln(); - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -async function testeRegel() { - const text = document.getElementById('regel-test-text').value; - if (!text) { - alert('Bitte Testtext eingeben'); - return; - } - - let muster, extraktion; - try { - muster = JSON.parse(document.getElementById('regel-muster').value); - extraktion = JSON.parse(document.getElementById('regel-extraktion').value); - } catch (e) { - alert('Ungültiges JSON'); - return; - } - - const regel = { - name: 'Test', - muster, - extraktion, - schema: document.getElementById('regel-schema').value.trim() - }; - - try { - const result = await api('/regeln/test', { - method: 'POST', - body: JSON.stringify({ regel, text }) - }); - - const container = document.getElementById('regel-test-ergebnis'); - container.classList.remove('hidden', 'success', 'error'); - - if (result.passt) { - container.classList.add('success'); - container.textContent = `✓ Regel passt!\n\nExtrahiert:\n${JSON.stringify(result.extrahiert, null, 2)}\n\nDateiname:\n${result.dateiname}`; - } else { - container.classList.add('error'); - container.textContent = '✗ Regel passt nicht'; - } - } catch (error) { - alert('Fehler: ' + error.message); - } -} - -// ============ Sortierung starten ============ - -async function sortierungStarten() { - try { - zeigeLoading('Sortiere Dateien...'); - const result = await api('/sortierung/starten', { method: 'POST' }); - zeigeSortierungLog(result); - } catch (error) { - alert('Fehler: ' + error.message); - } finally { - versteckeLoading(); - } -} - -function zeigeSortierungLog(result) { - const container = document.getElementById('sortierung-log'); - - if (!result.verarbeitet || result.verarbeitet.length === 0) { - container.innerHTML = '

    Keine Dateien verarbeitet

    '; - return; - } - - let html = `
    - Gesamt: ${result.gesamt} | Sortiert: ${result.sortiert} | ZUGFeRD: ${result.zugferd} | Fehler: ${result.fehler} -
    `; - - for (const d of result.verarbeitet) { - const status = d.fehler ? 'error' : (d.zugferd ? 'info' : 'success'); - const icon = d.fehler ? '✗' : (d.zugferd ? '🧾' : '✓'); - html += `
    - ${icon} ${escapeHtml(d.neuer_name || d.original)} - ${d.fehler ? `${escapeHtml(d.fehler)}` : ''} -
    `; - } - - container.innerHTML = html; -} - -// ============ Utilities ============ - -function schliesseModal(id) { - document.getElementById(id).classList.add('hidden'); -} - -function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -document.addEventListener('click', (e) => { - if (e.target.classList.contains('modal')) { - e.target.classList.add('hidden'); - } -}); - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden')); - } -}); diff --git a/frontend/templates/index.html b/frontend/templates/index.html deleted file mode 100644 index 2ecef1e..0000000 --- a/frontend/templates/index.html +++ /dev/null @@ -1,366 +0,0 @@ - - - - - - Dateiverwaltung - - - -
    - -
    -
    -

    Dateiverwaltung

    -
    -
    - -
    -
    - - -
    - -
    -
    -

    📧 Mail-Abruf

    -

    Attachments aus Postfächern in Ordner speichern

    -
    - -
    - -
    -
    -

    Postfächer

    - -
    -
    -
    -

    Keine Postfächer konfiguriert

    -
    -
    -
    - - -
    - -
    - - -
    -
    -

    Letzter Abruf

    -
    -
    -
    -

    Noch kein Abruf durchgeführt

    -
    -
    -
    -
    -
    - - -
    -
    -

    📁 Datei-Sortierung

    -

    Dateien nach Regeln umbenennen und verschieben

    -
    - -
    - -
    -
    -

    Quell-Ordner

    - -
    -
    -
    -

    Keine Ordner konfiguriert

    -
    -
    -
    - - -
    -
    -

    Sortier-Regeln

    - -
    -
    -
    -

    Keine Regeln definiert

    -
    -
    -
    - - -
    - -
    - - -
    -
    -

    Verarbeitete Dateien

    -
    -
    -
    -

    Noch keine Dateien verarbeitet

    -
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - -
    - - - -