diff --git a/.env.example b/.env.example index 2344d08..88c82e8 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,84 @@ -# Dateiverwaltung Umgebungsvariablen -# Kopiere diese Datei nach .env und passe sie an +# ============================================== +# Dateiverwaltung - Umgebungsvariablen +# ============================================== +# Kopiere diese Datei nach .env und passe sie an: +# cp .env.example .env +# nano .env +# ============================================== -# Datenbank -DATABASE_URL=sqlite:///./data/dateiverwaltung.db +# ---------------------------------------------- +# Server-Einstellungen +# ---------------------------------------------- + +# Port für die Web-Oberfläche +PORT=8000 # Zeitzone TZ=Europe/Berlin +# ---------------------------------------------- +# Pfade - WICHTIG: An dein System anpassen! +# ---------------------------------------------- + +# Wo die Datenbank gespeichert wird (persistent!) +# Hier werden alle Einstellungen, Regeln, Postfächer gespeichert +DATA_PATH=./data + +# Quell-Ordner: Hier liegen die unsortieren Dateien +# Beispiele: +# /home/benutzer/Dokumente/Inbox +# /mnt/nas/scans +# /mnt/mailanhänge +INBOX_PATH=/mnt/inbox + +# Ziel-Ordner: Hierhin werden sortierte Dateien verschoben +# Beispiele: +# /home/benutzer/Dokumente/Archiv +# /mnt/nas/archiv +ARCHIV_PATH=/mnt/archiv + +# Backup-Ordner: Original-PDFs vor OCR-Einbettung +# WICHTIG: Falls OCR fehlschlägt, sind die Originale hier gesichert +BACKUP_PATH=/mnt/backup + +# Zusätzliche Ordner (optional) +# Werden im Container unter /mnt/extra1, /mnt/extra2 verfügbar +# EXTRA_PATH_1=/mnt/dokumente +# EXTRA_PATH_2=/mnt/scans + +# ---------------------------------------------- +# Datenbank +# ---------------------------------------------- + +# SQLite Datenbank (Standard, keine Konfiguration nötig) +DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + +# PostgreSQL (optional, für größere Installationen) +# DATABASE_URL=postgresql://user:password@localhost/dateiverwaltung + +# ---------------------------------------------- # OCR Einstellungen +# ---------------------------------------------- + +# Sprache für OCR (deu = Deutsch, eng = Englisch) +# Mehrere Sprachen: deu+eng OCR_LANGUAGE=deu + +# DPI für OCR-Verarbeitung (höher = besser, aber langsamer) OCR_DPI=300 -# Optional: Claude API für KI-Validierung (spätere Erweiterung) +# ---------------------------------------------- +# Mail-Abruf (wird in der Web-UI konfiguriert) +# ---------------------------------------------- +# Die Mail-Zugangsdaten werden in der Datenbank gespeichert, +# nicht in Umgebungsvariablen (sicherer). + +# ---------------------------------------------- +# Erweitert (optional) +# ---------------------------------------------- + +# Log-Level (DEBUG, INFO, WARNING, ERROR) +# LOG_LEVEL=INFO + +# API-Key für KI-Validierung (zukünftige Erweiterung) # CLAUDE_API_KEY=sk-ant-... diff --git a/Dockerfile b/Dockerfile index 1c60c27..9edaa3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ 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 diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..dee1eba --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,489 @@ +# Dateiverwaltung - Installation & Deployment + +**Version 1.1** + +## Übersicht + +Diese Anleitung beschreibt die Installation der Dateiverwaltung mit Docker und Portainer. + +### Neue Features in Version 1.1 +- **Dateimanager mit 3-Panel Layout** (Ordnerbaum, Dateiliste, Vorschau) +- **Separates Vorschau-Fenster** - öffnet auf zweitem Monitor +- **Resize-Handles** für flexible Panel-Größen (auch im vertikalen Modus) +- **Vorschau-Panel ausblendbar** wenn externes Fenster aktiv +- **Natürliche Sortierung** (Sonderzeichen → Zahlen → Buchstaben) +- **PDF-Vorschau** mit Fit-to-Page (erste Seite komplett sichtbar) + +--- + +## 1. Voraussetzungen + +- Docker & Docker Compose installiert +- Portainer (optional, für Web-UI Verwaltung) +- Zugriff auf die Ordner, die verwaltet werden sollen + +--- + +## 2. Image erstellen + +### Option A: tar.gz erstellen und in Portainer hochladen + +1. **Auf deinem Rechner - Archiv erstellen:** + ```bash + cd /pfad/zum/projekt/docker.dateiverwaltung + tar -czvf dateiverwaltung.tar.gz * + ``` + +2. **In Portainer:** + - **Images** → **Build image** + - **Name:** `dateiverwaltung:latest` + - **Build method:** Upload + - **Select file:** Die erstellte `dateiverwaltung.tar.gz` hochladen + - **Build the image** klicken + +3. Warten bis "Successfully built" erscheint + +### Option B: Auf dem Server bauen (einfacher) + +```bash +# Projektordner erstellen +mkdir -p /opt/dateiverwaltung +cd /opt/dateiverwaltung + +# Dateien kopieren (oder git clone) +# Alle Projektdateien hierhin kopieren + +# Image bauen +docker build -t dateiverwaltung:latest . +``` + +### Option C: Mit Docker Compose bauen + +```bash +cd /opt/dateiverwaltung +docker compose build +``` + +Das Image heißt dann `dockerdateiverwaltung-dateiverwaltung` (oder je nach Ordnername). + +--- + +## 3. Schnellstart mit Docker Compose + +### 2.1 Repository klonen oder Dateien kopieren + +```bash +git clone /opt/dateiverwaltung +cd /opt/dateiverwaltung +``` + +### 2.2 Umgebungsvariablen konfigurieren + +Erstelle eine `.env` Datei: + +```bash +cp .env.example .env +nano .env +``` + +### 2.3 Container starten + +```bash +docker compose up -d +``` + +Die Anwendung ist dann unter `http://localhost:8000` erreichbar. + +--- + +## 4. Installation mit Portainer + +### 4.1 Image bauen (falls noch nicht geschehen) + +**Methode 1: Direkt auf dem Server** +```bash +cd /opt/dateiverwaltung +docker build -t dateiverwaltung:latest . +``` + +**Methode 2: In Portainer** +1. **Images** → **Build image** +2. Name: `dateiverwaltung:latest` +3. Upload: Projektdateien als tar.gz hochladen + ```bash + # Auf deinem PC/Server: + cd /pfad/zum/projekt + tar -czvf dateiverwaltung.tar.gz . + # Diese Datei dann in Portainer hochladen + ``` +4. **Build the image** klicken + +### 4.2 Stack erstellen + +1. Öffne Portainer → **Stacks** → **Add Stack** +2. Name: `dateiverwaltung` +3. Wähle **Web editor** und füge folgende Konfiguration ein: + +```yaml +services: + dateiverwaltung: + # WICHTIG: Entweder "image" ODER "build" verwenden, nicht beides! + + # Option 1: Bereits gebautes Image verwenden + image: dateiverwaltung:latest + + # Option 2: Image beim Deploy bauen (Projektdateien müssen auf Server liegen) + # build: + # context: /opt/dateiverwaltung + # dockerfile: Dockerfile + + container_name: dateiverwaltung + restart: unless-stopped + ports: + - "${PORT:-8000}:8000" + volumes: + # Persistente Daten (Datenbank) - WICHTIG! + - ${DATA_PATH:-/opt/dateiverwaltung/data}:/app/data + + # Quell-Ordner: Hier liegen die zu sortierenden Dateien + - ${INBOX_PATH:-/mnt/inbox}:/mnt/inbox + + # Ziel-Ordner: Hierhin werden sortierte Dateien verschoben + - ${ARCHIV_PATH:-/mnt/archiv}:/mnt/archiv + + # OCR-Backup Ordner (für Original-PDFs vor OCR) + - ${BACKUP_PATH:-/mnt/backup}:/mnt/backup + + # Optional: Zusätzliche Ordner einbinden + # - /mnt/nas/dokumente:/mnt/dokumente + # - /mnt/scans:/mnt/scans + + environment: + - TZ=${TIMEZONE:-Europe/Berlin} + - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### 4.4 Environment Variables in Portainer + +Scrolle runter zu **Environment variables** und füge folgende Variablen hinzu: + +| Variable | Beschreibung | Beispielwert | +|----------|--------------|--------------| +| `PORT` | Port für Web-UI | `8000` | +| `TIMEZONE` | Zeitzone | `Europe/Berlin` | +| `DATA_PATH` | Pfad für Datenbank (persistent!) | `/opt/dateiverwaltung/data` | +| `INBOX_PATH` | Ordner mit unsortieren Dateien | `/home/user/Dokumente/Inbox` | +| `ARCHIV_PATH` | Zielordner für sortierte Dateien | `/home/user/Dokumente/Archiv` | +| `BACKUP_PATH` | Backup-Ordner für PDFs vor OCR | `/home/user/Dokumente/Backup` | + +### 4.5 Deploy Stack + +Klicke auf **Deploy the stack**. + +--- + +## 5. Komplette Schritt-für-Schritt Anleitung (Portainer) + +### Schritt 1: Projektdateien auf Server kopieren + +```bash +# Auf dem Server +mkdir -p /opt/dateiverwaltung +cd /opt/dateiverwaltung + +# Projektdateien hierhin kopieren (z.B. per SCP, SFTP, Git) +# scp -r /lokaler/pfad/* user@server:/opt/dateiverwaltung/ +``` + +### Schritt 2: Image bauen + +```bash +cd /opt/dateiverwaltung +docker build -t dateiverwaltung:latest . +``` + +Warte bis "Successfully built" erscheint. + +### Schritt 3: Datenordner erstellen + +```bash +# Ordner für Datenbank erstellen +mkdir -p /opt/dateiverwaltung/data + +# Ordner für Dateien (falls nicht vorhanden) +mkdir -p /mnt/inbox /mnt/archiv /mnt/backup +``` + +### Schritt 4: Stack in Portainer erstellen + +1. Portainer öffnen → **Stacks** → **Add Stack** +2. Name: `dateiverwaltung` +3. Web editor → Diesen YAML-Code einfügen: + +```yaml +services: + dateiverwaltung: + image: dateiverwaltung:latest + container_name: dateiverwaltung + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - /opt/dateiverwaltung/data:/app/data + - /mnt/inbox:/mnt/inbox + - /mnt/archiv:/mnt/archiv + - /mnt/backup:/mnt/backup + environment: + - TZ=Europe/Berlin + - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +4. **Deploy the stack** klicken + +### Schritt 5: Anwendung öffnen + +- URL: `http://:8000` +- Dateimanager: `http://:8000/browser` + +--- + +## 6. Ordner-Struktur + +### Empfohlene Struktur auf dem Host: + +``` +/home/user/Dokumente/ +├── Inbox/ # Unsortierte Dateien (Quell-Ordner) +│ ├── mail-anhänge/ # z.B. von Mail-Abruf +│ └── scans/ # z.B. vom Scanner +├── Archiv/ # Sortierte Dateien (Ziel-Ordner) +│ ├── rechnungen/ +│ ├── verträge/ +│ └── sonstiges/ +└── Backup/ # Original-PDFs vor OCR +``` + +### Im Container sichtbar als: + +``` +/mnt/ +├── inbox/ → Host: /home/user/Dokumente/Inbox +├── archiv/ → Host: /home/user/Dokumente/Archiv +└── backup/ → Host: /home/user/Dokumente/Backup +``` + +--- + +## 7. Erstmalige Einrichtung nach Start + +### 5.1 Web-UI öffnen + +Öffne `http://:8000` + +### 5.2 Quell-Ordner hinzufügen + +1. Klicke auf **+ Hinzufügen** bei "Quell-Ordner" +2. Konfiguriere: + - **Name:** z.B. "Mail-Inbox" + - **Quell-Pfad:** `/mnt/inbox` (wie im Container gemountet) + - **Ziel-Ordner:** `/mnt/archiv` + - **Dateitypen:** PDF, JPG, PNG, etc. + +### 5.3 Regeln erstellen + +#### Schnell-Regeln (Grob-Sortierung nach Typ): +1. Klicke auf **+ Schnell-Regel** +2. Wähle z.B. "E-Rechnungen (ZUGFeRD)" → Unterordner: `e-rechnungen` +3. Wähle "Bilder" → Unterordner: `bilder` + +#### Fein-Regeln (nach Inhalt): +1. Klicke auf **+ Hinzufügen** bei "Fein-Regeln" +2. Konfiguriere Keywords und Dateinamen-Schema + +### 5.4 OCR-Backup aktivieren (empfohlen) + +1. Aktiviere "Backup vor OCR-Einbettung erstellen" +2. Wähle Backup-Ordner: `/mnt/backup` + +--- + +## 8. Persistente Daten + +### Diese Daten werden gespeichert: + +| Pfad im Container | Inhalt | Wichtig? | +|-------------------|--------|----------| +| `/app/data/dateiverwaltung.db` | SQLite-Datenbank mit allen Einstellungen | **JA** | +| `/app/data/` | Logs und temporäre Dateien | Ja | + +### Backup der Einstellungen: + +```bash +# Datenbank sichern +docker cp dateiverwaltung:/app/data/dateiverwaltung.db ./backup/ + +# Oder Volume-Pfad direkt sichern +cp /opt/dateiverwaltung/data/dateiverwaltung.db ./backup/ +``` + +--- + +## 9. Beispiel: Komplette docker-compose.yml + +```yaml +version: '3.8' + +services: + dateiverwaltung: + build: . + container_name: dateiverwaltung + restart: unless-stopped + ports: + - "8000:8000" + volumes: + # Datenbank (WICHTIG: Persistent!) + - ./data:/app/data + + # === ANPASSEN: Deine Ordner === + # Inbox: Hier landen unsortierte Dateien + - /home/benutzer/Dokumente/Inbox:/mnt/inbox + + # Archiv: Hierhin werden Dateien sortiert + - /home/benutzer/Dokumente/Archiv:/mnt/archiv + + # Backup: Original-PDFs vor OCR + - /home/benutzer/Dokumente/Backup:/mnt/backup + + # NAS-Ordner (optional) + - /mnt/nas/scans:/mnt/scans + + environment: + - TZ=Europe/Berlin + - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db +``` + +--- + +## 10. Dateimanager (Dual-Pane Browser) + +Die Anwendung enthält einen separaten Dateimanager unter `/browser`: + +- **URL:** `http://:8000/browser` +- **Features:** + - Dual-Pane Layout (Ordner links, Vorschau rechts) + - Dateien umbenennen, verschieben, löschen + - PDF/Bild-Vorschau ohne Dateisperrung + - Kann auf separatem Monitor geöffnet werden + +**Tipp:** Öffne den Link in einem neuen Fenster für Multi-Monitor-Setup. + +--- + +## 11. Troubleshooting + +### Container startet nicht + +```bash +# Logs prüfen +docker logs dateiverwaltung + +# Container neu bauen +docker compose build --no-cache +docker compose up -d +``` + +### Dateien können nicht gelesen/geschrieben werden + +```bash +# Berechtigungen prüfen +ls -la /home/benutzer/Dokumente/Inbox/ + +# Container-User prüfen (läuft als root) +docker exec dateiverwaltung id + +# Berechtigungen setzen +chmod -R 755 /home/benutzer/Dokumente/ +``` + +### Datenbank zurücksetzen + +```bash +# Alle Einstellungen löschen +docker exec dateiverwaltung rm /app/data/dateiverwaltung.db +docker restart dateiverwaltung +``` + +--- + +## 12. Updates + +```bash +# Neueste Version holen +git pull + +# Container neu bauen und starten +docker compose build +docker compose up -d +``` + +--- + +## 13. Portainer Stack Template (Copy & Paste) + +Für schnelles Deployment in Portainer, kopiere diesen Stack: + +```yaml +version: '3.8' + +services: + dateiverwaltung: + build: + context: /opt/dateiverwaltung + container_name: dateiverwaltung + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - dateiverwaltung_data:/app/data + - /mnt/inbox:/mnt/inbox + - /mnt/archiv:/mnt/archiv + - /mnt/backup:/mnt/backup + environment: + - TZ=Europe/Berlin + - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + dateiverwaltung_data: +``` + +**Nach Deploy:** +1. Web-UI öffnen: `http://:8000` +2. Quell-Ordner hinzufügen: `/mnt/inbox` → `/mnt/archiv` +3. Regeln erstellen +4. Fertig! + +--- + +## Zusammenfassung der wichtigsten Pfade + +| Was | Pfad im Container | Host-Pfad (anpassen!) | +|-----|-------------------|----------------------| +| Datenbank | `/app/data/` | `./data` oder Volume | +| Inbox (Quelle) | `/mnt/inbox` | `/home/user/Inbox` | +| Archiv (Ziel) | `/mnt/archiv` | `/home/user/Archiv` | +| OCR-Backup | `/mnt/backup` | `/home/user/Backup` | diff --git a/backend/app/main.py b/backend/app/main.py index 811f0de..7e91127 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -51,6 +51,18 @@ async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) +@app.get("/browser", response_class=HTMLResponse) +async def browser(request: Request): + """Dateimanager / Dual-Pane Browser""" + return templates.TemplateResponse("browser.html", {"request": request}) + + +@app.get("/browser/preview", response_class=HTMLResponse) +async def browser_preview(request: Request): + """Separates Vorschau-Fenster für Dateimanager""" + return templates.TemplateResponse("preview.html", {"request": request}) + + @app.get("/health") async def health(): """Health Check für Docker""" diff --git a/backend/app/models/database.py b/backend/app/models/database.py index 140af15..e57c539 100644 --- a/backend/app/models/database.py +++ b/backend/app/models/database.py @@ -28,6 +28,7 @@ class Postfach(Base): 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) + ab_datum = Column(DateTime, nullable=True) # Nur Mails ab diesem Datum # Ziel ziel_ordner = Column(String(500), nullable=False) @@ -121,7 +122,8 @@ def migrate_db(): migrations = { "postfaecher": { "alle_ordner": "BOOLEAN DEFAULT 0", - "nur_ungelesen": "BOOLEAN DEFAULT 0" + "nur_ungelesen": "BOOLEAN DEFAULT 0", + "ab_datum": "DATETIME" }, "quell_ordner": { "rekursiv": "BOOLEAN DEFAULT 1", diff --git a/backend/app/modules/extraktoren.py b/backend/app/modules/extraktoren.py index b5089b0..76b2e31 100644 --- a/backend/app/modules/extraktoren.py +++ b/backend/app/modules/extraktoren.py @@ -186,21 +186,59 @@ def extrahiere_nummer(text: str, spezifische_muster: List[Dict] = None) -> Optio # ============ FIRMA/ABSENDER ============ FIRMA_MUSTER = [ - # Absender-Zeile - {"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", "context": True}, - {"regex": r"Absender[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True}, - {"regex": r"Von[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True}, + # Rechtsformen direkt (GmbH, AG, etc.) - sehr zuverlässig + {"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\s+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR)", "context": True}, + + # Kopfzeile/Absender typisch erste Zeilen + {"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß0-9\s&\-\.]{2,50})$", "context": True, "multiline": True}, + + # Nach "von" / Absender + {"regex": r"(?:Absender|Von|From)[:\s]+([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True}, + + # Firmenname vor Adresse (PLZ Stadt) + {"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\n+[A-Za-zäöüÄÖÜß\s\d\-\.]+\n+\d{5}\s+[A-Za-zäöüÄÖÜß]+", "context": True}, + + # E-Mail Domain als Firmennamen + {"regex": r"(?:info|kontakt|rechnung|buchhaltung|office)@([a-zA-Z0-9\-]+)\.", "context": True}, + + # Website als Firmennamen + {"regex": r"(?:www\.|http[s]?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", "context": True}, ] # Bekannte Firmen (werden im Text gesucht) BEKANNTE_FIRMEN = [ + # Elektronik/IT "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", + "Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "Cyberport", + "Apple", "Microsoft", "Dell", "HP", "Lenovo", "ASUS", "Acer", + + # Baumärkte + "Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Globus", + + # Telekommunikation + "Telekom", "Vodafone", "O2", "1&1", "Congstar", "Drillisch", + + # Versicherungen + "Allianz", "HUK", "Provinzial", "DEVK", "Gothaer", "AXA", "ERGO", "Zurich", + "Generali", "HDI", "VHV", "R+V", "Debeka", "Signal Iduna", + + # Möbel + "IKEA", "Poco", "XXXLutz", "Roller", "Höffner", "Segmüller", + + # Versand/Logistik + "DHL", "DPD", "Hermes", "UPS", "GLS", "FedEx", + + # Lebensmittel/Drogerie + "REWE", "Edeka", "Aldi", "Lidl", "Rossmann", "dm", "Müller", + + # Energie + "E.ON", "RWE", "EnBW", "Vattenfall", "Stadtwerke", "EWE", "ENTEGA", + + # Banken + "Deutsche Bank", "Commerzbank", "Sparkasse", "Volksbank", "ING", "DKB", "Postbank", + + # Sonstige + "ADAC", "TÜV", "Dekra", "Würth", "Grainger", "Festo", "Bosch", ] @@ -224,16 +262,44 @@ def extrahiere_firma(text: str, absender_email: str = "", spezifische_muster: Li for firma in BEKANNTE_FIRMEN: if firma.lower() == domain.lower(): return firma + # Domain als Firmenname verwenden (kapitalisiert) + if len(domain) > 2: + return domain.capitalize() + + # 3. Firmen mit Rechtsform suchen (GmbH, AG, etc.) + rechtsform_match = re.search( + r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß0-9\s&\-\.]{1,50})\s*(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR|Co\.\s*KG)", + text + ) + if rechtsform_match: + firma = rechtsform_match.group(1).strip() + if len(firma) >= 2: + return firma + + # 4. E-Mail im Text suchen und Domain extrahieren + email_match = re.search(r"[\w\.\-]+@([\w\-]+)\.", text) + if email_match: + domain = email_match.group(1) + if len(domain) > 2 and domain.lower() not in ["gmail", "yahoo", "hotmail", "outlook", "web", "gmx", "mail"]: return domain.capitalize() - # 3. Regex-Muster + # 5. Website im Text suchen + web_match = re.search(r"(?:www\.|https?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", text, re.IGNORECASE) + if web_match: + domain = web_match.group(1) + if len(domain) > 2: + return domain.capitalize() + + # 6. Regex-Muster als Fallback muster_liste = (spezifische_muster or []) + FIRMA_MUSTER for muster in muster_liste: try: - match = re.search(muster["regex"], text, re.MULTILINE) + flags = re.MULTILINE if muster.get("multiline") else 0 + match = re.search(muster["regex"], text, flags) if match: firma = match.group(1).strip() - if len(firma) >= 2: + # Filtern: zu kurz, nur Zahlen, etc. + if len(firma) >= 2 and not firma.isdigit(): return firma except: continue diff --git a/backend/app/modules/mail_fetcher.py b/backend/app/modules/mail_fetcher.py index 61cc0be..89caedc 100644 --- a/backend/app/modules/mail_fetcher.py +++ b/backend/app/modules/mail_fetcher.py @@ -7,14 +7,77 @@ import email from email.header import decode_header from pathlib import Path from datetime import datetime -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Callable import logging +import threading from ..config import INBOX_DIR logger = logging.getLogger(__name__) +# Globaler Manager für laufende Abrufe +class AbrufManager: + """Verwaltet laufende Mail-Abrufe und ermöglicht Abbruch""" + + def __init__(self): + self._aktive_abrufe: Dict[int, dict] = {} # postfach_id -> status + self._lock = threading.Lock() + + def starten(self, postfach_id: int) -> bool: + """Startet einen Abruf, gibt False zurück wenn bereits einer läuft""" + with self._lock: + if postfach_id in self._aktive_abrufe: + return False + self._aktive_abrufe[postfach_id] = { + "status": "running", + "gestartet": datetime.now(), + "abbrechen": False + } + return True + + def stoppen(self, postfach_id: int) -> bool: + """Markiert einen Abruf zum Abbruch""" + with self._lock: + if postfach_id in self._aktive_abrufe: + self._aktive_abrufe[postfach_id]["abbrechen"] = True + return True + return False + + def beenden(self, postfach_id: int): + """Entfernt einen Abruf aus der Liste""" + with self._lock: + if postfach_id in self._aktive_abrufe: + del self._aktive_abrufe[postfach_id] + + def soll_abbrechen(self, postfach_id: int) -> bool: + """Prüft ob ein Abruf abgebrochen werden soll""" + with self._lock: + if postfach_id in self._aktive_abrufe: + return self._aktive_abrufe[postfach_id].get("abbrechen", False) + return True # Nicht registriert = abbrechen + + def ist_aktiv(self, postfach_id: int) -> bool: + """Prüft ob ein Abruf läuft""" + with self._lock: + return postfach_id in self._aktive_abrufe + + def alle_aktiven(self) -> Dict[int, dict]: + """Gibt alle aktiven Abrufe zurück""" + with self._lock: + return dict(self._aktive_abrufe) + + def stoppe_alle(self): + """Stoppt alle laufenden Abrufe""" + with self._lock: + for postfach_id in self._aktive_abrufe: + self._aktive_abrufe[postfach_id]["abbrechen"] = True + + +# Globale Instanz +abruf_manager = AbrufManager() + + class MailFetcher: """Holt Attachments aus einem IMAP-Postfach""" @@ -81,7 +144,8 @@ class MailFetcher: nur_ungelesen: bool = False, markiere_gelesen: bool = False, alle_ordner: bool = False, - bereits_verarbeitet: set = None) -> List[Dict]: + bereits_verarbeitet: set = None, + ab_datum: datetime = None) -> List[Dict]: """ Holt alle Attachments die den Filtern entsprechen @@ -114,7 +178,7 @@ 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 + nur_ungelesen, markiere_gelesen, bereits_verarbeitet, ab_datum )) return ergebnisse @@ -122,7 +186,7 @@ class MailFetcher: def _fetch_from_folder(self, ordner: str, ziel: Path, erlaubte_typen: List[str], max_groesse: int, nur_ungelesen: bool, markiere_gelesen: bool, - bereits_verarbeitet: set) -> List[Dict]: + bereits_verarbeitet: set, ab_datum: datetime = None) -> List[Dict]: """Holt Attachments aus einem einzelnen Ordner""" ergebnisse = [] @@ -130,8 +194,16 @@ class MailFetcher: # Ordner auswählen status, _ = self.connection.select(ordner) - # Suche nach Mails - search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" + # Suche nach Mails - mit optionalem Datum-Filter + if ab_datum: + # IMAP Datum-Format: DD-Mon-YYYY (z.B. 01-Jan-2024) + datum_str = ab_datum.strftime("%d-%b-%Y") + if nur_ungelesen: + search_criteria = f'(UNSEEN SINCE {datum_str})' + else: + search_criteria = f'(SINCE {datum_str})' + else: + search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" status, messages = self.connection.search(None, search_criteria) if status != "OK": @@ -252,12 +324,17 @@ class MailFetcher: nur_ungelesen: bool = False, markiere_gelesen: bool = False, alle_ordner: bool = False, - bereits_verarbeitet: set = None): + bereits_verarbeitet: set = None, + abbruch_callback: Callable[[], bool] = None, + ab_datum: datetime = None): """ Generator-Version für Streaming - yielded Events während des Abrufs + Args: + abbruch_callback: Funktion die True zurückgibt wenn abgebrochen werden soll + Yields: - Dict mit type: "ordner", "mails", "datei", "skip", "fehler" + Dict mit type: "ordner", "mails", "datei", "skip", "fehler", "abgebrochen" """ if not self.connection: if not self.connect(): @@ -279,11 +356,24 @@ class MailFetcher: ordner_liste = [self.config.get("ordner", "INBOX")] for ordner in ordner_liste: + # Abbruch prüfen + if abbruch_callback and abbruch_callback(): + yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"} + return + yield {"type": "ordner", "name": ordner} try: status, _ = self.connection.select(ordner) - search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" + # Suche mit optionalem Datum-Filter + if ab_datum: + datum_str = ab_datum.strftime("%d-%b-%Y") + if nur_ungelesen: + search_criteria = f'(UNSEEN SINCE {datum_str})' + else: + search_criteria = f'(SINCE {datum_str})' + else: + search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL" status, messages = self.connection.search(None, search_criteria) if status != "OK": @@ -293,6 +383,11 @@ class MailFetcher: yield {"type": "mails", "ordner": ordner, "anzahl": len(mail_ids)} for mail_id in mail_ids: + # Abbruch prüfen bei jeder Mail + if abbruch_callback and abbruch_callback(): + yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"} + return + try: status, msg_data = self.connection.fetch(mail_id, "(RFC822)") if status != "OK": diff --git a/backend/app/modules/pdf_processor.py b/backend/app/modules/pdf_processor.py index 650c564..17a5594 100644 --- a/backend/app/modules/pdf_processor.py +++ b/backend/app/modules/pdf_processor.py @@ -29,16 +29,24 @@ except ImportError: class PDFProcessor: """Verarbeitet PDFs: Text-Extraktion, OCR, ZUGFeRD-Erkennung""" - def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300): + def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300, backup_ordner: str = None): self.ocr_language = ocr_language self.ocr_dpi = ocr_dpi + self.backup_ordner = backup_ordner # Optional: Ordner für Original-Backups vor OCR - def verarbeite(self, pdf_pfad: str) -> Dict: + def verarbeite(self, pdf_pfad: str, ocr_einbetten: bool = True, backup_erstellen: bool = None) -> Dict: """ Vollständige PDF-Verarbeitung + Args: + pdf_pfad: Pfad zur PDF-Datei + ocr_einbetten: Wenn True, wird OCR-Text permanent in die PDF eingebettet. + ACHTUNG: Wird bei signierten PDFs und ZUGFeRD automatisch deaktiviert! + backup_erstellen: Wenn True, wird vor OCR-Einbettung ein Backup erstellt. + None = verwendet self.backup_ordner als Indikator + Returns: - Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt + Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt, ist_signiert, backup_pfad """ pfad = Path(pdf_pfad) if not pfad.exists(): @@ -51,7 +59,10 @@ class PDFProcessor: "zugferd_xml": None, "hat_text": False, "ocr_durchgefuehrt": False, - "seiten": 0 + "ist_signiert": False, + "ocr_uebersprungen_grund": None, + "seiten": 0, + "backup_pfad": None } # 1. ZUGFeRD prüfen @@ -59,23 +70,148 @@ class PDFProcessor: ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"] ergebnis["zugferd_xml"] = zugferd_result.get("xml") - # 2. Text extrahieren + # 2. Digitale Signatur prüfen + ergebnis["ist_signiert"] = self.hat_digitale_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) - # 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 + # 4. OCR falls kein Text - aber NICHT bei geschützten PDFs! + if not ergebnis["hat_text"]: + # Prüfen ob OCR-Einbettung sicher ist + if ergebnis["ist_zugferd"]: + ergebnis["ocr_uebersprungen_grund"] = "ZUGFeRD-Rechnung - keine Modifikation erlaubt" + logger.info(f"OCR übersprungen (ZUGFeRD): {pfad.name}") + # Trotzdem versuchen Text zu extrahieren ohne einzubetten + ocr_text, _ = self.fuehre_ocr_aus(pdf_pfad, in_place=False) + if ocr_text: + ergebnis["text"] = ocr_text + ergebnis["hat_text"] = True + elif ergebnis["ist_signiert"]: + ergebnis["ocr_uebersprungen_grund"] = "Digital signiert - keine Modifikation erlaubt" + logger.info(f"OCR übersprungen (signiert): {pfad.name}") + # Trotzdem versuchen Text zu extrahieren ohne einzubetten + ocr_text, _ = self.fuehre_ocr_aus(pdf_pfad, in_place=False) + if ocr_text: + ergebnis["text"] = ocr_text + ergebnis["hat_text"] = True + elif ocr_einbetten: + # Sicher zu modifizieren - OCR einbetten + logger.info(f"Kein Text gefunden, starte OCR mit Einbettung für {pfad.name}") + + # Backup erstellen wenn gewünscht + soll_backup = backup_erstellen if backup_erstellen is not None else bool(self.backup_ordner) + if soll_backup and self.backup_ordner: + backup_pfad = self._erstelle_backup(pdf_pfad) + if backup_pfad: + ergebnis["backup_pfad"] = backup_pfad + logger.info(f"Backup erstellt: {backup_pfad}") + + ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=True) + if ocr_erfolg: + ergebnis["text"] = ocr_text + ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50) + ergebnis["ocr_durchgefuehrt"] = True + else: + # OCR ohne Einbettung (nur Text extrahieren) + ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=False) + if ocr_erfolg: + ergebnis["text"] = ocr_text + ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50) return ergebnis + def _erstelle_backup(self, pdf_pfad: str) -> Optional[str]: + """ + Erstellt ein Backup der Original-PDF vor der OCR-Einbettung. + + Returns: + Pfad zum Backup oder None bei Fehler + """ + import shutil + from datetime import datetime + + if not self.backup_ordner: + return None + + try: + pfad = Path(pdf_pfad) + backup_dir = Path(self.backup_ordner) + backup_dir.mkdir(parents=True, exist_ok=True) + + # Dateiname mit Timestamp für Eindeutigkeit + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = f"{pfad.stem}_original_{timestamp}{pfad.suffix}" + backup_pfad = backup_dir / backup_name + + # Kopieren (nicht verschieben!) + shutil.copy2(pdf_pfad, backup_pfad) + + logger.info(f"Backup erstellt: {backup_pfad}") + return str(backup_pfad) + + except Exception as e: + logger.error(f"Backup-Erstellung fehlgeschlagen: {e}") + return None + + def hat_digitale_signatur(self, pdf_pfad: str) -> bool: + """ + Prüft ob eine PDF eine digitale Signatur enthält. + Signierte PDFs dürfen NICHT verändert werden, da dies die Signatur ungültig macht! + + Returns: + True wenn signiert, False sonst + """ + if not PYPDF_AVAILABLE: + return False + + try: + reader = PdfReader(pdf_pfad) + + # Methode 1: AcroForm mit SigFlags prüfen + if reader.trailer.get("/Root"): + root = reader.trailer["/Root"] + if hasattr(root, "get_object"): + root = root.get_object() + acro_form = root.get("/AcroForm") + if acro_form: + if hasattr(acro_form, "get_object"): + acro_form = acro_form.get_object() + sig_flags = acro_form.get("/SigFlags") + if sig_flags and int(sig_flags) > 0: + logger.info(f"Digitale Signatur gefunden (SigFlags): {Path(pdf_pfad).name}") + return True + + # Methode 2: Nach Signatur-Feldern in Seiten suchen + for page in reader.pages: + if "/Annots" in page: + annots = page["/Annots"] + if hasattr(annots, "get_object"): + annots = annots.get_object() + if annots: + for annot in annots: + if hasattr(annot, "get_object"): + annot = annot.get_object() + if annot.get("/FT") == "/Sig": + logger.info(f"Signatur-Feld gefunden: {Path(pdf_pfad).name}") + return True + + # Methode 3: Nach typischen Signatur-Strings suchen + # (Manche Signaturen sind nicht in AcroForm) + with open(pdf_pfad, 'rb') as f: + content = f.read(50000) # Erste 50KB lesen + if b'/Type /Sig' in content or b'/SubFilter /adbe.pkcs7' in content: + logger.info(f"Signatur-Marker gefunden: {Path(pdf_pfad).name}") + return True + + except Exception as e: + logger.debug(f"Signaturprüfung Fehler: {e}") + + return False + def extrahiere_text(self, pdf_pfad: str) -> Tuple[str, int]: """ Extrahiert Text aus PDF @@ -172,9 +308,14 @@ class PDFProcessor: return ergebnis - def fuehre_ocr_aus(self, pdf_pfad: str) -> Tuple[str, bool]: + def fuehre_ocr_aus(self, pdf_pfad: str, in_place: bool = True) -> Tuple[str, bool]: """ - Führt OCR mit ocrmypdf durch + Führt OCR mit ocrmypdf durch und bettet den Text permanent in die PDF ein. + Danach ist die PDF durchsuchbar und Copy&Paste funktioniert. + + Args: + pdf_pfad: Pfad zur PDF-Datei + in_place: Wenn True, wird die Original-PDF ersetzt (Standard) Returns: Tuple von (text, erfolg) @@ -183,31 +324,39 @@ class PDFProcessor: temp_pfad = pfad.with_suffix(".ocr.pdf") try: - # ocrmypdf ausführen + # ocrmypdf ausführen - Text wird permanent eingebettet 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 + "--deskew", # Schräge Scans korrigieren + "--clean", # Bild verbessern + "--rotate-pages", # Seiten automatisch drehen + "--skip-text", # Seiten mit vorhandenem Text überspringen + "--output-type", "pdfa", # PDF/A für bessere Kompatibilität str(pfad), str(temp_pfad) ], capture_output=True, text=True, - timeout=120 # 2 Minuten Timeout + timeout=180 # 3 Minuten Timeout ) if result.returncode == 0 and temp_pfad.exists(): - # Original mit OCR-Version ersetzen - pfad.unlink() - temp_pfad.rename(pfad) + if in_place: + # Original mit OCR-Version ersetzen + pfad.unlink() + temp_pfad.rename(pfad) + logger.info(f"OCR erfolgreich eingebettet: {pfad.name}") - # Text aus OCR-PDF extrahieren - text, _ = self.extrahiere_text(str(pfad)) - return text, True + # Text aus OCR-PDF extrahieren + text, _ = self.extrahiere_text(str(pfad)) + return text, True + else: + # Nur Text extrahieren, temp löschen + text, _ = self.extrahiere_text(str(temp_pfad)) + temp_pfad.unlink() + return text, True else: logger.error(f"OCR Fehler: {result.stderr}") if temp_pfad.exists(): @@ -228,6 +377,46 @@ class PDFProcessor: temp_pfad.unlink() return "", False + def ocr_einbetten(self, pdf_pfad: str) -> Dict: + """ + Bettet OCR-Text permanent in eine PDF ein (macht sie durchsuchbar). + Kann unabhängig von der Sortierung verwendet werden. + + Returns: + Dict mit: erfolg, text, nachricht + """ + pfad = Path(pdf_pfad) + if not pfad.exists(): + return {"erfolg": False, "nachricht": f"Datei nicht gefunden: {pdf_pfad}"} + + # Prüfen ob bereits Text vorhanden + text, seiten = self.extrahiere_text(pdf_pfad) + if text and len(text.strip()) > 50: + return { + "erfolg": True, + "text": text, + "nachricht": "PDF enthält bereits durchsuchbaren Text", + "ocr_durchgefuehrt": False + } + + # OCR durchführen und einbetten + ocr_text, erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=True) + + if erfolg: + return { + "erfolg": True, + "text": ocr_text, + "nachricht": "OCR erfolgreich eingebettet - PDF ist jetzt durchsuchbar", + "ocr_durchgefuehrt": True + } + else: + return { + "erfolg": False, + "text": "", + "nachricht": "OCR fehlgeschlagen", + "ocr_durchgefuehrt": False + } + def extrahiere_metadaten(self, pdf_pfad: str) -> Dict: """Extrahiert PDF-Metadaten""" metadaten = {} diff --git a/backend/app/modules/sorter.py b/backend/app/modules/sorter.py index d7e7654..b3e9c4d 100644 --- a/backend/app/modules/sorter.py +++ b/backend/app/modules/sorter.py @@ -51,6 +51,70 @@ class Sorter: text = dokument_info.get("text", "").lower() original_name = dokument_info.get("original_name", "").lower() absender = dokument_info.get("absender", "").lower() + dateityp = dokument_info.get("dateityp", "").lower() # z.B. ".pdf", ".jpg" + + # ========== TYP-BASIERTE REGELN (Stufe 1: Grob-Sortierung) ========== + + # dateityp_ist - Nur bestimmte Dateitypen (z.B. [".pdf", ".PDF"]) + if "dateityp_ist" in muster: + erlaubte = muster["dateityp_ist"] + if isinstance(erlaubte, str): + erlaubte = [erlaubte] + erlaubte_lower = [t.lower() for t in erlaubte] + if dateityp not in erlaubte_lower: + return False + + # dateityp_nicht - Ausschluss bestimmter Dateitypen + if "dateityp_nicht" in muster: + verbotene = muster["dateityp_nicht"] + if isinstance(verbotene, str): + verbotene = [verbotene] + verbotene_lower = [t.lower() for t in verbotene] + if dateityp in verbotene_lower: + return False + + # ist_zugferd - Nur ZUGFeRD/E-Rechnungen + if "ist_zugferd" in muster: + ist_zugferd = dokument_info.get("ist_zugferd", False) + if muster["ist_zugferd"] and not ist_zugferd: + return False + if not muster["ist_zugferd"] and ist_zugferd: + return False + + # ist_signiert - Nur signierte PDFs + if "ist_signiert" in muster: + ist_signiert = dokument_info.get("ist_signiert", False) + if muster["ist_signiert"] and not ist_signiert: + return False + if not muster["ist_signiert"] and ist_signiert: + return False + + # hat_text - Nur PDFs mit/ohne Text + if "hat_text" in muster: + hat_text = dokument_info.get("hat_text", False) + if muster["hat_text"] and not hat_text: + return False + if not muster["hat_text"] and hat_text: + return False + + # ist_bild - Prüft ob Datei ein Bild ist + if "ist_bild" in muster: + bild_typen = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"] + ist_bild = dateityp in bild_typen + if muster["ist_bild"] and not ist_bild: + return False + if not muster["ist_bild"] and ist_bild: + return False + + # ist_pdf - Prüft ob Datei ein PDF ist + if "ist_pdf" in muster: + ist_pdf = dateityp == ".pdf" + if muster["ist_pdf"] and not ist_pdf: + return False + if not muster["ist_pdf"] and ist_pdf: + return False + + # ========== INHALT-BASIERTE REGELN (Stufe 2: Fein-Sortierung) ========== # keywords (einfache Komma-getrennte Liste - für UI) if "keywords" in muster: @@ -321,3 +385,147 @@ def liste_dokumenttypen() -> List[Dict]: {"id": key, "name": config["name"], "schema": config["schema"]} for key, config in DOKUMENTTYPEN.items() ] + + +# ============ TYP-BASIERTE STANDARD-REGELN ============ +# Diese Regeln sortieren nach Dateityp/Eigenschaften (Stufe 1: Grob-Sortierung) + +TYP_REGELN = { + "zugferd": { + "name": "E-Rechnungen (ZUGFeRD/XRechnung)", + "beschreibung": "Elektronische Rechnungen mit maschinenlesbaren XML-Daten", + "prioritaet": 5, # Sehr hohe Priorität - vor anderen PDF-Regeln + "muster": { + "ist_pdf": True, + "ist_zugferd": True + }, + "schema": "{original}", # Originalname behalten + "unterordner": "e-rechnungen", + "ist_fallback": False + }, + "signierte_pdfs": { + "name": "Signierte PDFs", + "beschreibung": "Digital signierte PDF-Dokumente (Verträge, Bescheide)", + "prioritaet": 10, + "muster": { + "ist_pdf": True, + "ist_signiert": True + }, + "schema": "{original}", + "unterordner": "signierte_dokumente", + "ist_fallback": False + }, + "bilder": { + "name": "Bilder", + "beschreibung": "Alle Bilddateien (JPG, PNG, TIFF, etc.)", + "prioritaet": 20, + "muster": { + "ist_bild": True + }, + "schema": "{original}", + "unterordner": "bilder", + "ist_fallback": False + }, + "pdfs_ohne_text": { + "name": "Gescannte PDFs (ohne OCR)", + "beschreibung": "PDFs ohne durchsuchbaren Text (Scans)", + "prioritaet": 30, + "muster": { + "ist_pdf": True, + "hat_text": False + }, + "schema": "{original}", + "unterordner": "scans", + "ist_fallback": False + }, + "alle_pdfs": { + "name": "Alle PDFs (Fallback)", + "beschreibung": "Alle PDF-Dokumente die keiner anderen Regel entsprechen", + "prioritaet": 900, # Sehr niedrige Priorität - als Fallback + "muster": { + "ist_pdf": True + }, + "schema": "{original}", + "unterordner": "dokumente", + "ist_fallback": True + }, + "alle_bilder_fallback": { + "name": "Alle Bilder (Fallback)", + "beschreibung": "Alle Bilddateien die keiner anderen Regel entsprechen", + "prioritaet": 910, + "muster": { + "ist_bild": True + }, + "schema": "{original}", + "unterordner": "bilder", + "ist_fallback": True + }, + "alle_dateien_fallback": { + "name": "Alle anderen Dateien (Fallback)", + "beschreibung": "Alle Dateien die keiner Regel entsprechen - letzte Auffang-Regel", + "prioritaet": 999, # Absolut letzte Regel + "muster": {}, # Leeres Muster = passt auf alles + "schema": "{original}", + "unterordner": "sonstiges", + "ist_fallback": True + } +} + + +def liste_typ_regeln(nur_fallback: bool = None) -> List[Dict]: + """ + Gibt Liste aller Typ-basierten Regeln für UI zurück + + Args: + nur_fallback: None = alle, True = nur Fallbacks, False = keine Fallbacks + """ + result = [] + for key, config in TYP_REGELN.items(): + ist_fallback = config.get("ist_fallback", False) + + # Filtern nach Fallback-Status + if nur_fallback is True and not ist_fallback: + continue + if nur_fallback is False and ist_fallback: + continue + + result.append({ + "id": key, + "name": config["name"], + "beschreibung": config["beschreibung"], + "prioritaet": config["prioritaet"], + "muster": config["muster"], + "unterordner": config["unterordner"], + "ist_fallback": ist_fallback + }) + + # Nach Priorität sortieren + return sorted(result, key=lambda x: x["prioritaet"]) + + +def erstelle_typ_regel(typ_id: str, unterordner: str = None, prioritaet: int = None) -> Dict: + """ + Erstellt eine Typ-basierte Regel + + Args: + typ_id: ID aus TYP_REGELN (z.B. "zugferd", "bilder") + unterordner: Optionaler Unterordner (überschreibt Standard) + prioritaet: Optionale Priorität (überschreibt Standard) + + Returns: + Regel-Dict für die Datenbank + """ + if typ_id not in TYP_REGELN: + raise ValueError(f"Unbekannter Typ: {typ_id}") + + typ_config = TYP_REGELN[typ_id] + + return { + "name": typ_config["name"], + "prioritaet": prioritaet or typ_config["prioritaet"], + "aktiv": True, + "muster": typ_config["muster"].copy(), + "extraktion": {}, + "schema": typ_config["schema"], + "unterordner": unterordner or typ_config["unterordner"] + } diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index cffea4c..2f00d6f 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,7 +1,7 @@ """ API Routes - Getrennte Bereiche: Mail-Abruf und Datei-Sortierung """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import List, Optional @@ -12,7 +12,7 @@ import json import asyncio from ..models.database import get_db, Postfach, QuellOrdner, SortierRegel, VerarbeiteteDatei, VerarbeiteteMail -from ..modules.mail_fetcher import MailFetcher +from ..modules.mail_fetcher import MailFetcher, abruf_manager from ..modules.pdf_processor import PDFProcessor from ..modules.sorter import Sorter @@ -30,6 +30,7 @@ class PostfachCreate(BaseModel): ordner: str = "INBOX" alle_ordner: bool = False # Alle IMAP-Ordner durchsuchen nur_ungelesen: bool = False # Nur ungelesene Mails (False = alle) + ab_datum: Optional[datetime] = None # Nur Mails ab diesem Datum ziel_ordner: str erlaubte_typen: List[str] = [".pdf"] max_groesse_mb: int = 25 @@ -43,6 +44,7 @@ class PostfachResponse(BaseModel): ordner: str alle_ordner: bool nur_ungelesen: bool + ab_datum: Optional[datetime] ziel_ordner: str erlaubte_typen: List[str] max_groesse_mb: int @@ -124,21 +126,32 @@ def browse_directory(path: str = "/"): if not os.path.isdir(path): return {"error": "Kein Verzeichnis", "entries": []} + # Berechtigungen des aktuellen Verzeichnisses prüfen + current_readable = os.access(path, os.R_OK) + current_writable = os.access(path, os.W_OK) + try: entries = [] for entry in sorted(os.listdir(path)): full_path = os.path.join(path, entry) if os.path.isdir(full_path): + # Berechtigungen für jeden Unterordner prüfen + readable = os.access(full_path, os.R_OK) + writable = os.access(full_path, os.W_OK) entries.append({ "name": entry, "path": full_path, - "type": "directory" + "type": "directory", + "readable": readable, + "writable": writable }) return { "current": path, "parent": os.path.dirname(path) if path != "/" else None, - "entries": entries + "entries": entries, + "readable": current_readable, + "writable": current_writable } except PermissionError: return {"error": "Zugriff verweigert", "entries": []} @@ -212,6 +225,10 @@ def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): if not postfach: raise HTTPException(status_code=404, detail="Nicht gefunden") + # Prüfen ob bereits ein Abruf läuft + if not abruf_manager.starten(id): + raise HTTPException(status_code=409, detail="Ein Abruf läuft bereits für dieses Postfach") + # Daten kopieren für Generator (Session ist nach return nicht mehr verfügbar) pf_data = { "id": postfach.id, @@ -224,7 +241,8 @@ def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): "alle_ordner": postfach.alle_ordner, "erlaubte_typen": postfach.erlaubte_typen, "max_groesse_mb": postfach.max_groesse_mb, - "ziel_ordner": postfach.ziel_ordner + "ziel_ordner": postfach.ziel_ordner, + "ab_datum": postfach.ab_datum } # Bereits verarbeitete Message-IDs laden @@ -258,19 +276,24 @@ def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): }) attachments = [] + abgebrochen = False try: - # Generator für streaming + # Generator für streaming mit Abbruch-Callback for event in fetcher.fetch_attachments_generator( ziel, nur_ungelesen=False, alle_ordner=pf_data["alle_ordner"], - bereits_verarbeitet=bereits_verarbeitet + bereits_verarbeitet=bereits_verarbeitet, + abbruch_callback=lambda: abruf_manager.soll_abbrechen(pf_data["id"]), + ab_datum=pf_data["ab_datum"] ): yield send_event(event) if event.get("type") == "datei": attachments.append(event) + elif event.get("type") == "abgebrochen": + abgebrochen = True # DB-Session für Speicherung session = SessionLocal() @@ -298,12 +321,16 @@ def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): finally: session.close() - yield send_event({"type": "fertig", "anzahl": len(attachments)}) + if abgebrochen: + yield send_event({"type": "fertig", "anzahl": len(attachments), "abgebrochen": True}) + else: + yield send_event({"type": "fertig", "anzahl": len(attachments)}) except Exception as e: yield send_event({"type": "fehler", "nachricht": str(e)}) finally: fetcher.disconnect() + abruf_manager.beenden(pf_data["id"]) return StreamingResponse( event_generator(), @@ -316,6 +343,30 @@ def rufe_postfach_ab_stream(id: int, db: Session = Depends(get_db)): ) +@router.post("/postfaecher/{id}/abrufen/stoppen") +def stoppe_postfach_abruf(id: int): + """Stoppt einen laufenden Mail-Abruf""" + if abruf_manager.stoppen(id): + return {"erfolg": True, "nachricht": "Abruf wird gestoppt"} + return {"erfolg": False, "nachricht": "Kein aktiver Abruf für dieses Postfach"} + + +@router.get("/postfaecher/status") +def postfach_status(): + """Gibt Status aller laufenden Abrufe zurück""" + aktive = abruf_manager.alle_aktiven() + return { + "aktive_abrufe": [ + { + "postfach_id": pid, + "gestartet": info["gestartet"].isoformat(), + "wird_abgebrochen": info["abbrechen"] + } + for pid, info in aktive.items() + ] + } + + @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() @@ -515,6 +566,39 @@ def loesche_regel(id: int, db: Session = Depends(get_db)): return {"message": "Gelöscht"} +class PrioritaetUpdate(BaseModel): + prioritaet: int + + +@router.put("/regeln/{id}/prioritaet") +def aendere_prioritaet(id: int, data: PrioritaetUpdate, db: Session = Depends(get_db)): + """Ändert die Priorität einer Regel""" + regel = db.query(SortierRegel).filter(SortierRegel.id == id).first() + if not regel: + raise HTTPException(status_code=404, detail="Nicht gefunden") + regel.prioritaet = data.prioritaet + db.commit() + return {"id": regel.id, "prioritaet": regel.prioritaet} + + +class RegelReihenfolge(BaseModel): + reihenfolge: List[int] # Liste von Regel-IDs in gewünschter Reihenfolge + + +@router.post("/regeln/reihenfolge") +def setze_reihenfolge(data: RegelReihenfolge, db: Session = Depends(get_db)): + """ + Setzt die Reihenfolge aller Regeln basierend auf der übergebenen ID-Liste. + Prioritäten werden automatisch vergeben (10, 20, 30, ...) + """ + for index, regel_id in enumerate(data.reihenfolge): + regel = db.query(SortierRegel).filter(SortierRegel.id == regel_id).first() + if regel: + regel.prioritaet = (index + 1) * 10 + db.commit() + return {"erfolg": True, "nachricht": f"{len(data.reihenfolge)} Regeln neu sortiert"} + + @router.post("/regeln/test") def teste_regel(data: RegelTestRequest): regel = data.regel @@ -553,6 +637,291 @@ def sammle_dateien(ordner: QuellOrdner) -> list: return dateien +# Sortierungs-Manager für Abbruch +class SortierungsManager: + """Verwaltet laufende Sortierungen""" + + def __init__(self): + self._aktiv = False + self._abbrechen = False + self._lock = threading.Lock() + + def starten(self) -> bool: + with self._lock: + if self._aktiv: + return False + self._aktiv = True + self._abbrechen = False + return True + + def stoppen(self): + with self._lock: + self._abbrechen = True + + def beenden(self): + with self._lock: + self._aktiv = False + self._abbrechen = False + + def soll_abbrechen(self) -> bool: + with self._lock: + return self._abbrechen + + def ist_aktiv(self) -> bool: + with self._lock: + return self._aktiv + + +import threading +sortierungs_manager = SortierungsManager() + + +@router.get("/sortierung/stream") +def starte_sortierung_stream( + db: Session = Depends(get_db), + testmodus: bool = False, + ocr_backup_ordner: str = None +): + """ + Streaming-Endpoint für Sortierung mit Live-Updates + + Args: + testmodus: Wenn True, werden Dateien nur analysiert aber NICHT verschoben. + Perfekt zum Testen von Regeln ohne Dateien zu bewegen. + ocr_backup_ordner: Optionaler Pfad für Backups von PDFs vor OCR-Einbettung. + Wenn gesetzt, werden Originale vor OCR dorthin kopiert. + """ + 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: + raise HTTPException(status_code=400, detail="Keine Quell-Ordner konfiguriert") + if not regeln: + raise HTTPException(status_code=400, detail="Keine Regeln definiert") + + if not sortierungs_manager.starten(): + raise HTTPException(status_code=409, detail="Eine Sortierung läuft bereits") + + # Daten kopieren für Generator + ordner_data = [ + {"id": o.id, "name": o.name, "pfad": o.pfad, "ziel_ordner": o.ziel_ordner, + "rekursiv": o.rekursiv, "dateitypen": o.dateitypen} + for o in ordner_liste + ] + 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 + ] + + # Flags für Generator + ist_testmodus = testmodus + backup_ordner = ocr_backup_ordner + + def event_generator(): + from ..models.database import SessionLocal + + def send_event(data): + return f"data: {json.dumps(data)}\n\n" + + sorter = Sorter(regeln_dicts) + # PDF Processor mit optionalem Backup-Ordner + pdf_processor = PDFProcessor(backup_ordner=backup_ordner) + + stats = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0} + + # Start-Event mit Testmodus-Info + yield send_event({ + "type": "start", + "ordner": len(ordner_data), + "regeln": len(regeln_dicts), + "testmodus": ist_testmodus + }) + + try: + for quell_ordner in ordner_data: + if sortierungs_manager.soll_abbrechen(): + yield send_event({"type": "abgebrochen"}) + break + + pfad = Path(quell_ordner["pfad"]) + if not pfad.exists(): + yield send_event({"type": "warnung", "nachricht": f"Ordner existiert nicht: {quell_ordner['pfad']}"}) + continue + + yield send_event({"type": "ordner", "name": quell_ordner["name"], "pfad": quell_ordner["pfad"]}) + + ziel_basis = Path(quell_ordner["ziel_ordner"]) + + # Dateien sammeln + dateien = [] + pattern = "**/*" if quell_ordner["rekursiv"] else "*" + erlaubte = [t.lower() for t in (quell_ordner["dateitypen"] or [".pdf"])] + for f in pfad.glob(pattern): + if f.is_file() and f.suffix.lower() in erlaubte: + dateien.append(f) + + yield send_event({"type": "dateien_gefunden", "anzahl": len(dateien)}) + + for datei in dateien: + if sortierungs_manager.soll_abbrechen(): + yield send_event({"type": "abgebrochen"}) + break + + stats["gesamt"] += 1 + + try: + rel_pfad = str(datei.relative_to(pfad)) + except: + rel_pfad = datei.name + + try: + ist_pdf = datei.suffix.lower() == ".pdf" + text = "" + ist_zugferd = False + ist_signiert = False + hat_text = False + ocr_gemacht = False + + if ist_pdf: + # Im Testmodus: OCR nur extrahieren, NICHT einbetten + if ist_testmodus: + # Text extrahieren ohne PDF zu verändern + text_result, seiten = pdf_processor.extrahiere_text(str(datei)) + text = text_result + hat_text = bool(text and len(text.strip()) > 50) + # ZUGFeRD prüfen + zugferd_result = pdf_processor.pruefe_zugferd(str(datei)) + ist_zugferd = zugferd_result["ist_zugferd"] + # Signatur prüfen + ist_signiert = pdf_processor.hat_digitale_signatur(str(datei)) + else: + # Normale Verarbeitung mit OCR-Einbettung + 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) + ist_signiert = pdf_result.get("ist_signiert", False) + hat_text = pdf_result.get("hat_text", False) + ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False) + + # Dokument-Info für Regel-Matching inkl. aller Eigenschaften + doc_info = { + "text": text, + "original_name": datei.name, + "absender": "", + "dateityp": datei.suffix.lower(), + # PDF-Eigenschaften für Typ-basierte Regeln + "ist_zugferd": ist_zugferd, + "ist_signiert": ist_signiert, + "hat_text": hat_text, + "ist_pdf": ist_pdf, + "ist_bild": datei.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"] + } + + regel = sorter.finde_passende_regel(doc_info) + + if not regel: + stats["fehler"] += 1 + yield send_event({ + "type": "datei", + "original": rel_pfad, + "status": "keine_regel", + "testmodus": ist_testmodus + }) + continue + + 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"] + + if ist_testmodus: + # Testmodus: Nur simulieren, nicht verschieben + stats["sortiert"] += 1 + yield send_event({ + "type": "datei", + "original": rel_pfad, + "neuer_name": neuer_name, + "ziel_ordner": str(ziel), + "status": "sortiert", + "regel": regel.get("name"), + "extrahiert": extrahiert, + "testmodus": True + }) + else: + # Echte Sortierung + ziel.mkdir(parents=True, exist_ok=True) + neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name) + stats["sortiert"] += 1 + + # DB speichern + session = SessionLocal() + try: + session.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 + )) + session.commit() + finally: + session.close() + + yield send_event({ + "type": "datei", + "original": rel_pfad, + "neuer_name": neuer_name, + "ziel_ordner": str(ziel), + "status": "sortiert", + "regel": regel.get("name"), + "extrahiert": extrahiert, + "testmodus": False + }) + + except Exception as e: + stats["fehler"] += 1 + yield send_event({ + "type": "datei", + "original": rel_pfad, + "status": "fehler", + "fehler": str(e), + "testmodus": ist_testmodus + }) + + yield send_event({"type": "fertig", "testmodus": ist_testmodus, **stats}) + + finally: + sortierungs_manager.beenden() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"} + ) + + +@router.post("/sortierung/stoppen") +def stoppe_sortierung(): + """Stoppt die laufende Sortierung""" + if sortierungs_manager.ist_aktiv(): + sortierungs_manager.stoppen() + return {"erfolg": True, "nachricht": "Sortierung wird gestoppt"} + return {"erfolg": False, "nachricht": "Keine aktive Sortierung"} + + +@router.get("/sortierung/status") +def sortierung_status(): + """Gibt Status der Sortierung zurück""" + return {"aktiv": sortierungs_manager.ist_aktiv()} + + @router.post("/sortierung/starten") def starte_sortierung(db: Session = Depends(get_db)): ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all() @@ -713,6 +1082,47 @@ def health(): return {"status": "ok"} +# ============ Datenbank-Management ============ + +@router.post("/db/reset") +def reset_database(db: Session = Depends(get_db)): + """Setzt die Datenbank zurück (löscht alle verarbeiteten Mails/Dateien)""" + try: + # Verarbeitete Mails löschen + mails_count = db.query(VerarbeiteteMail).count() + db.query(VerarbeiteteMail).delete() + + # Verarbeitete Dateien löschen + dateien_count = db.query(VerarbeiteteDatei).count() + db.query(VerarbeiteteDatei).delete() + + db.commit() + + return { + "erfolg": True, + "nachricht": f"Datenbank zurückgesetzt", + "geloescht": { + "mails": mails_count, + "dateien": dateien_count + } + } + except Exception as e: + db.rollback() + return {"erfolg": False, "nachricht": str(e)} + + +@router.get("/db/statistik") +def db_statistik(db: Session = Depends(get_db)): + """Gibt Statistiken über die Datenbank zurück""" + return { + "postfaecher": db.query(Postfach).count(), + "quell_ordner": db.query(QuellOrdner).count(), + "regeln": db.query(SortierRegel).count(), + "verarbeitete_mails": db.query(VerarbeiteteMail).count(), + "verarbeitete_dateien": db.query(VerarbeiteteDatei).count() + } + + # ============ Einfache Regeln (UI-freundlich) ============ @router.get("/dokumenttypen") @@ -725,6 +1135,52 @@ def liste_dokumenttypen(): ] +@router.get("/typ-regeln") +def liste_typ_regeln_api(nur_fallback: bool = None): + """ + Gibt alle verfügbaren Typ-basierten Regeln für das UI zurück + + Args: + nur_fallback: None = alle, true = nur Fallbacks, false = keine Fallbacks + """ + from ..modules.sorter import liste_typ_regeln + return liste_typ_regeln(nur_fallback=nur_fallback) + + +class TypRegelCreate(BaseModel): + typ_id: str # z.B. "zugferd", "bilder", "signierte_pdfs" + unterordner: Optional[str] = None + prioritaet: Optional[int] = None + + +@router.post("/regeln/typ") +def erstelle_typ_regel_api(data: TypRegelCreate, db: Session = Depends(get_db)): + """Erstellt eine Typ-basierte Regel (Grob-Sortierung nach Dateityp/Eigenschaften)""" + from ..modules.sorter import erstelle_typ_regel + + try: + regel_dict = erstelle_typ_regel( + data.typ_id, + unterordner=data.unterordner, + prioritaet=data.prioritaet + ) + + regel = SortierRegel(**regel_dict) + db.add(regel) + db.commit() + db.refresh(regel) + + return { + "id": regel.id, + "name": regel.name, + "typ_id": data.typ_id, + "prioritaet": regel.prioritaet, + "unterordner": regel.unterordner + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + class EinfacheRegelCreate(BaseModel): name: str dokumenttyp: str # z.B. "rechnung", "vertrag" @@ -777,6 +1233,14 @@ class ExtraktionTestRequest(BaseModel): dateiname: Optional[str] = "test.pdf" +class CustomExtraktionRequest(BaseModel): + text: str + firma: Optional[str] = None + datum_regex: Optional[str] = None + betrag_regex: Optional[str] = None + nummer_regex: Optional[str] = None + + @router.post("/extraktion/test") def teste_extraktion(data: ExtraktionTestRequest): """Testet die automatische Extraktion auf einem Text""" @@ -802,6 +1266,173 @@ def teste_extraktion(data: ExtraktionTestRequest): } +@router.post("/extraktion/test-custom") +def teste_custom_extraktion(data: CustomExtraktionRequest): + """Testet Extraktion mit benutzerdefinierten Regex-Mustern""" + import re + from ..modules.extraktoren import _parse_betrag + + felder = {} + fehler = [] + + # Firma (fester Wert) + if data.firma: + felder["firma"] = data.firma + + # Datum mit Custom-Regex + if data.datum_regex: + try: + match = re.search(data.datum_regex, data.text, re.IGNORECASE) + if match: + datum_str = match.group(1) + # Versuche verschiedene Formate zu parsen + for fmt in ["%d.%m.%Y", "%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y"]: + try: + from datetime import datetime + dt = datetime.strptime(datum_str, fmt) + felder["datum"] = dt.strftime("%Y-%m-%d") + break + except: + continue + if "datum" not in felder: + felder["datum"] = datum_str # Rohwert wenn Parsing fehlschlägt + else: + fehler.append(f"Datum-Regex matched nicht") + except Exception as e: + fehler.append(f"Datum-Regex Fehler: {str(e)}") + else: + # Fallback auf automatische Extraktion + from ..modules.extraktoren import extrahiere_datum + datum = extrahiere_datum(data.text) + if datum: + felder["datum"] = datum + + # Betrag mit Custom-Regex + if data.betrag_regex: + try: + match = re.search(data.betrag_regex, data.text, re.IGNORECASE) + if match: + betrag_str = match.group(1) + betrag = _parse_betrag(betrag_str) + if betrag is not None: + if betrag == int(betrag): + felder["betrag"] = str(int(betrag)) + else: + felder["betrag"] = f"{betrag:.2f}".replace(".", ",") + else: + felder["betrag"] = betrag_str + else: + fehler.append(f"Betrag-Regex matched nicht") + except Exception as e: + fehler.append(f"Betrag-Regex Fehler: {str(e)}") + else: + from ..modules.extraktoren import extrahiere_betrag + betrag = extrahiere_betrag(data.text) + if betrag: + felder["betrag"] = betrag + + # Nummer mit Custom-Regex + if data.nummer_regex: + try: + match = re.search(data.nummer_regex, data.text, re.IGNORECASE) + if match: + felder["nummer"] = match.group(1) + else: + fehler.append(f"Nummer-Regex matched nicht") + except Exception as e: + fehler.append(f"Nummer-Regex Fehler: {str(e)}") + else: + from ..modules.extraktoren import extrahiere_nummer + nummer = extrahiere_nummer(data.text) + if nummer: + felder["nummer"] = nummer + + # Dokumenttyp automatisch + from ..modules.extraktoren import extrahiere_dokumenttyp + typ = extrahiere_dokumenttyp(data.text) + if typ: + felder["typ"] = typ + + return { + "extrahiert": felder, + "fehler": "; ".join(fehler) if fehler else None + } + + +class DateiExtraktionRequest(BaseModel): + pfad: str + + +@router.post("/extraktion/upload-pdf") +async def upload_pdf_extraktion(file: UploadFile = File(...)): + """Extrahiert Text aus einer hochgeladenen PDF-Datei""" + import tempfile + import os + + if not file.filename.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="Nur PDF-Dateien erlaubt") + + # Temporäre Datei erstellen + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + pdf_processor = PDFProcessor() + result = pdf_processor.verarbeite(tmp_path) + + if result.get("fehler"): + raise HTTPException(status_code=500, detail=result["fehler"]) + + return { + "text": result.get("text", ""), + "ocr_durchgefuehrt": result.get("ocr_durchgefuehrt", False), + "ist_zugferd": result.get("ist_zugferd", False), + "dateiname": file.filename + } + finally: + # Temporäre Datei löschen + try: + os.unlink(tmp_path) + except: + pass + + +@router.post("/extraktion/from-file") +def extraktion_from_file(data: DateiExtraktionRequest): + """Extrahiert Text aus einer Datei auf dem Server""" + import os + + pfad = data.pfad + + # Sicherheitsprüfung + pfad = os.path.abspath(pfad) + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + raise HTTPException(status_code=403, detail="Pfad nicht erlaubt") + + if not os.path.exists(pfad): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + if not pfad.lower().endswith('.pdf'): + raise HTTPException(status_code=400, detail="Nur PDF-Dateien werden unterstützt") + + pdf_processor = PDFProcessor() + result = pdf_processor.verarbeite(pfad) + + if result.get("fehler"): + raise HTTPException(status_code=500, detail=result["fehler"]) + + return { + "text": result.get("text", ""), + "ocr_durchgefuehrt": result.get("ocr_durchgefuehrt", False), + "ist_zugferd": result.get("ist_zugferd", False), + "pfad": pfad + } + + @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""" @@ -849,3 +1480,295 @@ def regel_vorschau(id: int, data: ExtraktionTestRequest, db: Session = Depends(g "dateiname": dateiname, "unterordner": passende_regel.get("unterordner") } + + +# ============ Dateimanager / File Operations ============ + +@router.get("/browse/files") +def browse_files(path: str = "/"): + """Listet Ordner und Dateien für den Dateimanager""" + 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", "folders": [], "files": []} + + if not os.path.exists(path): + return {"error": "Pfad existiert nicht", "folders": [], "files": []} + + if not os.path.isdir(path): + return {"error": "Kein Verzeichnis", "folders": [], "files": []} + + def natuerliche_sortierung(name): + """ + Sortiert natürlich: Sonderzeichen zuerst, dann Zahlen numerisch, dann Buchstaben. + Beispiel: !datei, #datei, 1.pdf, 2.pdf, 10.pdf, a.pdf, z.pdf + """ + import re + # Prüfe erstes Zeichen + first_char = name[0] if name else '' + + # Kategorie: 0 = Sonderzeichen, 1 = Zahlen, 2 = Buchstaben + if first_char.isdigit(): + kategorie = 1 + elif first_char.isalpha(): + kategorie = 2 + else: + kategorie = 0 # Sonderzeichen zuerst + + # Für natürliche Sortierung: Zahlen als Integers behandeln + teile = re.split(r'(\d+)', name.lower()) + sortier_key = [] + for teil in teile: + if teil.isdigit(): + sortier_key.append((0, int(teil))) # Zahlen numerisch + else: + sortier_key.append((1, teil)) # Text alphabetisch + + return (kategorie, sortier_key) + + try: + folders = [] + files = [] + + for entry in os.listdir(path): + full_path = os.path.join(path, entry) + try: + if os.path.isdir(full_path): + folders.append({ + "name": entry, + "path": full_path + }) + else: + stat = os.stat(full_path) + files.append({ + "name": entry, + "path": full_path, + "size": stat.st_size, + "modified": stat.st_mtime + }) + except (OSError, PermissionError): + continue + + # Sortierung: Sonderzeichen -> Zahlen -> Buchstaben + folders.sort(key=lambda x: natuerliche_sortierung(x["name"])) + files.sort(key=lambda x: natuerliche_sortierung(x["name"])) + + return { + "current": path, + "parent": os.path.dirname(path) if path != "/" else None, + "folders": folders, + "files": files + } + except PermissionError: + return {"error": "Zugriff verweigert", "folders": [], "files": []} + + +class FileRenameRequest(BaseModel): + pfad: str + neuer_name: str + + +@router.post("/file/rename") +def rename_file(data: FileRenameRequest): + """Benennt eine Datei um""" + import os + import shutil + + pfad = os.path.abspath(data.pfad) + neuer_name = data.neuer_name + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + return {"erfolg": False, "fehler": "Pfad nicht erlaubt"} + + if not os.path.exists(pfad): + return {"erfolg": False, "fehler": "Datei nicht gefunden"} + + # Ungültige Zeichen prüfen + if '/' in neuer_name or '\\' in neuer_name or '\0' in neuer_name: + return {"erfolg": False, "fehler": "Ungültiger Dateiname"} + + ordner = os.path.dirname(pfad) + neuer_pfad = os.path.join(ordner, neuer_name) + + if os.path.exists(neuer_pfad): + return {"erfolg": False, "fehler": "Eine Datei mit diesem Namen existiert bereits"} + + try: + shutil.move(pfad, neuer_pfad) + return {"erfolg": True, "neuer_pfad": neuer_pfad} + except Exception as e: + return {"erfolg": False, "fehler": str(e)} + + +class FileMoveRequest(BaseModel): + pfad: str + ziel_ordner: str + + +@router.post("/file/move") +def move_file(data: FileMoveRequest): + """Verschiebt eine Datei in einen anderen Ordner""" + import os + import shutil + + pfad = os.path.abspath(data.pfad) + ziel_ordner = os.path.abspath(data.ziel_ordner) + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed_source = any(pfad.startswith(base) for base in allowed_bases) + is_allowed_target = any(ziel_ordner.startswith(base) for base in allowed_bases) + + if not is_allowed_source or not is_allowed_target: + return {"erfolg": False, "fehler": "Pfad nicht erlaubt"} + + if not os.path.exists(pfad): + return {"erfolg": False, "fehler": "Datei nicht gefunden"} + + if not os.path.isdir(ziel_ordner): + return {"erfolg": False, "fehler": "Zielordner existiert nicht"} + + dateiname = os.path.basename(pfad) + neuer_pfad = os.path.join(ziel_ordner, dateiname) + + if os.path.exists(neuer_pfad): + return {"erfolg": False, "fehler": "Eine Datei mit diesem Namen existiert bereits im Zielordner"} + + try: + shutil.move(pfad, neuer_pfad) + return {"erfolg": True, "neuer_pfad": neuer_pfad} + except Exception as e: + return {"erfolg": False, "fehler": str(e)} + + +class FileDeleteRequest(BaseModel): + pfad: str + + +@router.delete("/file/delete") +def delete_file(data: FileDeleteRequest): + """Löscht eine Datei""" + import os + + pfad = os.path.abspath(data.pfad) + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + return {"erfolg": False, "fehler": "Pfad nicht erlaubt"} + + if not os.path.exists(pfad): + return {"erfolg": False, "fehler": "Datei nicht gefunden"} + + if os.path.isdir(pfad): + return {"erfolg": False, "fehler": "Ordner können nicht gelöscht werden (nur Dateien)"} + + try: + os.remove(pfad) + return {"erfolg": True} + except Exception as e: + return {"erfolg": False, "fehler": str(e)} + + +@router.get("/file/preview") +def preview_file(path: str): + """Liefert eine Datei für die Vorschau (ohne Sperrung)""" + from fastapi.responses import FileResponse, Response + import os + import mimetypes + + pfad = os.path.abspath(path) + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + raise HTTPException(status_code=403, detail="Pfad nicht erlaubt") + + if not os.path.exists(pfad): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + # Mimetype bestimmen + mime_type, _ = mimetypes.guess_type(pfad) + if not mime_type: + mime_type = "application/octet-stream" + + # Datei als Kopie lesen um Sperrung zu vermeiden + try: + with open(pfad, 'rb') as f: + content = f.read() + + return Response( + content=content, + media_type=mime_type, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/file/text") +def get_file_text(path: str): + """Liefert den Textinhalt einer Datei""" + import os + + pfad = os.path.abspath(path) + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + raise HTTPException(status_code=403, detail="Pfad nicht erlaubt") + + if not os.path.exists(pfad): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + try: + # Max 1MB lesen + max_size = 1024 * 1024 + with open(pfad, 'r', encoding='utf-8', errors='replace') as f: + content = f.read(max_size) + + return {"content": content} + except Exception as e: + return {"content": None, "error": str(e)} + + +@router.get("/file/download") +def download_file(path: str): + """Datei zum Download""" + from fastapi.responses import FileResponse + import os + + pfad = os.path.abspath(path) + + # Sicherheit + allowed_bases = ["/srv", "/home", "/mnt", "/media", "/data", "/tmp"] + is_allowed = any(pfad.startswith(base) for base in allowed_bases) + if not is_allowed: + raise HTTPException(status_code=403, detail="Pfad nicht erlaubt") + + if not os.path.exists(pfad): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + dateiname = os.path.basename(pfad) + + return FileResponse( + path=pfad, + filename=dateiname, + media_type="application/octet-stream" + ) diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml new file mode 100644 index 0000000..ba16e6a --- /dev/null +++ b/docker-compose.portainer.yml @@ -0,0 +1,81 @@ +# ============================================== +# Dateiverwaltung - Docker Compose für Portainer +# ============================================== +# +# Diese Datei ist für die Verwendung mit Portainer optimiert. +# Alle Pfade werden über Umgebungsvariablen konfiguriert. +# +# Verwendung in Portainer: +# 1. Stacks → Add Stack +# 2. Diese Datei einfügen +# 3. Environment variables unten konfigurieren +# 4. Deploy +# ============================================== + +services: + dateiverwaltung: + build: + context: . + dockerfile: Dockerfile + image: dateiverwaltung:latest + container_name: dateiverwaltung + restart: unless-stopped + + ports: + - "${PORT:-8000}:8000" + + volumes: + # Persistente Daten (Datenbank mit allen Einstellungen) + # WICHTIG: Dieser Pfad muss persistent sein! + - ${DATA_PATH:-./data}:/app/data + + # Quell-Ordner: Hier liegen unsortierte Dateien + - ${INBOX_PATH:-/mnt/inbox}:/mnt/inbox + + # Ziel-Ordner: Hierhin werden sortierte Dateien verschoben + - ${ARCHIV_PATH:-/mnt/archiv}:/mnt/archiv + + # Backup-Ordner: Original-PDFs vor OCR + - ${BACKUP_PATH:-/mnt/backup}:/mnt/backup + + # Zusätzliche Ordner (optional, auskommentieren wenn benötigt) + # - ${EXTRA_PATH_1:-/mnt/extra1}:/mnt/extra1 + # - ${EXTRA_PATH_2:-/mnt/extra2}:/mnt/extra2 + + environment: + - TZ=${TIMEZONE:-Europe/Berlin} + - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db + - OCR_LANGUAGE=${OCR_LANGUAGE:-deu} + - OCR_DPI=${OCR_DPI:-300} + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Optional: Ressourcen-Limits + # deploy: + # resources: + # limits: + # memory: 2G + # reservations: + # memory: 512M + +# ============================================== +# PORTAINER ENVIRONMENT VARIABLES +# ============================================== +# Füge diese in Portainer unter "Environment variables" hinzu: +# +# | Name | Wert | Beschreibung | +# |---------------|-----------------------------------|---------------------------------| +# | PORT | 8000 | Web-UI Port | +# | TIMEZONE | Europe/Berlin | Zeitzone | +# | DATA_PATH | /opt/dateiverwaltung/data | Datenbank-Pfad (persistent!) | +# | INBOX_PATH | /home/user/Dokumente/Inbox | Quell-Ordner | +# | ARCHIV_PATH | /home/user/Dokumente/Archiv | Ziel-Ordner | +# | BACKUP_PATH | /home/user/Dokumente/Backup | OCR-Backup Ordner | +# | OCR_LANGUAGE | deu | OCR Sprache (deu, eng, deu+eng) | +# | OCR_DPI | 300 | OCR Auflösung | +# ============================================== diff --git a/docker-compose.yml b/docker-compose.yml index 60de501..73896c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,13 @@ services: - ./data:/app/data # Regeln können außerhalb bearbeitet werden - ./regeln:/app/regeln - # Archiv auf Host mounten (optional, für direkten Zugriff) - # - /mnt/user/archiv:/archiv + # Host /mnt einbinden für Zugriff auf Dateien + - /mnt:/mnt + # Dev: Source code einbinden + - ./backend:/app/backend + - ./frontend:/app/frontend + # Zugriff auf /srv für Dateimanager + - /srv:/srv environment: - TZ=Europe/Berlin - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db diff --git a/frontend/static/css/browser.css b/frontend/static/css/browser.css new file mode 100644 index 0000000..2acaf25 --- /dev/null +++ b/frontend/static/css/browser.css @@ -0,0 +1,608 @@ +/* ============ Dateimanager / Browser Styles ============ */ + +/* Browser App Layout */ +.browser-app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.browser-app .header { + flex-shrink: 0; +} + +.back-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + margin-right: 1rem; +} + +.back-link:hover { + color: var(--primary); +} + +/* ============ Toolbar ============ */ +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.toolbar-left { + display: flex; + gap: 0.25rem; +} + +.toolbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.file-count { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* ============ Breadcrumb ============ */ +.breadcrumb-bar { + flex: 1; + overflow-x: auto; + white-space: nowrap; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8rem; + font-family: monospace; +} + +.breadcrumb-item { + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.breadcrumb-item:hover { + background: var(--bg); + color: var(--primary); +} + +.breadcrumb-separator { + color: var(--text-secondary); +} + +.breadcrumb-current { + color: var(--text); + font-weight: 500; +} + +/* ============ Browser Main (3-Panel Layout) ============ */ +.browser-main { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* Vertikaler Modus */ +.browser-main.vertical { + flex-direction: column; +} + +.pane { + display: flex; + flex-direction: column; + background: var(--bg); + overflow: hidden; +} + +/* Panel ausgeblendet */ +.pane.hidden-panel { + display: none !important; +} + +.pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + font-weight: 500; + flex-shrink: 0; +} + +.pane-toolbar { + display: flex; + gap: 0.25rem; +} + +/* Panel 1: Ordner-Baum */ +.pane-tree { + width: 220px; + min-width: 150px; + max-width: 400px; + border-right: 1px solid var(--border); +} + +/* Panel 2: Dateiliste */ +.pane-list { + width: 300px; + min-width: 200px; + max-width: 600px; + border-right: 1px solid var(--border); +} + +/* Wenn Preview ausgeblendet: Dateiliste expandiert */ +.pane-list.expanded { + flex: 1; + max-width: none; + border-right: none; +} + +/* Panel 3: Vorschau */ +.pane-preview { + flex: 1; + min-width: 300px; +} + +/* ============ Resize Handles ============ */ +.resize-handle { + flex-shrink: 0; + background: transparent; + position: relative; + z-index: 10; +} + +/* Horizontaler Modus: Vertikale Handles (Breite ändern) */ +.browser-main:not(.vertical) .resize-handle { + width: 6px; + cursor: col-resize; +} + +/* Vertikaler Modus: Horizontale Handles (Höhe ändern) */ +.browser-main.vertical .resize-handle { + height: 6px; + width: 100%; + cursor: row-resize; +} + +.resize-handle:hover { + background: var(--primary); + opacity: 0.5; +} + +.resize-handle.active { + background: var(--primary); + opacity: 1; +} + +/* ============ Folder Tree (Baumstruktur) ============ */ +.folder-tree { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0.25rem 0; +} + +.tree-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.35rem 0.5rem; + cursor: pointer; + font-size: 0.8rem; + white-space: nowrap; + border-left: 3px solid transparent; +} + +.tree-item:hover { + background: var(--bg-tertiary); +} + +.tree-item.active { + background: var(--primary); + color: white; + border-left-color: var(--primary); +} + +.tree-item.active:hover { + background: var(--primary); +} + +.tree-toggle { + width: 16px; + flex-shrink: 0; + font-size: 0.65rem; + color: var(--text-secondary); + text-align: center; +} + +.tree-item.active .tree-toggle { + color: rgba(255, 255, 255, 0.7); +} + +.tree-icon { + font-size: 0.9rem; + flex-shrink: 0; +} + +.tree-name { + overflow: hidden; + text-overflow: ellipsis; +} + +/* ============ File List ============ */ +.file-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem; +} + +.file-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.8rem; + transition: background 0.1s; +} + +.file-item:hover { + background: var(--bg-tertiary); +} + +.file-item.selected { + background: var(--primary); + color: white; +} + +.file-item.folder { + color: var(--text-secondary); +} + +.file-item.folder:hover { + color: var(--text); +} + +.file-item .file-icon { + font-size: 1.1rem; + flex-shrink: 0; +} + +.file-item .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-item .file-size { + font-size: 0.7rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.file-item.selected .file-size { + color: rgba(255, 255, 255, 0.7); +} + +.file-item.drop-target { + background: var(--primary); + color: white; + outline: 2px dashed white; + outline-offset: -2px; +} + +/* ============ File Info Bar ============ */ +.file-info { + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.file-info-name { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.file-info-name #preview-filename { + font-weight: 500; + font-size: 0.9rem; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-info-name .file-size { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.125rem 0.5rem; + border-radius: 4px; +} + +.file-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* ============ Preview Container ============ */ +.preview-container { + flex: 1; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + padding: 1rem; +} + +.preview-placeholder { + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.preview-placeholder span { + display: block; + margin-top: 0.5rem; +} + +/* PDF Preview */ +.preview-pdf { + width: 100%; + height: 100%; + border: none; + background: white; +} + +/* Image Preview */ +.preview-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +/* Text Preview */ +.preview-text { + width: 100%; + height: 100%; + background: var(--bg); + color: var(--text); + padding: 1rem; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.8rem; + overflow: auto; + border-radius: var(--radius); + white-space: pre-wrap; + word-wrap: break-word; +} + +/* No Preview Available */ +.preview-unavailable { + text-align: center; + padding: 2rem; +} + +.preview-unavailable .file-type-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.preview-unavailable p { + color: var(--text-secondary); + margin-bottom: 1rem; +} + +/* ============ Preview Window Button ============ */ +#btn-open-preview { + margin-right: 0.5rem; +} + +#btn-open-preview.active { + background: var(--success); + color: white; + border-color: var(--success); +} + +#btn-show-preview { + background: var(--warning); + color: white; + border-color: var(--warning); +} + +/* ============ Toast Notifications ============ */ +.toast-container { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 3000; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + padding: 0.75rem 1rem; + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text); + box-shadow: var(--shadow); + animation: slideIn 0.3s ease; + max-width: 350px; +} + +.toast.success { + border-left: 4px solid var(--success); +} + +.toast.error { + border-left: 4px solid var(--danger); +} + +.toast.warning { + border-left: 4px solid var(--warning); +} + +.toast.info { + border-left: 4px solid var(--primary); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* ============ Modal Adjustments ============ */ +.modal-small { + max-width: 400px; +} + +.delete-warning { + background: var(--bg-tertiary); + padding: 0.75rem; + border-radius: var(--radius); + font-family: monospace; + font-size: 0.85rem; + word-break: break-all; + margin: 0.75rem 0; +} + +.delete-hint { + color: var(--danger); + font-size: 0.8rem; +} + +/* ============ Context Menu ============ */ +.context-menu { + position: fixed; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + z-index: 2000; + min-width: 150px; +} + +.context-menu-item { + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.context-menu-item:hover { + background: var(--bg-tertiary); +} + +.context-menu-item.danger { + color: var(--danger); +} + +.context-menu-separator { + height: 1px; + background: var(--border); + margin: 0.25rem 0; +} + +/* ============ Empty State ============ */ +.empty-state { + color: var(--text-secondary); + font-size: 0.85rem; + text-align: center; + padding: 1rem; +} + +/* ============ Responsive / Vertikales Layout ============ */ +@media (max-width: 1000px) { + .browser-main { + flex-direction: column; + } + + .browser-main .pane-tree { + width: 100% !important; + max-width: none; + height: 150px; + min-height: 80px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .browser-main .pane-list { + width: 100% !important; + max-width: none; + height: 200px; + min-height: 100px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .browser-main .pane-preview { + min-width: auto; + min-height: 150px; + flex: 1; + } + + /* Resize Handles im vertikalen Modus */ + .browser-main .resize-handle { + width: 100%; + height: 8px; + cursor: row-resize; + } +} + +/* ============ Loading State ============ */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css index cf22316..2a94ff0 100644 --- a/frontend/static/css/style.css +++ b/frontend/static/css/style.css @@ -1,4 +1,5 @@ /* ============ Variables ============ */ +/* Default Theme (Original Dark) */ :root { --primary: #3b82f6; --primary-dark: #2563eb; @@ -15,6 +16,76 @@ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); } +/* KDE Breeze Dark Theme */ +[data-theme="breeze-dark"] { + --primary: #3daee9; + --primary-dark: #2980b9; + --success: #27ae60; + --danger: #da4453; + --warning: #f67400; + --bg: #1b1e20; + --bg-secondary: #232629; + --bg-tertiary: #31363b; + --text: #eff0f1; + --text-secondary: #7f8c8d; + --border: #3d4349; + --radius: 4px; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.4); +} + +/* KDE Breeze Light Theme */ +[data-theme="breeze-light"] { + --primary: #2980b9; + --primary-dark: #1d5a8a; + --success: #27ae60; + --danger: #da4453; + --warning: #f67400; + --bg: #eff0f1; + --bg-secondary: #fcfcfc; + --bg-tertiary: #e3e5e7; + --text: #232629; + --text-secondary: #7f8c8d; + --border: #bdc3c7; + --radius: 4px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} + +/* Explicit Dark Theme (overrides system preference) */ +[data-theme="dark"] { + --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); +} + +/* System preference detection (only when no theme is explicitly set) */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --primary: #2980b9; + --primary-dark: #1d5a8a; + --success: #27ae60; + --danger: #da4453; + --warning: #f67400; + --bg: #eff0f1; + --bg-secondary: #fcfcfc; + --bg-tertiary: #e3e5e7; + --text: #232629; + --text-secondary: #7f8c8d; + --border: #bdc3c7; + --radius: 4px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + } +} + /* ============ Reset & Base ============ */ * { margin: 0; @@ -173,6 +244,20 @@ body { margin-bottom: 1rem; } +.action-buttons { + display: flex; + justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.action-hint { + display: block; + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.75rem; +} + /* ============ Config Items ============ */ .config-item { display: flex; @@ -382,6 +467,76 @@ body { .badge-warning { background: var(--warning); color: #000; } .badge-danger { background: var(--danger); } .badge-info { background: var(--primary); } +.badge-secondary { background: var(--bg-tertiary); } +.badge-typ { background: #7c3aed; } + +/* ============ Schnell-Regeln (Typ-basiert) ============ */ +.card-hint { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 0.75rem; + padding: 0.5rem; + background: var(--bg-tertiary); + border-radius: var(--radius); + border-left: 3px solid var(--primary); +} + +.config-item.typ-regel { + border-left: 3px solid #7c3aed; +} + +.config-item.fallback-regel { + border-left: 3px solid var(--warning); + opacity: 0.85; +} + +.config-item.fallback-regel h4::after { + content: " (Fallback)"; + font-size: 0.7rem; + color: var(--warning); + font-weight: normal; +} + +.info-box { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; +} + +.info-box strong { + display: block; + margin-bottom: 0.5rem; + color: var(--text); +} + +.info-box p { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.info-box small { + display: block; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.info-box code { + background: var(--bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.7rem; +} + +.modal-hint { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: var(--radius); +} /* ============ Loading Overlay ============ */ .loading-overlay { @@ -541,3 +696,411 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* ============ Statistik ============ */ +.statistik-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; +} + +.stat-item { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + text-align: center; +} + +.stat-label { + display: block; + color: var(--text-secondary); + font-size: 0.75rem; + margin-bottom: 0.5rem; +} + +.stat-value { + display: block; + font-size: 1.5rem; + font-weight: 600; + color: var(--primary); +} + +/* ============ Hilfe Bereich ============ */ +.hilfe-section { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.hilfe-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.hilfe-section h4 { + margin-bottom: 0.75rem; + font-size: 1rem; +} + +.hilfe-section p { + color: var(--text-secondary); + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.hilfe-section textarea { + 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; + font-family: 'Consolas', 'Monaco', monospace; + resize: vertical; +} + +.btn-group { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.btn-file { + display: inline-flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.doku-box { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); + font-size: 0.8rem; +} + +.doku-box h5 { + margin-top: 1rem; + margin-bottom: 0.5rem; + color: var(--primary); + font-size: 0.875rem; +} + +.doku-box h5:first-child { + margin-top: 0; +} + +.doku-box pre { + background: var(--bg); + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; + font-size: 0.75rem; + line-height: 1.5; +} + +#hilfe-analyse { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: var(--radius); +} + +#hilfe-analyse h5 { + margin-top: 1rem; + margin-bottom: 0.5rem; + color: var(--success); +} + +#hilfe-analyse h5:first-child { + margin-top: 0; +} + +#hilfe-analyse pre { + background: var(--bg); + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; + font-size: 0.75rem; +} + +#hilfe-analyse ul { + list-style: none; + padding: 0; +} + +#hilfe-analyse li { + padding: 0.25rem 0; + font-size: 0.8rem; +} + +/* ============ Header Right ============ */ +.header-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* ============ Theme Selector ============ */ +.theme-select { + background: var(--bg-tertiary); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.375rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; + outline: none; +} + +.theme-select:hover { + border-color: var(--primary); +} + +.theme-select:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.theme-select option { + background: var(--bg-secondary); + color: var(--text); +} + +/* ============ Regex Editor ============ */ +.regex-editor { + background: var(--bg); + padding: 1rem; + border-radius: var(--radius); + margin: 1rem 0; +} + +.regex-row { + margin-bottom: 0.75rem; +} + +.regex-row:last-child { + margin-bottom: 0; +} + +.regex-row label { + display: block; + font-size: 0.8rem; + font-weight: 500; + margin-bottom: 0.25rem; + color: var(--text); +} + +.regex-row input { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text); + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.8rem; +} + +.regex-row small { + display: block; + color: var(--text-secondary); + font-size: 0.7rem; + margin-top: 0.25rem; +} + +.regel-vorschau { + margin-top: 1rem; + padding: 1rem; + background: var(--bg); + border-radius: var(--radius); + border-left: 3px solid var(--success); +} + +.regel-vorschau h5 { + margin-bottom: 0.5rem; + color: var(--success); +} + +.regel-vorschau pre { + background: var(--bg-tertiary); + padding: 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; +} + +/* ============ Regex Input mit Dropdown ============ */ +.regex-input-group { + display: flex; + gap: 0.5rem; +} + +.regex-input-group input { + flex: 1; +} + +.regex-input-group select { + width: auto; + min-width: 120px; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text); + font-size: 0.75rem; + cursor: pointer; +} + +/* ============ Regex Cheatsheet ============ */ +.regex-cheatsheet { + font-size: 0.8rem; +} + +.cheatsheet-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.cheatsheet-section { + background: var(--bg); + padding: 0.75rem; + border-radius: 4px; +} + +.cheatsheet-section h5 { + margin-top: 0 !important; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.cheatsheet-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.cheatsheet-table td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.cheatsheet-table td:first-child { + font-family: 'Consolas', 'Monaco', monospace; + color: var(--success); + white-space: nowrap; +} + +.cheatsheet-table tr:last-child td { + border-bottom: none; +} + +.cheatsheet-tip { + margin-top: 1rem; + padding: 0.75rem; + background: rgba(59, 130, 246, 0.2); + border-left: 3px solid var(--primary); + border-radius: 4px; + font-size: 0.8rem; +} + +.cheatsheet-tip code { + background: var(--bg); + padding: 0.1rem 0.3rem; + border-radius: 3px; + font-size: 0.75rem; +} + +/* ============ PDF Browser ============ */ +.pdf-ordner-auswahl { + margin-bottom: 1rem; +} + +.pdf-ordner-auswahl label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pdf-ordner-auswahl 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; +} + +.pdf-dateien-liste { + max-height: 300px; + overflow-y: auto; +} + +.pdf-file-list { + list-style: none; + padding: 0; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.pdf-file-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; +} + +.pdf-file-item:hover { + background: var(--bg-tertiary); +} + +.pdf-file-item:last-child { + border-bottom: none; +} + +/* ============ Permission Badges ============ */ +.perm-badge { + display: inline-block; + padding: 0.125rem 0.4rem; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 500; + margin-left: 0.5rem; + background: var(--bg-tertiary); +} + +.perm-badge-small { + display: inline-block; + padding: 0.1rem 0.3rem; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 500; + margin-left: auto; + background: var(--bg); + color: var(--text-secondary); +} + +.file-browser-item.perm-ok .perm-badge-small { + color: var(--success); +} + +.file-browser-item.perm-no-write .perm-badge-small { + color: var(--warning); +} + +.file-browser-item.perm-no-read { + opacity: 0.6; +} + +.file-browser-item.perm-no-read .perm-badge-small { + color: var(--danger); +} diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index 77422d0..3cd7d6c 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -31,12 +31,12 @@ function versteckeLoading() { // ============ File Browser ============ let browserTargetInput = null; -let browserCurrentPath = '/srv/http/dateiverwaltung/data'; +let browserCurrentPath = '/mnt'; function oeffneBrowser(inputId) { browserTargetInput = inputId; const currentValue = document.getElementById(inputId).value; - browserCurrentPath = currentValue || '/srv/http/dateiverwaltung/data'; + browserCurrentPath = currentValue || '/mnt'; ladeBrowserInhalt(browserCurrentPath); document.getElementById('browser-modal').classList.remove('hidden'); } @@ -52,7 +52,10 @@ async function ladeBrowserInhalt(path) { } browserCurrentPath = data.current; - document.getElementById('browser-current-path').textContent = data.current; + + // Berechtigungen des aktuellen Ordners anzeigen + const permStr = getPermissionString(data.readable, data.writable); + document.getElementById('browser-current-path').innerHTML = `${escapeHtml(data.current)} ${permStr}`; let html = ''; @@ -63,10 +66,12 @@ async function ladeBrowserInhalt(path) { `; } - // Directories + // Directories mit Berechtigungsanzeige for (const entry of data.entries) { - html += `
  • - 📁 ${entry.name} + const entryPermStr = getPermissionString(entry.readable, entry.writable); + const permClass = !entry.readable ? 'perm-no-read' : (!entry.writable ? 'perm-no-write' : 'perm-ok'); + html += `
  • + 📁 ${escapeHtml(entry.name)} ${entryPermStr}
  • `; } @@ -81,6 +86,13 @@ async function ladeBrowserInhalt(path) { } } +function getPermissionString(readable, writable) { + if (readable && writable) return '✓ RW'; + if (readable && !writable) return '⚠ R'; + if (!readable) return '✗ ---'; + return '?'; +} + function browserSelect(element, path) { document.querySelectorAll('.file-browser-item.selected').forEach(el => el.classList.remove('selected')); element.classList.add('selected'); @@ -111,11 +123,50 @@ function setCheckedTypes(groupId, types) { // ============ Init ============ document.addEventListener('DOMContentLoaded', () => { + ladeTheme(); // Theme zuerst laden ladePostfaecher(); ladeOrdner(); ladeRegeln(); + ladeTypRegeln(); // Typ-Regeln für Schnell-Regeln Dropdown laden + ladeOcrBackupEinstellungen(); // OCR-Backup Einstellungen laden }); +// ============ Theme Management ============ + +function ladeTheme() { + const gespeichertesTheme = localStorage.getItem('theme') || 'auto'; + const select = document.getElementById('theme-select'); + if (select) { + select.value = gespeichertesTheme; + } + wendeThemeAn(gespeichertesTheme); +} + +function wechsleTheme(theme) { + localStorage.setItem('theme', theme); + wendeThemeAn(theme); +} + +function wendeThemeAn(theme) { + const html = document.documentElement; + + if (theme === 'auto') { + // System-Präferenz nutzen + html.removeAttribute('data-theme'); + } else if (theme === 'dark') { + // Original Dark Theme (kein data-theme = default CSS) + html.removeAttribute('data-theme'); + // Aber System-Präferenz überschreiben durch explizites Setzen + html.setAttribute('data-theme', 'dark'); + } else { + // Breeze Themes + html.setAttribute('data-theme', theme); + } +} + +// Original Dark Theme explizit definieren +// (wird verwendet wenn "dark" gewählt ist, auch bei Light System-Präferenz) + // ============ BEREICH 1: Mail-Abruf ============ async function ladePostfaecher() { @@ -129,6 +180,9 @@ async function ladePostfaecher() { let bearbeitetesPostfachId = null; +// Aktive Abrufe tracken +let aktiveAbrufe = {}; + function renderPostfaecher(postfaecher) { const container = document.getElementById('postfaecher-liste'); @@ -137,20 +191,25 @@ function renderPostfaecher(postfaecher) { return; } - container.innerHTML = postfaecher.map(p => ` -
    + container.innerHTML = postfaecher.map(p => { + const istAktiv = aktiveAbrufe[p.id]; + return ` +
    -

    ${escapeHtml(p.name)}

    +

    ${escapeHtml(p.name)} ${istAktiv ? 'Läuft...' : ''}

    ${escapeHtml(p.email)} → ${escapeHtml(p.ziel_ordner)}
    - + ${istAktiv + ? `` + : `` + }
    - `).join(''); + `}).join(''); } function zeigePostfachModal(postfach = null) { @@ -163,6 +222,13 @@ function zeigePostfachModal(postfach = null) { 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'; + // Datum formatieren für date input (YYYY-MM-DD) + if (postfach?.ab_datum) { + const d = new Date(postfach.ab_datum); + document.getElementById('pf-ab-datum').value = d.toISOString().split('T')[0]; + } else { + document.getElementById('pf-ab-datum').value = ''; + } 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'; @@ -189,6 +255,13 @@ async function speicherePostfach() { return; } + // Datum konvertieren + const abDatumValue = document.getElementById('pf-ab-datum').value; + let abDatum = null; + if (abDatumValue) { + abDatum = new Date(abDatumValue).toISOString(); + } + const data = { name: document.getElementById('pf-name').value.trim(), imap_server: document.getElementById('pf-server').value.trim(), @@ -197,6 +270,7 @@ async function speicherePostfach() { passwort: document.getElementById('pf-passwort').value, ordner: document.getElementById('pf-ordner').value.trim(), alle_ordner: document.getElementById('pf-alle-ordner').value === 'true', + ab_datum: abDatum, ziel_ordner: document.getElementById('pf-ziel').value.trim(), erlaubte_typen: erlaubteTypen, max_groesse_mb: parseInt(document.getElementById('pf-max-groesse').value) @@ -241,8 +315,13 @@ async function postfachAbrufen(id) { const logContainer = document.getElementById('abruf-log'); logContainer.innerHTML = '
    Verbinde...
    '; + // Als aktiv markieren + aktiveAbrufe[id] = true; + ladePostfaecher(); + // EventSource für Server-Sent Events const eventSource = new EventSource(`/api/postfaecher/${id}/abrufen/stream`); + aktiveAbrufe[id] = eventSource; // EventSource speichern für Stopp let dateiCount = 0; let currentOrdner = ''; @@ -300,11 +379,21 @@ async function postfachAbrufen(id) {
    `; break; + case 'abgebrochen': + logContainer.innerHTML += `
    + ⚠ ${escapeHtml(data.nachricht)} +
    `; + break; + case 'fertig': - logContainer.innerHTML += `
    - ✓ Fertig: ${data.anzahl} Dateien gespeichert + const msg = data.abgebrochen + ? `⚠ Abgebrochen: ${data.anzahl} Dateien gespeichert` + : `✓ Fertig: ${data.anzahl} Dateien gespeichert`; + logContainer.innerHTML += `
    + ${msg}
    `; eventSource.close(); + delete aktiveAbrufe[id]; ladePostfaecher(); break; } @@ -315,9 +404,25 @@ async function postfachAbrufen(id) { ✗ Verbindung unterbrochen
    `; eventSource.close(); + delete aktiveAbrufe[id]; + ladePostfaecher(); }; } +async function postfachAbrufStoppen(id) { + try { + const result = await api(`/postfaecher/${id}/abrufen/stoppen`, { method: 'POST' }); + if (result.erfolg) { + const logContainer = document.getElementById('abruf-log'); + logContainer.innerHTML += `
    + ⚠ Stopp angefordert... +
    `; + } + } catch (error) { + alert('Fehler: ' + error.message); + } +} + function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; @@ -474,6 +579,17 @@ async function ordnerScannen(id) { let editierteRegelId = null; +// Verfügbare Typ-Regeln (vom Server geladen) +let verfuegbareTypRegeln = []; + +async function ladeTypRegeln() { + try { + verfuegbareTypRegeln = await api('/typ-regeln'); + } catch (error) { + console.error('Fehler beim Laden der Typ-Regeln:', error); + } +} + async function ladeRegeln() { try { const regeln = await api('/regeln'); @@ -484,25 +600,207 @@ async function ladeRegeln() { } function renderRegeln(regeln) { - const container = document.getElementById('regeln-liste'); + const schnellContainer = document.getElementById('schnell-regeln-liste'); + const feinContainer = document.getElementById('regeln-liste'); - if (!regeln || regeln.length === 0) { - container.innerHTML = '

    Keine Regeln definiert

    '; + // Regeln aufteilen: Typ-basierte (Schnell) vs. Inhalt-basierte (Fein) + const schnellRegeln = []; + const feinRegeln = []; + + for (const r of (regeln || [])) { + const muster = r.muster || {}; + // Schnell-Regel wenn sie Typ-basierte Muster hat (ohne Keywords/Text-Match) + const istTypRegel = ( + ('ist_zugferd' in muster || 'ist_signiert' in muster || + 'ist_bild' in muster || 'ist_pdf' in muster || + 'hat_text' in muster || 'dateityp_ist' in muster) && + !muster.keywords && !muster.text_match && !muster.text_match_any && !muster.text_regex + ); + + if (istTypRegel) { + schnellRegeln.push(r); + } else { + feinRegeln.push(r); + } + } + + // Schnell-Regeln rendern (sortiert nach Priorität) + schnellRegeln.sort((a, b) => a.prioritaet - b.prioritaet); + + if (schnellRegeln.length === 0) { + schnellContainer.innerHTML = '

    Keine Schnell-Regeln definiert

    '; + } else { + schnellContainer.innerHTML = schnellRegeln.map((r, index) => { + const typBadge = getTypRegelBadge(r.muster); + const istFallback = r.prioritaet >= 900; + const fallbackClass = istFallback ? 'fallback-regel' : ''; + return ` +
    +
    +

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

    + → ${escapeHtml(r.unterordner || 'Zielordner')} +
    +
    + + + +
    +
    + `}).join(''); + } + + // Fein-Regeln rendern (sortiert nach Priorität) + feinRegeln.sort((a, b) => a.prioritaet - b.prioritaet); + + if (feinRegeln.length === 0) { + feinContainer.innerHTML = '

    Keine Fein-Regeln definiert

    '; + } else { + feinContainer.innerHTML = feinRegeln.map((r, index) => ` +
    +
    +

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

    + ${escapeHtml(r.schema)} +
    +
    + + + + +
    +
    + `).join(''); + } +} + +function getTypRegelBadge(muster) { + const badges = []; + if (muster.ist_zugferd) badges.push('ZUGFeRD'); + if (muster.ist_signiert) badges.push('Signiert'); + if (muster.ist_bild) badges.push('Bild'); + if (muster.ist_pdf) badges.push('PDF'); + if (muster.hat_text === false) badges.push('Ohne Text'); + return badges.join(' '); +} + +// ============ Schnell-Regeln UI ============ + +function zeigeSchnellRegelModal() { + // Dropdown befüllen + const select = document.getElementById('schnell-regel-typ'); + select.innerHTML = ''; + + for (const typ of verfuegbareTypRegeln) { + select.innerHTML += ``; + } + + // Details verstecken + document.getElementById('schnell-regel-details').classList.add('hidden'); + document.getElementById('schnell-regel-speichern-btn').disabled = true; + + document.getElementById('schnell-regel-modal').classList.remove('hidden'); +} + +function schnellRegelTypGeaendert() { + const typId = document.getElementById('schnell-regel-typ').value; + const detailsDiv = document.getElementById('schnell-regel-details'); + const speichernBtn = document.getElementById('schnell-regel-speichern-btn'); + + if (!typId) { + detailsDiv.classList.add('hidden'); + speichernBtn.disabled = true; return; } - container.innerHTML = regeln.map(r => ` -
    -
    -

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

    - ${escapeHtml(r.schema)} -
    -
    - - -
    -
    - `).join(''); + const typ = verfuegbareTypRegeln.find(t => t.id === typId); + if (!typ) return; + + // Details anzeigen + document.getElementById('schnell-regel-name').textContent = typ.name; + document.getElementById('schnell-regel-beschreibung').textContent = typ.beschreibung; + document.getElementById('schnell-regel-muster').textContent = JSON.stringify(typ.muster); + document.getElementById('schnell-regel-unterordner').value = typ.unterordner || ''; + document.getElementById('schnell-regel-prioritaet').value = typ.prioritaet || 10; + + detailsDiv.classList.remove('hidden'); + speichernBtn.disabled = false; +} + +async function speichereSchnellRegel() { + const typId = document.getElementById('schnell-regel-typ').value; + const unterordner = document.getElementById('schnell-regel-unterordner').value.trim(); + const prioritaet = parseInt(document.getElementById('schnell-regel-prioritaet').value) || 10; + + if (!typId) { + alert('Bitte Regel-Typ auswählen'); + return; + } + + try { + zeigeLoading('Erstelle Schnell-Regel...'); + await api('/regeln/typ', { + method: 'POST', + body: JSON.stringify({ + typ_id: typId, + unterordner: unterordner || null, + prioritaet: prioritaet + }) + }); + schliesseModal('schnell-regel-modal'); + ladeRegeln(); + } catch (error) { + alert('Fehler: ' + error.message); + } finally { + versteckeLoading(); + } +} + +// ============ Prioritäts-Verwaltung ============ + +async function regelPrioritaetAendern(regelId, delta) { + try { + // Aktuelle Regeln holen um Priorität zu finden + const regeln = await api('/regeln'); + const regel = regeln.find(r => r.id === regelId); + if (!regel) return; + + const neuePrio = Math.max(1, regel.prioritaet + delta); + + await api(`/regeln/${regelId}/prioritaet`, { + method: 'PUT', + body: JSON.stringify({ prioritaet: neuePrio }) + }); + + ladeRegeln(); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +// ============ Datei-Browser für Regel-Unterordner ============ + +let regelBrowserQuellOrdner = null; + +async function oeffneBrowserFuerRegel() { + // Wenn Quell-Ordner konfiguriert sind, deren Ziel-Ordner als Basis nehmen + try { + const ordner = await api('/ordner'); + if (ordner.length > 0) { + // Ersten konfigurierten Ziel-Ordner als Startpunkt + browserCurrentPath = ordner[0].ziel_ordner || '/mnt'; + regelBrowserQuellOrdner = ordner[0]; + } else { + browserCurrentPath = '/mnt'; + } + + browserTargetInput = 'regel-unterordner'; + ladeBrowserInhalt(browserCurrentPath); + document.getElementById('browser-modal').classList.remove('hidden'); + } catch (error) { + browserCurrentPath = '/mnt'; + browserTargetInput = 'regel-unterordner'; + ladeBrowserInhalt(browserCurrentPath); + document.getElementById('browser-modal').classList.remove('hidden'); + } } function zeigeRegelModal(regel = null) { @@ -631,15 +929,218 @@ async function testeRegel() { // ============ Sortierung starten ============ -async function sortierungStarten() { +let sortierungAktiv = false; +let sortierungEventSource = null; + +// ============ OCR-Backup Einstellungen ============ + +function toggleOcrBackup() { + const checkbox = document.getElementById('ocr-backup-aktiv'); + const ordnerGruppe = document.getElementById('ocr-backup-ordner-gruppe'); + + if (checkbox.checked) { + ordnerGruppe.classList.remove('hidden'); + } else { + ordnerGruppe.classList.add('hidden'); + } + + // Einstellung im localStorage speichern + localStorage.setItem('ocr-backup-aktiv', checkbox.checked); + localStorage.setItem('ocr-backup-ordner', document.getElementById('ocr-backup-ordner').value); +} + +function ladeOcrBackupEinstellungen() { + const aktiv = localStorage.getItem('ocr-backup-aktiv') === 'true'; + const ordner = localStorage.getItem('ocr-backup-ordner') || ''; + + const checkbox = document.getElementById('ocr-backup-aktiv'); + const input = document.getElementById('ocr-backup-ordner'); + const gruppe = document.getElementById('ocr-backup-ordner-gruppe'); + + if (checkbox) { + checkbox.checked = aktiv; + if (aktiv) { + gruppe.classList.remove('hidden'); + } + } + if (input) { + input.value = ordner; + // Event-Listener für Änderungen + input.addEventListener('change', () => { + localStorage.setItem('ocr-backup-ordner', input.value); + }); + } +} + +function oeffneBrowserFuerOcrBackup() { + browserCurrentPath = '/mnt'; + browserTargetInput = 'ocr-backup-ordner'; + ladeBrowserInhalt(browserCurrentPath); + document.getElementById('browser-modal').classList.remove('hidden'); +} + +async function sortierungStarten(testmodus = false) { + const logContainer = document.getElementById('sortierung-log'); + const modeText = testmodus ? 'Starte Testlauf (keine Änderungen)...' : 'Starte Sortierung...'; + logContainer.innerHTML = `
    ${modeText}
    `; + + // Button aktualisieren + sortierungAktiv = true; + aktualisiereSortierungsButtons(); + + // URL mit optionalem Testmodus und Backup-Ordner bauen + let url = '/api/sortierung/stream?'; + const params = []; + if (testmodus) params.push('testmodus=true'); + + // OCR-Backup-Ordner hinzufügen wenn aktiviert + const backupAktiv = document.getElementById('ocr-backup-aktiv')?.checked; + const backupOrdner = document.getElementById('ocr-backup-ordner')?.value?.trim(); + if (backupAktiv && backupOrdner) { + params.push(`ocr_backup_ordner=${encodeURIComponent(backupOrdner)}`); + } + + url += params.join('&'); + sortierungEventSource = new EventSource(url); + + let stats = { gesamt: 0, sortiert: 0, zugferd: 0, fehler: 0 }; + + sortierungEventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + const testBadge = data.testmodus ? 'TEST ' : ''; + + switch (data.type) { + case 'start': + const modeInfo = data.testmodus + ? '🔍 TESTMODUS - Dateien werden nur analysiert, nicht verschoben!' + : 'Sortierung gestartet'; + logContainer.innerHTML = `
    + ${modeInfo}: ${data.ordner} Ordner, ${data.regeln} Regeln +
    `; + break; + + case 'ordner': + logContainer.innerHTML += `
    + 📁 ${escapeHtml(data.name)} + ${escapeHtml(data.pfad)} +
    `; + break; + + case 'dateien_gefunden': + logContainer.innerHTML += `
    + ${data.anzahl} Dateien gefunden +
    `; + break; + + case 'datei': + stats.gesamt++; + let icon, statusClass, statusText; + if (data.status === 'sortiert') { + stats.sortiert++; + icon = data.testmodus ? '→' : '✓'; + statusClass = data.testmodus ? 'info' : 'success'; + statusText = data.testmodus ? 'würde sortiert' : 'sortiert'; + } else if (data.status === 'zugferd') { + stats.zugferd++; + icon = '🧾'; + statusClass = 'info'; + statusText = data.testmodus ? 'ZUGFeRD erkannt' : 'ZUGFeRD'; + } else if (data.status === 'keine_regel') { + stats.fehler++; + icon = '?'; + statusClass = ''; + statusText = 'keine Regel'; + } else { + stats.fehler++; + icon = '✗'; + statusClass = 'error'; + statusText = 'Fehler'; + } + + // Zielordner im Testmodus anzeigen + const zielInfo = data.testmodus && data.ziel_ordner + ? `→ ${escapeHtml(data.ziel_ordner)}` + : ''; + + logContainer.innerHTML += `
    + ${testBadge}${icon} ${escapeHtml(data.neuer_name || data.original)} + ${data.regel ? `Regel: ${escapeHtml(data.regel)}` : ''} + ${data.fehler ? `${escapeHtml(data.fehler)}` : ''} + ${zielInfo} +
    `; + logContainer.scrollTop = logContainer.scrollHeight; + break; + + case 'warnung': + logContainer.innerHTML += `
    + ⚠ ${escapeHtml(data.nachricht)} +
    `; + break; + + case 'abgebrochen': + logContainer.innerHTML += `
    + ⚠ Sortierung abgebrochen +
    `; + break; + + case 'fertig': + const fertigText = data.testmodus + ? `🔍 Testlauf fertig: ${data.sortiert} würden sortiert, ${data.zugferd} ZUGFeRD, ${data.fehler} ohne Regel` + : `✓ Fertig: ${data.sortiert} sortiert, ${data.zugferd} ZUGFeRD, ${data.fehler} ohne Regel`; + const fertigHint = data.testmodus + ? '
    Keine Dateien wurden verschoben. Starte echte Sortierung wenn zufrieden.
    ' + : ''; + logContainer.innerHTML += `
    + ${fertigText} +
    ${fertigHint}`; + sortierungEventSource.close(); + sortierungAktiv = false; + sortierungEventSource = null; + aktualisiereSortierungsButtons(); + break; + } + }; + + sortierungEventSource.onerror = (error) => { + logContainer.innerHTML += `
    + ✗ Verbindung unterbrochen +
    `; + sortierungEventSource.close(); + sortierungAktiv = false; + sortierungEventSource = null; + aktualisiereSortierungsButtons(); + }; +} + +async function sortierungStoppen() { try { - zeigeLoading('Sortiere Dateien...'); - const result = await api('/sortierung/starten', { method: 'POST' }); - zeigeSortierungLog(result); + const result = await api('/sortierung/stoppen', { method: 'POST' }); + if (result.erfolg) { + const logContainer = document.getElementById('sortierung-log'); + logContainer.innerHTML += `
    + ⚠ Stopp angefordert... +
    `; + } } catch (error) { alert('Fehler: ' + error.message); - } finally { - versteckeLoading(); + } +} + +function aktualisiereSortierungsButtons() { + const startBtn = document.getElementById('sortierung-start-btn'); + const testBtn = document.getElementById('sortierung-test-btn'); + const stoppBtn = document.getElementById('sortierung-stopp-btn'); + + if (startBtn && stoppBtn) { + if (sortierungAktiv) { + startBtn.classList.add('hidden'); + if (testBtn) testBtn.classList.add('hidden'); + stoppBtn.classList.remove('hidden'); + } else { + startBtn.classList.remove('hidden'); + if (testBtn) testBtn.classList.remove('hidden'); + stoppBtn.classList.add('hidden'); + } } } @@ -691,3 +1192,430 @@ document.addEventListener('keydown', (e) => { document.querySelectorAll('.modal:not(.hidden)').forEach(m => m.classList.add('hidden')); } }); + + +// ============ Datenbank-Management ============ + +async function dbZuruecksetzen() { + if (!confirm('Datenbank wirklich zurücksetzen?\n\nDies löscht alle Einträge über verarbeitete Mails und Dateien.\nPostfächer, Ordner und Regeln bleiben erhalten.\n\nBeim nächsten Abruf werden alle Mails erneut verarbeitet!')) { + return; + } + + try { + zeigeLoading('Setze Datenbank zurück...'); + const result = await api('/db/reset', { method: 'POST' }); + + if (result.erfolg) { + alert(`Datenbank zurückgesetzt!\n\nGelöscht:\n- ${result.geloescht.mails} verarbeitete Mails\n- ${result.geloescht.dateien} verarbeitete Dateien`); + } else { + alert('Fehler: ' + result.nachricht); + } + } catch (error) { + alert('Fehler: ' + error.message); + } finally { + versteckeLoading(); + } +} + +async function zeigeStatistik() { + document.getElementById('statistik-modal').classList.remove('hidden'); + document.getElementById('statistik-inhalt').innerHTML = 'Wird geladen...'; + + try { + const stats = await api('/db/statistik'); + document.getElementById('statistik-inhalt').innerHTML = ` +
    +
    + Postfächer + ${stats.postfaecher} +
    +
    + Quell-Ordner + ${stats.quell_ordner} +
    +
    + Sortier-Regeln + ${stats.regeln} +
    +
    + Verarbeitete Mails + ${stats.verarbeitete_mails} +
    +
    + Verarbeitete Dateien + ${stats.verarbeitete_dateien} +
    +
    + `; + } catch (error) { + document.getElementById('statistik-inhalt').innerHTML = `

    Fehler: ${error.message}

    `; + } +} + + +// ============ Regel-Hilfe ============ + +function zeigeRegelHilfe() { + document.getElementById('hilfe-modal').classList.remove('hidden'); + document.getElementById('hilfe-text').value = ''; + document.getElementById('hilfe-ergebnis').classList.add('hidden'); +} + +// Speichert das letzte Analyse-Ergebnis +let letzteAnalyse = null; + +// Standard-Regex-Muster für häufige Felder +const STANDARD_REGEX = { + datum: [ + { label: 'DD.MM.YYYY', regex: '(\\d{2}\\.\\d{2}\\.\\d{4})' }, + { label: 'Rechnungsdatum: DD.MM.YYYY', regex: 'Rechnungsdatum[:\\s]*(\\d{2}\\.\\d{2}\\.\\d{4})' }, + { label: 'Datum: DD.MM.YYYY', regex: 'Datum[:\\s]*(\\d{2}\\.\\d{2}\\.\\d{4})' }, + { label: 'YYYY-MM-DD', regex: '(\\d{4}-\\d{2}-\\d{2})' } + ], + betrag: [ + { label: 'Gesamtbetrag: X,XX', regex: 'Gesamtbetrag[:\\s]*([\\d.,]+)' }, + { label: 'Summe: X,XX', regex: 'Summe[:\\s]*([\\d.,]+)' }, + { label: 'Rechnungsbetrag: X,XX', regex: 'Rechnungsbetrag[:\\s]*([\\d.,]+)' }, + { label: 'Total: X,XX', regex: 'Total[:\\s]*([\\d.,]+)' }, + { label: 'Brutto: X,XX', regex: 'Brutto[:\\s]*([\\d.,]+)' }, + { label: 'Netto: X,XX', regex: 'Netto[:\\s]*([\\d.,]+)' }, + { label: 'EUR X,XX (letzter)', regex: 'EUR\\s*([\\d.,]+)(?!.*EUR\\s*[\\d.,]+)' } + ], + nummer: [ + { label: 'Rechnungsnummer: XXX', regex: 'Rechnungsnummer[:\\s]*(\\S+)' }, + { label: 'Rechnung Nr. XXX', regex: 'Rechnung\\s*(?:Nr\\.?|Nummer)?[:\\s]*(\\S+)' }, + { label: 'Belegnummer: XXX', regex: 'Belegnummer[:\\s]*(\\S+)' }, + { label: 'Bestellnummer: XXX', regex: 'Bestellnummer[:\\s]*(\\S+)' }, + { label: 'Dokumentnummer: XXX', regex: 'Dokumentnummer[:\\s]*(\\S+)' } + ] +}; + +async function analysiereText() { + const text = document.getElementById('hilfe-text').value.trim(); + if (!text) { + alert('Bitte Text eingeben oder Datei hochladen'); + return; + } + + try { + zeigeLoading('Analysiere Text...'); + const result = await api('/extraktion/test', { + method: 'POST', + body: JSON.stringify({ text: text }) + }); + + letzteAnalyse = result; + + const container = document.getElementById('hilfe-analyse'); + container.innerHTML = ` +
    Automatisch extrahierte Felder:
    +
    ${JSON.stringify(result.extrahiert, null, 2)}
    +

    + Falls Werte falsch sind, passe unten die Regex-Muster an! +

    + `; + + // Felder vorausfüllen wenn gefunden + document.getElementById('hilfe-firma').value = result.extrahiert.firma || ''; + + // Standard-Regex vorausfüllen basierend auf erkannten Werten + if (result.extrahiert.datum) { + // Prüfen welches Muster gepasst haben könnte + document.getElementById('hilfe-datum-regex').value = STANDARD_REGEX.datum[0].regex; + } else { + document.getElementById('hilfe-datum-regex').value = STANDARD_REGEX.datum[0].regex; + } + + if (result.extrahiert.betrag) { + document.getElementById('hilfe-betrag-regex').value = STANDARD_REGEX.betrag[0].regex; + } else { + document.getElementById('hilfe-betrag-regex').value = STANDARD_REGEX.betrag[0].regex; + } + + if (result.extrahiert.nummer) { + document.getElementById('hilfe-nummer-regex').value = STANDARD_REGEX.nummer[0].regex; + } else { + document.getElementById('hilfe-nummer-regex').value = STANDARD_REGEX.nummer[0].regex; + } + + // Keywords aus ersten erkennbaren Wörtern + const words = text.split(/\s+/).filter(w => w.length > 4 && /^[a-zA-ZäöüÄÖÜß]+$/.test(w)); + document.getElementById('hilfe-keywords').value = words.slice(0, 3).join(', ').toLowerCase(); + + document.getElementById('hilfe-ergebnis').classList.remove('hidden'); + document.getElementById('hilfe-regel-vorschau').classList.add('hidden'); + } catch (error) { + alert('Fehler: ' + error.message); + } finally { + versteckeLoading(); + } +} + +async function testeMitRegex() { + const text = document.getElementById('hilfe-text').value.trim(); + if (!text) { + alert('Bitte erst Text eingeben'); + return; + } + + const firma = document.getElementById('hilfe-firma').value.trim(); + const datumRegex = document.getElementById('hilfe-datum-regex').value.trim(); + const betragRegex = document.getElementById('hilfe-betrag-regex').value.trim(); + const nummerRegex = document.getElementById('hilfe-nummer-regex').value.trim(); + + try { + zeigeLoading('Teste Regex...'); + + // Custom Regex an Backend senden + const result = await api('/extraktion/test-custom', { + method: 'POST', + body: JSON.stringify({ + text: text, + firma: firma, + datum_regex: datumRegex, + betrag_regex: betragRegex, + nummer_regex: nummerRegex + }) + }); + + letzteAnalyse = result; + + const container = document.getElementById('hilfe-analyse'); + container.innerHTML = ` +
    Extrahierte Felder (mit deinen Regex):
    +
    ${JSON.stringify(result.extrahiert, null, 2)}
    + ${result.fehler ? `

    Fehler: ${escapeHtml(result.fehler)}

    ` : ''} + `; + + } catch (error) { + alert('Fehler: ' + error.message); + } finally { + versteckeLoading(); + } +} + +function erstelleRegelAusHilfe() { + const firma = document.getElementById('hilfe-firma').value.trim(); + const datumRegex = document.getElementById('hilfe-datum-regex').value.trim(); + const betragRegex = document.getElementById('hilfe-betrag-regex').value.trim(); + const nummerRegex = document.getElementById('hilfe-nummer-regex').value.trim(); + const keywords = document.getElementById('hilfe-keywords').value.trim(); + + if (!keywords) { + alert('Bitte mindestens Keywords für die Erkennung eingeben'); + return; + } + + // Regel-Objekt bauen + const regel = { + name: firma ? `${firma} Rechnung` : 'Neue Regel', + prioritaet: 50, + muster: { + text_match: keywords.split(',').map(k => k.trim().toLowerCase()).filter(k => k) + }, + extraktion: {}, + schema: '{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf', + unterordner: firma ? firma.toLowerCase().replace(/[^a-z0-9]/g, '_') : null + }; + + // Extraktion hinzufügen + if (firma) { + regel.extraktion.firma = { wert: firma }; + } + if (datumRegex) { + regel.extraktion.datum = { regex: datumRegex }; + } + if (betragRegex) { + regel.extraktion.betrag = { regex: betragRegex, typ: 'betrag' }; + } + if (nummerRegex) { + regel.extraktion.nummer = { regex: nummerRegex }; + } + + // Vorschau anzeigen + const jsonStr = JSON.stringify(regel, null, 2); + document.getElementById('hilfe-regel-json').textContent = jsonStr; + document.getElementById('hilfe-regel-vorschau').classList.remove('hidden'); + + // In Regel-Modal übernehmen Button + if (confirm('Regel übernehmen und im Editor öffnen?')) { + // Modal schließen + schliesseModal('hilfe-modal'); + + // Regel-Modal öffnen und befüllen + editierteRegelId = null; + document.getElementById('regel-modal-title').textContent = 'Regel hinzufügen'; + document.getElementById('regel-name').value = regel.name; + document.getElementById('regel-prioritaet').value = regel.prioritaet; + 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 || ''; + document.getElementById('regel-test-text').value = document.getElementById('hilfe-text').value; + document.getElementById('regel-test-ergebnis').classList.add('hidden'); + + document.getElementById('regel-modal').classList.remove('hidden'); + } +} + +async function ladeHilfeDatei(input) { + const file = input.files[0]; + if (!file) return; + + if (file.type === 'text/plain') { + // TXT Datei direkt lesen + const reader = new FileReader(); + reader.onload = (e) => { + document.getElementById('hilfe-text').value = e.target.result; + }; + reader.readAsText(file); + } else if (file.type === 'application/pdf') { + // PDF serverseitig verarbeiten + try { + zeigeLoading('Extrahiere Text aus PDF...'); + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/extraktion/upload-pdf', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'PDF-Verarbeitung fehlgeschlagen'); + } + + const result = await response.json(); + document.getElementById('hilfe-text').value = result.text || ''; + + if (result.ocr_durchgefuehrt) { + alert('Text wurde per OCR extrahiert (Bild-PDF erkannt)'); + } + + // Automatisch analysieren nach Upload + versteckeLoading(); + await analysiereText(); + return; // Loading wird in analysiereText() gehandelt + } catch (error) { + alert('Fehler beim PDF-Upload: ' + error.message); + } finally { + versteckeLoading(); + } + } + + // Input zurücksetzen für erneuten Upload + input.value = ''; +} + +// Regex-Preset aus Dropdown übernehmen +function setzeRegexPreset(typ) { + const select = document.getElementById(`hilfe-${typ}-preset`); + const input = document.getElementById(`hilfe-${typ}-regex`); + if (select.value) { + input.value = select.value; + } + select.value = ''; // Reset dropdown +} + +// ============ Datei-Browser für PDFs aus Quell-Ordnern ============ + +let pdfBrowserOrdner = []; + +async function zeigePdfBrowser() { + document.getElementById('pdf-browser-modal').classList.remove('hidden'); + document.getElementById('pdf-browser-liste').innerHTML = 'Lade Ordner...'; + + try { + // Quell-Ordner laden + const ordner = await api('/ordner'); + pdfBrowserOrdner = ordner; + + if (ordner.length === 0) { + document.getElementById('pdf-browser-liste').innerHTML = + '

    Keine Quell-Ordner konfiguriert

    '; + return; + } + + // Ordner-Auswahl anzeigen + let html = '
    '; + html += ''; + html += '
    '; + html += '
    '; + + document.getElementById('pdf-browser-liste').innerHTML = html; + } catch (error) { + document.getElementById('pdf-browser-liste').innerHTML = + `

    Fehler: ${escapeHtml(error.message)}

    `; + } +} + +async function ladePdfDateien() { + const ordnerId = document.getElementById('pdf-browser-ordner').value; + const container = document.getElementById('pdf-dateien-liste'); + + if (!ordnerId) { + container.innerHTML = ''; + return; + } + + container.innerHTML = 'Scanne Ordner...'; + + try { + const result = await api(`/ordner/${ordnerId}/scannen`); + + if (!result.dateien || result.dateien.length === 0) { + container.innerHTML = '

    Keine Dateien im Ordner

    '; + return; + } + + // Ordner-Daten für Pfad-Konstruktion + const ordner = pdfBrowserOrdner.find(o => o.id == ordnerId); + const basePfad = ordner?.pfad || ''; + + let html = `

    ${result.anzahl} Dateien gefunden:

      `; + for (const datei of result.dateien) { + const fullPath = basePfad + (basePfad.endsWith('/') ? '' : '/') + datei; + html += `
    • + 📄 ${escapeHtml(datei)} +
    • `; + } + html += '
    '; + + container.innerHTML = html; + } catch (error) { + container.innerHTML = `

    Fehler: ${escapeHtml(error.message)}

    `; + } +} + +async function waehleServerPdf(pfad) { + try { + zeigeLoading('Extrahiere Text aus PDF...'); + + const response = await api('/extraktion/from-file', { + method: 'POST', + body: JSON.stringify({ pfad: pfad }) + }); + + document.getElementById('hilfe-text').value = response.text || ''; + schliesseModal('pdf-browser-modal'); + + if (response.ocr_durchgefuehrt) { + alert('Text wurde per OCR extrahiert (Bild-PDF erkannt)'); + } + + // Automatisch analysieren nach Auswahl + versteckeLoading(); + await analysiereText(); + return; + } catch (error) { + alert('Fehler: ' + error.message); + } finally { + versteckeLoading(); + } +} diff --git a/frontend/static/js/browser.js b/frontend/static/js/browser.js new file mode 100644 index 0000000..7e851ea --- /dev/null +++ b/frontend/static/js/browser.js @@ -0,0 +1,1079 @@ +/** + * Dateimanager / Browser JavaScript + * Dual-Pane Datei-Browser mit separatem Vorschau-Fenster + */ + +// ============ State ============ +let state = { + currentPath: '/srv/http/dateiverwaltung/data', + selectedFile: null, + selectedFilePath: null, + files: [], + folders: [], + modalBrowserPath: '/', + verschiebenPath: '/', + folderTree: {}, // Baum-Cache + expandedFolders: new Set(), // Aufgeklappte Ordner + previewWindow: null, // Referenz zum Vorschau-Fenster + previewWindowOpen: false, + previewPanelHidden: false, // Preview-Panel ausgeblendet + isVerticalMode: false // Vertikaler Layout-Modus +}; + +// ============ BroadcastChannel für Vorschau-Fenster ============ +const previewChannel = new BroadcastChannel('dateiverwaltung-preview'); + +previewChannel.onmessage = (event) => { + const { type } = event.data; + + switch (type) { + case 'preview-window-ready': + state.previewWindowOpen = true; + aktualisierePreviewButton(); + aktualisiereHidePreviewButton(); + // Aktuelle Datei an Vorschau senden + if (state.selectedFilePath) { + sendeAnVorschau(state.selectedFilePath, state.selectedFile); + } + break; + case 'preview-window-closed': + state.previewWindowOpen = false; + state.previewWindow = null; + aktualisierePreviewButton(); + aktualisiereHidePreviewButton(); + break; + case 'pong': + state.previewWindowOpen = true; + aktualisierePreviewButton(); + aktualisiereHidePreviewButton(); + break; + } +}; + +function sendeAnVorschau(pfad, name) { + previewChannel.postMessage({ + type: 'preview', + data: { path: pfad, name: name } + }); +} + +function aktualisierePreviewButton() { + const btn = document.getElementById('btn-open-preview'); + if (btn) { + if (state.previewWindowOpen) { + btn.textContent = '🖥️ Vorschau-Fenster aktiv'; + btn.classList.add('active'); + } else { + btn.textContent = '🖥️ Vorschau-Fenster öffnen'; + btn.classList.remove('active'); + } + } +} + +function aktualisiereHidePreviewButton() { + const btnHide = document.getElementById('btn-hide-preview'); + const btnShow = document.getElementById('btn-show-preview'); + + // "Ausblenden" Button nur zeigen wenn Preview-Fenster aktiv + if (btnHide) { + if (state.previewWindowOpen && !state.previewPanelHidden) { + btnHide.classList.remove('hidden'); + } else { + btnHide.classList.add('hidden'); + } + } + + // "Einblenden" Button zeigen wenn Panel ausgeblendet + if (btnShow) { + if (state.previewPanelHidden) { + btnShow.classList.remove('hidden'); + } else { + btnShow.classList.add('hidden'); + } + } +} + +// ============ API Helper ============ +async function api(endpoint, options = {}) { + const url = `/api${endpoint}`; + const config = { + headers: { 'Content-Type': 'application/json' }, + ...options + }; + + try { + const response = await fetch(url, config); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + throw error; + } +} + +// ============ Initialisierung ============ +document.addEventListener('DOMContentLoaded', () => { + ladeTheme(); + pruefeLayoutModus(); + initResizeHandles(); + + // Gespeicherten Pfad laden + const gespeicherterPfad = localStorage.getItem('browser_path'); + if (gespeicherterPfad) { + state.currentPath = gespeicherterPfad; + } + + // Aufgeklappte Ordner laden + const gespeicherteExpanded = localStorage.getItem('browser_expanded'); + if (gespeicherteExpanded) { + try { + state.expandedFolders = new Set(JSON.parse(gespeicherteExpanded)); + } catch (e) {} + } + + // Preview-Panel Status laden + const previewHidden = localStorage.getItem('browser_preview_hidden'); + if (previewHidden === 'true') { + state.previewPanelHidden = true; + togglePreviewPanelUI(true); + } + + // Baum initialisieren + ladeBaum('/'); + ladeOrdnerInhalt(state.currentPath); + + // Keyboard shortcuts + document.addEventListener('keydown', handleKeydown); + + // Context menu schließen bei Klick außerhalb + document.addEventListener('click', () => { + const menu = document.querySelector('.context-menu'); + if (menu) menu.remove(); + }); + + // Prüfen ob Vorschau-Fenster bereits offen + previewChannel.postMessage({ type: 'ping' }); + + // Layout bei Resize prüfen + window.addEventListener('resize', pruefeLayoutModus); +}); + +// ============ Layout Modus ============ +function pruefeLayoutModus() { + const istVertical = window.innerWidth <= 1000; + if (istVertical !== state.isVerticalMode) { + state.isVerticalMode = istVertical; + const main = document.getElementById('browser-main'); + if (main) { + if (istVertical) { + main.classList.add('vertical'); + } else { + main.classList.remove('vertical'); + } + } + // Resize-Handles neu initialisieren + initResizeHandles(); + } +} + +// ============ Theme ============ +function ladeTheme() { + const gespeichertesTheme = localStorage.getItem('theme') || 'auto'; + wendeThemeAn(gespeichertesTheme); + document.getElementById('theme-select').value = gespeichertesTheme; +} + +function wendeThemeAn(theme) { + const html = document.documentElement; + if (theme === 'auto') { + html.removeAttribute('data-theme'); + } else { + html.setAttribute('data-theme', theme); + } +} + +function wechsleTheme(theme) { + wendeThemeAn(theme); + localStorage.setItem('theme', theme); +} + +// ============ Preview Panel Toggle ============ +function togglePreviewPanel() { + state.previewPanelHidden = !state.previewPanelHidden; + togglePreviewPanelUI(state.previewPanelHidden); + localStorage.setItem('browser_preview_hidden', state.previewPanelHidden); + aktualisiereHidePreviewButton(); +} + +function togglePreviewPanelUI(hidden) { + const previewPane = document.getElementById('pane-preview'); + const resizeHandle = document.getElementById('resize-handle-2'); + const listPane = document.getElementById('pane-list'); + + if (previewPane) { + if (hidden) { + previewPane.classList.add('hidden-panel'); + } else { + previewPane.classList.remove('hidden-panel'); + } + } + + if (resizeHandle) { + if (hidden) { + resizeHandle.classList.add('hidden-panel'); + } else { + resizeHandle.classList.remove('hidden-panel'); + } + } + + // Dateiliste expandieren wenn Preview ausgeblendet + if (listPane) { + if (hidden) { + listPane.classList.add('expanded'); + listPane.style.width = ''; // Inline-Style entfernen + } else { + listPane.classList.remove('expanded'); + } + } +} + +// ============ Ordner-Baum ============ +async function ladeBaum(startPfad) { + try { + const result = await api(`/browse?path=${encodeURIComponent(startPfad)}`); + if (result.error) return; + + // Root-Knoten + if (startPfad === '/') { + state.folderTree['/'] = { + name: '/', + path: '/', + children: result.entries + .filter(e => e.type === 'directory') + .map(e => e.path) + }; + } + + // Unterordner cachen + result.entries.forEach(entry => { + if (entry.type === 'directory') { + if (!state.folderTree[entry.path]) { + state.folderTree[entry.path] = { + name: entry.name, + path: entry.path, + children: null, // Noch nicht geladen + loaded: false + }; + } + } + }); + + aktualisiereBaumAnzeige(); + } catch (e) { + console.error('Fehler beim Laden des Baums:', e); + } +} + +async function ladeBaumKnoten(pfad) { + try { + const result = await api(`/browse?path=${encodeURIComponent(pfad)}`); + if (result.error) return; + + const children = result.entries + .filter(e => e.type === 'directory') + .map(e => e.path); + + state.folderTree[pfad] = { + ...state.folderTree[pfad], + children: children, + loaded: true + }; + + // Kinder registrieren + result.entries.forEach(entry => { + if (entry.type === 'directory' && !state.folderTree[entry.path]) { + state.folderTree[entry.path] = { + name: entry.name, + path: entry.path, + children: null, + loaded: false + }; + } + }); + + aktualisiereBaumAnzeige(); + } catch (e) { + console.error('Fehler beim Laden des Knotens:', e); + } +} + +function aktualisiereBaumAnzeige() { + const container = document.getElementById('folder-tree'); + if (!container) return; + + // Standard-Startordner + const startPfade = ['/', '/mnt', '/srv', '/home']; + let html = ''; + + startPfade.forEach(pfad => { + html += renderBaumKnoten(pfad, 0); + }); + + container.innerHTML = html || '

    Keine Ordner

    '; +} + +function renderBaumKnoten(pfad, tiefe) { + const knoten = state.folderTree[pfad]; + if (!knoten) { + // Knoten existiert nicht im Cache - erstelle Platzhalter + return ` +
    + + 📁 + ${pfad.split('/').pop() || pfad} +
    + `; + } + + const istExpanded = state.expandedFolders.has(pfad); + const istAktiv = state.currentPath === pfad || state.currentPath.startsWith(pfad + '/'); + const hatKinder = knoten.children && knoten.children.length > 0; + const istGeladen = knoten.loaded; + + let html = ` +
    + + ${hatKinder || !istGeladen ? (istExpanded ? '▼' : '▶') : ''} + + ${istExpanded ? '📂' : '📁'} + ${knoten.name} +
    + `; + + // Kinder rendern wenn aufgeklappt + if (istExpanded && knoten.children) { + knoten.children.forEach(childPfad => { + html += renderBaumKnoten(childPfad, tiefe + 1); + }); + } + + return html; +} + +async function toggleBaum(pfad) { + if (state.expandedFolders.has(pfad)) { + state.expandedFolders.delete(pfad); + } else { + state.expandedFolders.add(pfad); + // Laden wenn noch nicht geladen + const knoten = state.folderTree[pfad]; + if (!knoten || !knoten.loaded) { + await ladeBaumKnoten(pfad); + } + } + + // Speichern + localStorage.setItem('browser_expanded', JSON.stringify([...state.expandedFolders])); + aktualisiereBaumAnzeige(); +} + +async function expandiereBaum(pfad) { + if (!state.expandedFolders.has(pfad)) { + state.expandedFolders.add(pfad); + const knoten = state.folderTree[pfad]; + if (!knoten || !knoten.loaded) { + await ladeBaumKnoten(pfad); + } + localStorage.setItem('browser_expanded', JSON.stringify([...state.expandedFolders])); + aktualisiereBaumAnzeige(); + } +} + +function waehleBaumOrdner(pfad) { + navigiereZu(pfad); +} + +// ============ Ordner Navigation ============ +async function ladeOrdnerInhalt(pfad) { + try { + const result = await api(`/browse/files?path=${encodeURIComponent(pfad)}`); + + if (result.error) { + toast(result.error, 'error'); + return; + } + + state.currentPath = result.current; + state.folders = result.folders || []; + state.files = result.files || []; + + // Pfad speichern + localStorage.setItem('browser_path', state.currentPath); + + // UI aktualisieren + aktualisiereBreadcrumb(); + aktualisiereDateiliste(); + aktualisiereBaumAnzeige(); + + // Vorschau zurücksetzen + if (state.selectedFile) { + const existiert = state.files.some(f => f.name === state.selectedFile); + if (!existiert) { + state.selectedFile = null; + state.selectedFilePath = null; + if (!state.previewWindowOpen && !state.previewPanelHidden) { + zeigeLeereVorschau(); + } + } + } + + } catch (error) { + toast('Fehler beim Laden: ' + error.message, 'error'); + } +} + +function aktualisiereBreadcrumb() { + const container = document.getElementById('breadcrumb'); + const teile = state.currentPath.split('/').filter(t => t); + + let html = `/`; + let pfad = ''; + + teile.forEach((teil, index) => { + pfad += '/' + teil; + const istLetzter = index === teile.length - 1; + + if (istLetzter) { + html += `/`; + html += `${teil}`; + } else { + html += `/`; + html += `${teil}`; + } + }); + + container.innerHTML = html; +} + +function aktualisiereDateiliste() { + const container = document.getElementById('file-list'); + const countEl = document.getElementById('file-count'); + + // Ordner + Dateien zählen + const totalCount = state.folders.length + state.files.length; + countEl.textContent = `${state.folders.length} Ordner, ${state.files.length} Dateien`; + + let html = ''; + + // Ordner zuerst + state.folders.forEach(folder => { + html += ` +
    + 📁 + ${folder.name} + +
    + `; + }); + + // Dateien + state.files.forEach(file => { + const isSelected = state.selectedFile === file.name; + const icon = getFileIcon(file.name); + const size = formatSize(file.size); + + html += ` +
    + ${icon} + ${file.name} + ${size} +
    + `; + }); + + if (!html) { + html = '

    Keine Dateien in diesem Ordner

    '; + } + + container.innerHTML = html; +} + +function getFileIcon(name) { + const ext = name.split('.').pop().toLowerCase(); + const icons = { + 'pdf': '📄', + 'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'bmp': '🖼️', 'tiff': '🖼️', 'webp': '🖼️', + 'doc': '📝', 'docx': '📝', 'odt': '📝', + 'xls': '📊', 'xlsx': '📊', 'ods': '📊', 'csv': '📊', + 'zip': '📦', 'rar': '📦', '7z': '📦', 'tar': '📦', 'gz': '📦', + 'txt': '📃', 'md': '📃', 'log': '📃', + 'mp3': '🎵', 'wav': '🎵', 'flac': '🎵', + 'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', + 'xml': '📋', 'json': '📋', 'html': '📋' + }; + return icons[ext] || '📎'; +} + +function formatSize(bytes) { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit++; + } + return `${size.toFixed(unit > 0 ? 1 : 0)} ${units[unit]}`; +} + +function navigiereZu(pfad) { + ladeOrdnerInhalt(pfad); + // Ordner im Baum aufklappen + expandiereBaumPfad(pfad); +} + +function expandiereBaumPfad(pfad) { + // Alle übergeordneten Ordner aufklappen + const teile = pfad.split('/').filter(t => t); + let aktuell = ''; + teile.forEach(teil => { + aktuell += '/' + teil; + if (!state.expandedFolders.has(aktuell) && aktuell !== pfad) { + state.expandedFolders.add(aktuell); + } + }); + localStorage.setItem('browser_expanded', JSON.stringify([...state.expandedFolders])); +} + +function ordnerHoch() { + const parent = state.currentPath.split('/').slice(0, -1).join('/') || '/'; + navigiereZu(parent); +} + +function ordnerAktualisieren() { + ladeOrdnerInhalt(state.currentPath); + toast('Aktualisiert', 'info'); +} + +// ============ Vorschau-Fenster ============ +function oeffneVorschauFenster() { + if (state.previewWindow && !state.previewWindow.closed) { + state.previewWindow.focus(); + return; + } + + const width = 800; + const height = 600; + const left = window.screenX + window.outerWidth - width - 50; + const top = window.screenY + 50; + + state.previewWindow = window.open( + '/browser/preview', + 'dateiverwaltung-preview', + `width=${width},height=${height},left=${left},top=${top},resizable=yes` + ); +} + +// ============ Datei-Auswahl und Vorschau ============ +function waehltDatei(name) { + state.selectedFile = name; + state.selectedFilePath = state.currentPath + '/' + name; + + // UI aktualisieren + document.querySelectorAll('.file-item').forEach(el => { + el.classList.remove('selected'); + }); + event.currentTarget.classList.add('selected'); + + // Datei-Info anzeigen (nur wenn Panel sichtbar) + const file = state.files.find(f => f.name === name); + if (file && !state.previewPanelHidden) { + document.getElementById('file-info').classList.remove('hidden'); + document.getElementById('preview-filename').textContent = name; + document.getElementById('preview-size').textContent = formatSize(file.size); + document.getElementById('btn-extern').classList.remove('hidden'); + } + + // Vorschau laden - entweder in separatem Fenster oder inline + if (state.previewWindowOpen) { + sendeAnVorschau(state.selectedFilePath, name); + // Inline-Vorschau zeigt Hinweis (nur wenn Panel sichtbar) + if (!state.previewPanelHidden) { + document.getElementById('preview-container').innerHTML = ` +
    + Vorschau wird im separaten Fenster angezeigt +
    + `; + } + } else if (!state.previewPanelHidden) { + ladeVorschau(state.selectedFilePath, name); + } +} + +async function ladeVorschau(pfad, name) { + const container = document.getElementById('preview-container'); + if (!container) return; + + const ext = name.split('.').pop().toLowerCase(); + + // PDF Vorschau - mit fit-to-page für erste Seite + if (ext === 'pdf') { + container.innerHTML = ``; + return; + } + + // Bild Vorschau + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'].includes(ext)) { + container.innerHTML = `${name}`; + return; + } + + // Text-basierte Dateien + if (['txt', 'md', 'log', 'xml', 'json', 'csv', 'html', 'css', 'js'].includes(ext)) { + try { + const result = await api(`/file/text?path=${encodeURIComponent(pfad)}`); + if (result.content) { + container.innerHTML = `
    ${escapeHtml(result.content)}
    `; + } else { + zeigeKeineVorschau(name, ext); + } + } catch (e) { + zeigeKeineVorschau(name, ext); + } + return; + } + + // Keine Vorschau verfügbar + zeigeKeineVorschau(name, ext); +} + +function zeigeKeineVorschau(name, ext) { + const icon = getFileIcon(name); + const container = document.getElementById('preview-container'); + if (container) { + container.innerHTML = ` +
    +
    ${icon}
    +

    Keine Vorschau für .${ext} Dateien

    + +
    + `; + } +} + +function zeigeLeereVorschau() { + const fileInfo = document.getElementById('file-info'); + const btnExtern = document.getElementById('btn-extern'); + const container = document.getElementById('preview-container'); + + if (fileInfo) fileInfo.classList.add('hidden'); + if (btnExtern) btnExtern.classList.add('hidden'); + if (container) { + container.innerHTML = ` +
    + Datei auswählen um Vorschau zu sehen +
    + `; + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ============ Datei-Operationen ============ +function dateiUmbenennen() { + if (!state.selectedFile) return; + + document.getElementById('neuer-name').value = state.selectedFile; + oeffneModal('umbenennen-modal'); + + setTimeout(() => { + const input = document.getElementById('neuer-name'); + input.focus(); + const dotIndex = state.selectedFile.lastIndexOf('.'); + if (dotIndex > 0) { + input.setSelectionRange(0, dotIndex); + } else { + input.select(); + } + }, 100); +} + +async function umbenennenBestaetigen() { + const neuerName = document.getElementById('neuer-name').value.trim(); + if (!neuerName) { + toast('Bitte Namen eingeben', 'warning'); + return; + } + + if (neuerName === state.selectedFile) { + schliesseModal('umbenennen-modal'); + return; + } + + try { + const result = await api('/file/rename', { + method: 'POST', + body: JSON.stringify({ + pfad: state.selectedFilePath, + neuer_name: neuerName + }) + }); + + if (result.erfolg) { + toast('Datei umbenannt', 'success'); + schliesseModal('umbenennen-modal'); + state.selectedFile = neuerName; + state.selectedFilePath = state.currentPath + '/' + neuerName; + ladeOrdnerInhalt(state.currentPath); + } else { + toast(result.fehler || 'Umbenennen fehlgeschlagen', 'error'); + } + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +function dateiVerschieben() { + if (!state.selectedFile) return; + + document.getElementById('verschieben-datei').textContent = state.selectedFile; + state.verschiebenPath = state.currentPath; + ladeVerschiebenBrowser(state.verschiebenPath); + oeffneModal('verschieben-modal'); +} + +async function ladeVerschiebenBrowser(pfad) { + try { + const result = await api(`/browse?path=${encodeURIComponent(pfad)}`); + + if (result.error) { + toast(result.error, 'error'); + return; + } + + state.verschiebenPath = result.current; + document.getElementById('verschieben-browser-path').textContent = result.current; + + let html = ''; + + if (result.parent) { + html += `
  • + 📁 .. +
  • `; + } + + result.entries.forEach(entry => { + if (entry.type === 'directory') { + html += `
  • + 📁 ${entry.name} +
  • `; + } + }); + + if (!html) { + html = '
  • Keine Unterordner
  • '; + } + + document.getElementById('verschieben-browser-list').innerHTML = html; + + } catch (e) { + toast('Fehler beim Laden', 'error'); + } +} + +async function verschiebenBestaetigen() { + if (state.verschiebenPath === state.currentPath) { + toast('Zielordner ist der aktuelle Ordner', 'warning'); + return; + } + + try { + const result = await api('/file/move', { + method: 'POST', + body: JSON.stringify({ + pfad: state.selectedFilePath, + ziel_ordner: state.verschiebenPath + }) + }); + + if (result.erfolg) { + toast('Datei verschoben', 'success'); + schliesseModal('verschieben-modal'); + state.selectedFile = null; + state.selectedFilePath = null; + zeigeLeereVorschau(); + ladeOrdnerInhalt(state.currentPath); + } else { + toast(result.fehler || 'Verschieben fehlgeschlagen', 'error'); + } + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +function dateiLoeschen() { + if (!state.selectedFile) return; + + document.getElementById('loeschen-datei').textContent = state.selectedFile; + oeffneModal('loeschen-modal'); +} + +async function loeschenBestaetigen() { + try { + const result = await api('/file/delete', { + method: 'DELETE', + body: JSON.stringify({ + pfad: state.selectedFilePath + }) + }); + + if (result.erfolg) { + toast('Datei gelöscht', 'success'); + schliesseModal('loeschen-modal'); + state.selectedFile = null; + state.selectedFilePath = null; + zeigeLeereVorschau(); + ladeOrdnerInhalt(state.currentPath); + } else { + toast(result.fehler || 'Löschen fehlgeschlagen', 'error'); + } + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +function dateiExternOeffnen() { + if (!state.selectedFilePath) return; + window.open(`/api/file/download?path=${encodeURIComponent(state.selectedFilePath)}`, '_blank'); +} + +// ============ Drag & Drop ============ +function handleDragStart(event, pfad) { + event.dataTransfer.setData('text/plain', pfad); + event.dataTransfer.effectAllowed = 'move'; +} + +async function handleDrop(event, zielOrdner) { + event.preventDefault(); + event.currentTarget.classList.remove('drop-target'); + + const quellPfad = event.dataTransfer.getData('text/plain'); + if (!quellPfad || quellPfad === zielOrdner) return; + + try { + const result = await api('/file/move', { + method: 'POST', + body: JSON.stringify({ + pfad: quellPfad, + ziel_ordner: zielOrdner + }) + }); + + if (result.erfolg) { + toast('Datei verschoben', 'success'); + ladeOrdnerInhalt(state.currentPath); + } else { + toast(result.fehler || 'Verschieben fehlgeschlagen', 'error'); + } + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +// ============ Context Menu ============ +function zeigeContextMenu(event, dateiname) { + event.preventDefault(); + event.stopPropagation(); + + const vorher = document.querySelector('.context-menu'); + if (vorher) vorher.remove(); + + state.selectedFile = dateiname; + state.selectedFilePath = state.currentPath + '/' + dateiname; + + const menu = document.createElement('div'); + menu.className = 'context-menu'; + menu.innerHTML = ` +
    🔗 Öffnen
    +
    +
    ✏️ Umbenennen
    +
    📦 Verschieben
    +
    +
    🗑 Löschen
    + `; + + menu.style.left = event.clientX + 'px'; + menu.style.top = event.clientY + 'px'; + + document.body.appendChild(menu); + + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = (window.innerWidth - rect.width - 10) + 'px'; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = (window.innerHeight - rect.height - 10) + 'px'; + } +} + +// ============ Keyboard Shortcuts ============ +function handleKeydown(event) { + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; + + switch (event.key) { + case 'F2': + event.preventDefault(); + if (state.selectedFile) dateiUmbenennen(); + break; + case 'Delete': + event.preventDefault(); + if (state.selectedFile) dateiLoeschen(); + break; + case 'F5': + event.preventDefault(); + ordnerAktualisieren(); + break; + case 'Backspace': + event.preventDefault(); + ordnerHoch(); + break; + case 'Enter': + if (state.selectedFile) { + dateiExternOeffnen(); + } + break; + } +} + +// ============ Resize Handles ============ +function initResizeHandles() { + const main = document.getElementById('browser-main'); + const treePane = document.getElementById('pane-tree'); + const listPane = document.getElementById('pane-list'); + const handle1 = document.getElementById('resize-handle-1'); + const handle2 = document.getElementById('resize-handle-2'); + + if (!main || !treePane || !listPane) return; + + const isVertical = main.classList.contains('vertical') || window.innerWidth <= 1000; + + // Handle 1: Zwischen Baum und Liste + if (handle1) { + let isResizing = false; + + handle1.onmousedown = (e) => { + isResizing = true; + handle1.classList.add('active'); + document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }; + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + if (isVertical) { + // Vertikaler Modus: Höhe ändern + const containerTop = main.getBoundingClientRect().top; + const newHeight = Math.max(80, Math.min(e.clientY - containerTop, 300)); + treePane.style.height = newHeight + 'px'; + } else { + // Horizontaler Modus: Breite ändern + const containerLeft = main.getBoundingClientRect().left; + const newWidth = Math.max(150, Math.min(e.clientX - containerLeft, 400)); + treePane.style.width = newWidth + 'px'; + } + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + handle1.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }); + } + + // Handle 2: Zwischen Liste und Vorschau + if (handle2) { + let isResizing = false; + + handle2.onmousedown = (e) => { + isResizing = true; + handle2.classList.add('active'); + document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }; + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + if (isVertical) { + // Vertikaler Modus: Höhe ändern + const treePaneHeight = treePane.offsetHeight; + const handle1Height = handle1 ? handle1.offsetHeight : 0; + const containerTop = main.getBoundingClientRect().top; + const offset = treePaneHeight + handle1Height; + const newHeight = Math.max(100, Math.min(e.clientY - containerTop - offset, 400)); + listPane.style.height = newHeight + 'px'; + } else { + // Horizontaler Modus: Breite ändern + const treePaneWidth = treePane.offsetWidth; + const handle1Width = handle1 ? handle1.offsetWidth : 0; + const containerLeft = main.getBoundingClientRect().left; + const offset = treePaneWidth + handle1Width; + const newWidth = Math.max(200, Math.min(e.clientX - containerLeft - offset, 600)); + listPane.style.width = newWidth + 'px'; + } + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + handle2.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }); + } +} + +// ============ Modal Helper ============ +function oeffneModal(id) { + document.getElementById(id).classList.remove('hidden'); +} + +function schliesseModal(id) { + document.getElementById(id).classList.add('hidden'); +} + +// ============ Toast Notifications ============ +function toast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'fadeOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3000); +} diff --git a/frontend/templates/browser.html b/frontend/templates/browser.html new file mode 100644 index 0000000..cc427c9 --- /dev/null +++ b/frontend/templates/browser.html @@ -0,0 +1,178 @@ + + + + + + Dateimanager - Dateiverwaltung + + + + +
    + +
    +
    + ← Hauptseite +

    Dateimanager

    +
    +
    + + + +
    +
    + + +
    +
    + + +
    + +
    + 0 Dateien +
    +
    + + +
    + +
    +
    + 📁 Ordner +
    +
    +

    Lade Ordner...

    +
    +
    + + +
    + + +
    +
    + 📄 Dateien +
    +
    +

    Keine Dateien

    +
    +
    + + +
    + + +
    +
    + 👁 Vorschau +
    + + +
    +
    + + + + + +
    +
    + Datei auswählen um Vorschau zu sehen +
    +
    +
    +
    + + + + + + + + + + + +
    +
    + + + + diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 2ecef1e..d6c7961 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -14,6 +14,15 @@

    Dateiverwaltung

    + 📂 Dateimanager + + +
    @@ -83,24 +92,77 @@ - +
    -

    Sortier-Regeln

    - +

    Schnell-Regeln (Grob-Sortierung)

    +
    + +
    +

    Sortiert automatisch nach Dateityp/Eigenschaften. Wird vor den Fein-Regeln angewendet.

    +
    +

    Keine Schnell-Regeln definiert

    +
    +
    +
    + + +
    +
    +

    Fein-Regeln (nach Inhalt)

    +
    + + +
    +
    +
    +

    Sortiert nach Textinhalt (Keywords, Regex). Höhere Priorität = wird später geprüft.

    Keine Regeln definiert

    - + +
    +
    +

    OCR-Einstellungen

    +
    +
    +
    + + Originale werden gesichert bevor Text eingebettet wird +
    + +
    +
    + +
    - +
    + + + +
    + Testlauf zeigt was passieren würde, ohne Dateien zu verschieben
    @@ -168,6 +230,11 @@ +
    + + + Nur Mails ab diesem Datum abrufen (leer = alle) +
    @@ -313,7 +380,10 @@
    - +
    + + +
    Wird an den Ziel-Ordner des Quell-Ordners angehängt
    @@ -354,6 +424,282 @@
    + + + + + + + + + + + +