Version 1.1: Dateimanager mit 3-Panel Layout

Neue Features:
- 3-Panel Dateimanager (Ordnerbaum, Dateiliste, Vorschau)
- Separates Vorschau-Fenster für zweiten Monitor
- Resize-Handles für flexible Panel-Größen (horizontal & vertikal)
- Vorschau-Panel ausblendbar wenn externes Fenster aktiv
- Natürliche Sortierung (Sonderzeichen → Zahlen → Buchstaben)
- PDF-Vorschau mit Fit-to-Page
- Email-Attachment Abruf erweitert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-02 12:51:40 +01:00
parent 013b037322
commit e91d554ce1
19 changed files with 6324 additions and 108 deletions

View file

@ -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 # Zeitzone
TZ=Europe/Berlin 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 # OCR Einstellungen
# ----------------------------------------------
# Sprache für OCR (deu = Deutsch, eng = Englisch)
# Mehrere Sprachen: deu+eng
OCR_LANGUAGE=deu OCR_LANGUAGE=deu
# DPI für OCR-Verarbeitung (höher = besser, aber langsamer)
OCR_DPI=300 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-... # CLAUDE_API_KEY=sk-ant-...

View file

@ -21,7 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
# Anwendung kopieren # Anwendung kopieren
COPY backend/ ./backend/ COPY backend/ ./backend/
COPY frontend/ ./frontend/ COPY frontend/ ./frontend/
COPY config/ ./config/
COPY regeln/ ./regeln/ COPY regeln/ ./regeln/
# Daten-Verzeichnis # Daten-Verzeichnis

489
INSTALLATION.md Normal file
View file

@ -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 <repository-url> /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://<server-ip>:8000`
- Dateimanager: `http://<server-ip>: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://<server-ip>: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://<server-ip>: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://<ip>: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` |

View file

@ -51,6 +51,18 @@ async def index(request: Request):
return templates.TemplateResponse("index.html", {"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") @app.get("/health")
async def health(): async def health():
"""Health Check für Docker""" """Health Check für Docker"""

View file

@ -28,6 +28,7 @@ class Postfach(Base):
ordner = Column(String(100), default="INBOX") ordner = Column(String(100), default="INBOX")
alle_ordner = Column(Boolean, default=False) # Alle IMAP-Ordner durchsuchen alle_ordner = Column(Boolean, default=False) # Alle IMAP-Ordner durchsuchen
nur_ungelesen = Column(Boolean, default=False) # Nur ungelesene Mails (False = alle) nur_ungelesen = Column(Boolean, default=False) # Nur ungelesene Mails (False = alle)
ab_datum = Column(DateTime, nullable=True) # Nur Mails ab diesem Datum
# Ziel # Ziel
ziel_ordner = Column(String(500), nullable=False) ziel_ordner = Column(String(500), nullable=False)
@ -121,7 +122,8 @@ def migrate_db():
migrations = { migrations = {
"postfaecher": { "postfaecher": {
"alle_ordner": "BOOLEAN DEFAULT 0", "alle_ordner": "BOOLEAN DEFAULT 0",
"nur_ungelesen": "BOOLEAN DEFAULT 0" "nur_ungelesen": "BOOLEAN DEFAULT 0",
"ab_datum": "DATETIME"
}, },
"quell_ordner": { "quell_ordner": {
"rekursiv": "BOOLEAN DEFAULT 1", "rekursiv": "BOOLEAN DEFAULT 1",

View file

@ -186,21 +186,59 @@ def extrahiere_nummer(text: str, spezifische_muster: List[Dict] = None) -> Optio
# ============ FIRMA/ABSENDER ============ # ============ FIRMA/ABSENDER ============
FIRMA_MUSTER = [ FIRMA_MUSTER = [
# Absender-Zeile # Rechtsformen direkt (GmbH, AG, etc.) - sehr zuverlässig
{"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", "context": True}, {"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\s+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR)", "context": True},
{"regex": r"Absender[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True},
{"regex": r"Von[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "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 (werden im Text gesucht)
BEKANNTE_FIRMEN = [ BEKANNTE_FIRMEN = [
# Elektronik/IT
"Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt", "Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt",
"Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "Cyberport",
"Telekom", "Vodafone", "O2", "1&1", "Apple", "Microsoft", "Dell", "HP", "Lenovo", "ASUS", "Acer",
"Allianz", "HUK", "Provinzial", "DEVK", "Gothaer",
"IKEA", "Poco", "XXXLutz", "Roller", # Baumärkte
"Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Globus",
"DHL", "DPD", "Hermes", "UPS", "GLS",
# 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: for firma in BEKANNTE_FIRMEN:
if firma.lower() == domain.lower(): if firma.lower() == domain.lower():
return firma return firma
# Domain als Firmenname verwenden (kapitalisiert)
if len(domain) > 2:
return domain.capitalize() return domain.capitalize()
# 3. Regex-Muster # 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()
# 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 muster_liste = (spezifische_muster or []) + FIRMA_MUSTER
for muster in muster_liste: for muster in muster_liste:
try: 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: if match:
firma = match.group(1).strip() 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 return firma
except: except:
continue continue

View file

@ -7,14 +7,77 @@ import email
from email.header import decode_header from email.header import decode_header
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Dict, Optional from typing import List, Dict, Optional, Callable
import logging import logging
import threading
from ..config import INBOX_DIR from ..config import INBOX_DIR
logger = logging.getLogger(__name__) 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: class MailFetcher:
"""Holt Attachments aus einem IMAP-Postfach""" """Holt Attachments aus einem IMAP-Postfach"""
@ -81,7 +144,8 @@ class MailFetcher:
nur_ungelesen: bool = False, nur_ungelesen: bool = False,
markiere_gelesen: bool = False, markiere_gelesen: bool = False,
alle_ordner: 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 Holt alle Attachments die den Filtern entsprechen
@ -114,7 +178,7 @@ class MailFetcher:
for ordner in ordner_liste: for ordner in ordner_liste:
ergebnisse.extend(self._fetch_from_folder( ergebnisse.extend(self._fetch_from_folder(
ordner, ziel, erlaubte_typen, max_groesse, ordner, ziel, erlaubte_typen, max_groesse,
nur_ungelesen, markiere_gelesen, bereits_verarbeitet nur_ungelesen, markiere_gelesen, bereits_verarbeitet, ab_datum
)) ))
return ergebnisse return ergebnisse
@ -122,7 +186,7 @@ class MailFetcher:
def _fetch_from_folder(self, ordner: str, ziel: Path, def _fetch_from_folder(self, ordner: str, ziel: Path,
erlaubte_typen: List[str], max_groesse: int, erlaubte_typen: List[str], max_groesse: int,
nur_ungelesen: bool, markiere_gelesen: bool, 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""" """Holt Attachments aus einem einzelnen Ordner"""
ergebnisse = [] ergebnisse = []
@ -130,7 +194,15 @@ class MailFetcher:
# Ordner auswählen # Ordner auswählen
status, _ = self.connection.select(ordner) status, _ = self.connection.select(ordner)
# Suche nach Mails # 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" search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
status, messages = self.connection.search(None, search_criteria) status, messages = self.connection.search(None, search_criteria)
@ -252,12 +324,17 @@ class MailFetcher:
nur_ungelesen: bool = False, nur_ungelesen: bool = False,
markiere_gelesen: bool = False, markiere_gelesen: bool = False,
alle_ordner: 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 Generator-Version für Streaming - yielded Events während des Abrufs
Args:
abbruch_callback: Funktion die True zurückgibt wenn abgebrochen werden soll
Yields: 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.connection:
if not self.connect(): if not self.connect():
@ -279,10 +356,23 @@ class MailFetcher:
ordner_liste = [self.config.get("ordner", "INBOX")] ordner_liste = [self.config.get("ordner", "INBOX")]
for ordner in ordner_liste: 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} yield {"type": "ordner", "name": ordner}
try: try:
status, _ = self.connection.select(ordner) status, _ = self.connection.select(ordner)
# 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" search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
status, messages = self.connection.search(None, search_criteria) status, messages = self.connection.search(None, search_criteria)
@ -293,6 +383,11 @@ class MailFetcher:
yield {"type": "mails", "ordner": ordner, "anzahl": len(mail_ids)} yield {"type": "mails", "ordner": ordner, "anzahl": len(mail_ids)}
for mail_id in 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: try:
status, msg_data = self.connection.fetch(mail_id, "(RFC822)") status, msg_data = self.connection.fetch(mail_id, "(RFC822)")
if status != "OK": if status != "OK":

View file

@ -29,16 +29,24 @@ except ImportError:
class PDFProcessor: class PDFProcessor:
"""Verarbeitet PDFs: Text-Extraktion, OCR, ZUGFeRD-Erkennung""" """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_language = ocr_language
self.ocr_dpi = ocr_dpi 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 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: 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) pfad = Path(pdf_pfad)
if not pfad.exists(): if not pfad.exists():
@ -51,7 +59,10 @@ class PDFProcessor:
"zugferd_xml": None, "zugferd_xml": None,
"hat_text": False, "hat_text": False,
"ocr_durchgefuehrt": False, "ocr_durchgefuehrt": False,
"seiten": 0 "ist_signiert": False,
"ocr_uebersprungen_grund": None,
"seiten": 0,
"backup_pfad": None
} }
# 1. ZUGFeRD prüfen # 1. ZUGFeRD prüfen
@ -59,23 +70,148 @@ class PDFProcessor:
ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"] ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"]
ergebnis["zugferd_xml"] = zugferd_result.get("xml") 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) text, seiten = self.extrahiere_text(pdf_pfad)
ergebnis["text"] = text ergebnis["text"] = text
ergebnis["seiten"] = seiten ergebnis["seiten"] = seiten
ergebnis["hat_text"] = bool(text and len(text.strip()) > 50) ergebnis["hat_text"] = bool(text and len(text.strip()) > 50)
# 3. OCR falls kein Text (aber NICHT bei ZUGFeRD!) # 4. OCR falls kein Text - aber NICHT bei geschützten PDFs!
if not ergebnis["hat_text"] and not ergebnis["ist_zugferd"]: if not ergebnis["hat_text"]:
logger.info(f"Kein Text gefunden, starte OCR für {pfad.name}") # Prüfen ob OCR-Einbettung sicher ist
ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad) 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: if ocr_erfolg:
ergebnis["text"] = ocr_text ergebnis["text"] = ocr_text
ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50) ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50)
ergebnis["ocr_durchgefuehrt"] = True 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 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]: def extrahiere_text(self, pdf_pfad: str) -> Tuple[str, int]:
""" """
Extrahiert Text aus PDF Extrahiert Text aus PDF
@ -172,9 +308,14 @@ class PDFProcessor:
return ergebnis 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: Returns:
Tuple von (text, erfolg) Tuple von (text, erfolg)
@ -183,31 +324,39 @@ class PDFProcessor:
temp_pfad = pfad.with_suffix(".ocr.pdf") temp_pfad = pfad.with_suffix(".ocr.pdf")
try: try:
# ocrmypdf ausführen # ocrmypdf ausführen - Text wird permanent eingebettet
result = subprocess.run( result = subprocess.run(
[ [
"ocrmypdf", "ocrmypdf",
"--language", self.ocr_language, "--language", self.ocr_language,
"--deskew", # Schräge Scans korrigieren "--deskew", # Schräge Scans korrigieren
"--clean", # Bild verbessern "--clean", # Bild verbessern
"--skip-text", # Seiten mit Text überspringen "--rotate-pages", # Seiten automatisch drehen
"--force-ocr", # OCR erzwingen falls nötig "--skip-text", # Seiten mit vorhandenem Text überspringen
"--output-type", "pdfa", # PDF/A für bessere Kompatibilität
str(pfad), str(pfad),
str(temp_pfad) str(temp_pfad)
], ],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120 # 2 Minuten Timeout timeout=180 # 3 Minuten Timeout
) )
if result.returncode == 0 and temp_pfad.exists(): if result.returncode == 0 and temp_pfad.exists():
if in_place:
# Original mit OCR-Version ersetzen # Original mit OCR-Version ersetzen
pfad.unlink() pfad.unlink()
temp_pfad.rename(pfad) temp_pfad.rename(pfad)
logger.info(f"OCR erfolgreich eingebettet: {pfad.name}")
# Text aus OCR-PDF extrahieren # Text aus OCR-PDF extrahieren
text, _ = self.extrahiere_text(str(pfad)) text, _ = self.extrahiere_text(str(pfad))
return text, True return text, True
else:
# Nur Text extrahieren, temp löschen
text, _ = self.extrahiere_text(str(temp_pfad))
temp_pfad.unlink()
return text, True
else: else:
logger.error(f"OCR Fehler: {result.stderr}") logger.error(f"OCR Fehler: {result.stderr}")
if temp_pfad.exists(): if temp_pfad.exists():
@ -228,6 +377,46 @@ class PDFProcessor:
temp_pfad.unlink() temp_pfad.unlink()
return "", False 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: def extrahiere_metadaten(self, pdf_pfad: str) -> Dict:
"""Extrahiert PDF-Metadaten""" """Extrahiert PDF-Metadaten"""
metadaten = {} metadaten = {}

View file

@ -51,6 +51,70 @@ class Sorter:
text = dokument_info.get("text", "").lower() text = dokument_info.get("text", "").lower()
original_name = dokument_info.get("original_name", "").lower() original_name = dokument_info.get("original_name", "").lower()
absender = dokument_info.get("absender", "").lower() absender = dokument_info.get("absender", "").lower()
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) # keywords (einfache Komma-getrennte Liste - für UI)
if "keywords" in muster: if "keywords" in muster:
@ -321,3 +385,147 @@ def liste_dokumenttypen() -> List[Dict]:
{"id": key, "name": config["name"], "schema": config["schema"]} {"id": key, "name": config["name"], "schema": config["schema"]}
for key, config in DOKUMENTTYPEN.items() 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"]
}

File diff suppressed because it is too large Load diff

View file

@ -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 |
# ==============================================

View file

@ -12,8 +12,13 @@ services:
- ./data:/app/data - ./data:/app/data
# Regeln können außerhalb bearbeitet werden # Regeln können außerhalb bearbeitet werden
- ./regeln:/app/regeln - ./regeln:/app/regeln
# Archiv auf Host mounten (optional, für direkten Zugriff) # Host /mnt einbinden für Zugriff auf Dateien
# - /mnt/user/archiv:/archiv - /mnt:/mnt
# Dev: Source code einbinden
- ./backend:/app/backend
- ./frontend:/app/frontend
# Zugriff auf /srv für Dateimanager
- /srv:/srv
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- DATABASE_URL=sqlite:////app/data/dateiverwaltung.db - DATABASE_URL=sqlite:////app/data/dateiverwaltung.db

View file

@ -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);
}
}

View file

@ -1,4 +1,5 @@
/* ============ Variables ============ */ /* ============ Variables ============ */
/* Default Theme (Original Dark) */
:root { :root {
--primary: #3b82f6; --primary: #3b82f6;
--primary-dark: #2563eb; --primary-dark: #2563eb;
@ -15,6 +16,76 @@
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); --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 ============ */ /* ============ Reset & Base ============ */
* { * {
margin: 0; margin: 0;
@ -173,6 +244,20 @@ body {
margin-bottom: 1rem; 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 Items ============ */
.config-item { .config-item {
display: flex; display: flex;
@ -382,6 +467,76 @@ body {
.badge-warning { background: var(--warning); color: #000; } .badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); } .badge-danger { background: var(--danger); }
.badge-info { background: var(--primary); } .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 ============ */
.loading-overlay { .loading-overlay {
@ -541,3 +696,411 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary); 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);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dateimanager - Dateiverwaltung</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/browser.css">
</head>
<body>
<div id="app" class="browser-app">
<!-- Header -->
<header class="header">
<div class="header-left">
<a href="/" class="back-link">← Hauptseite</a>
<h1>Dateimanager</h1>
</div>
<div class="header-right">
<button id="btn-show-preview" class="btn btn-sm hidden" onclick="togglePreviewPanel()" title="Vorschau-Panel einblenden">
👁 Vorschau einblenden
</button>
<button id="btn-open-preview" class="btn btn-sm" onclick="oeffneVorschauFenster()" title="Vorschau in separatem Fenster öffnen">
🖥️ Vorschau-Fenster öffnen
</button>
<select id="theme-select" class="theme-select" onchange="wechsleTheme(this.value)">
<option value="auto">🎨 Auto</option>
<option value="dark">🌙 Dark</option>
<option value="breeze-dark">🌙 Breeze Dark</option>
<option value="breeze-light">☀️ Breeze Light</option>
</select>
</div>
</header>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<button class="btn btn-sm" onclick="ordnerHoch()" title="Eine Ebene hoch (Backspace)">⬆️</button>
<button class="btn btn-sm" onclick="ordnerAktualisieren()" title="Aktualisieren (F5)">🔄</button>
</div>
<div class="breadcrumb-bar">
<div id="breadcrumb" class="breadcrumb"></div>
</div>
<div class="toolbar-right">
<span id="file-count" class="file-count">0 Dateien</span>
</div>
</div>
<!-- Main Browser Area: 3 Panels -->
<div class="browser-main" id="browser-main">
<!-- Panel 1: Ordner-Baum -->
<div class="pane pane-tree" id="pane-tree">
<div class="pane-header">
<span>📁 Ordner</span>
</div>
<div id="folder-tree" class="folder-tree">
<p class="empty-state">Lade Ordner...</p>
</div>
</div>
<!-- Resize Handle 1 (zwischen Baum und Liste) -->
<div id="resize-handle-1" class="resize-handle resize-handle-v"></div>
<!-- Panel 2: Dateiliste -->
<div class="pane pane-list" id="pane-list">
<div class="pane-header">
<span>📄 Dateien</span>
</div>
<div id="file-list" class="file-list">
<p class="empty-state">Keine Dateien</p>
</div>
</div>
<!-- Resize Handle 2 (zwischen Liste und Vorschau) -->
<div id="resize-handle-2" class="resize-handle resize-handle-h"></div>
<!-- Panel 3: Vorschau -->
<div class="pane pane-preview" id="pane-preview">
<div class="pane-header">
<span>👁 Vorschau</span>
<div class="pane-toolbar">
<button id="btn-hide-preview" class="btn btn-sm hidden" onclick="togglePreviewPanel()" title="Vorschau-Panel ausblenden">
👁‍🗨 Ausblenden
</button>
<button id="btn-extern" class="btn btn-sm hidden" onclick="dateiExternOeffnen()" title="Extern öffnen">🔗</button>
</div>
</div>
<!-- Datei-Info -->
<div id="file-info" class="file-info hidden">
<div class="file-info-name">
<span id="preview-filename"></span>
<span id="preview-size" class="file-size"></span>
</div>
<div class="file-actions">
<button class="btn btn-sm" onclick="dateiUmbenennen()">✏️ Umbenennen</button>
<button class="btn btn-sm" onclick="dateiVerschieben()">📦 Verschieben</button>
<button class="btn btn-sm btn-danger" onclick="dateiLoeschen()">🗑 Löschen</button>
</div>
</div>
<!-- Vorschau-Bereich -->
<div id="preview-container" class="preview-container">
<div class="preview-placeholder">
<span>Datei auswählen um Vorschau zu sehen</span>
</div>
</div>
</div>
</div>
<!-- Modal: Umbenennen -->
<div id="umbenennen-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Datei umbenennen</h3>
<button class="modal-close" onclick="schliesseModal('umbenennen-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Neuer Name</label>
<input type="text" id="neuer-name" placeholder="Neuer Dateiname">
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('umbenennen-modal')">Abbrechen</button>
<button class="btn btn-primary" onclick="umbenennenBestaetigen()">Umbenennen</button>
</div>
</div>
</div>
<!-- Modal: Verschieben -->
<div id="verschieben-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Datei verschieben</h3>
<button class="modal-close" onclick="schliesseModal('verschieben-modal')">&times;</button>
</div>
<div class="modal-body">
<p class="modal-hint">Wähle den Zielordner für: <strong id="verschieben-datei"></strong></p>
<div class="file-browser">
<div class="file-browser-path">
<span id="verschieben-browser-path">/</span>
</div>
<ul class="file-browser-list" id="verschieben-browser-list"></ul>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('verschieben-modal')">Abbrechen</button>
<button class="btn btn-primary" onclick="verschiebenBestaetigen()">Hierhin verschieben</button>
</div>
</div>
</div>
<!-- Modal: Löschen bestätigen -->
<div id="loeschen-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Datei löschen</h3>
<button class="modal-close" onclick="schliesseModal('loeschen-modal')">&times;</button>
</div>
<div class="modal-body">
<p>Soll diese Datei wirklich gelöscht werden?</p>
<p class="delete-warning"><strong id="loeschen-datei"></strong></p>
<p class="delete-hint">Diese Aktion kann nicht rückgängig gemacht werden!</p>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('loeschen-modal')">Abbrechen</button>
<button class="btn btn-danger" onclick="loeschenBestaetigen()">Endgültig löschen</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container"></div>
</div>
<script src="/static/js/browser.js"></script>
</body>
</html>

View file

@ -14,6 +14,15 @@
<h1>Dateiverwaltung</h1> <h1>Dateiverwaltung</h1>
</div> </div>
<div class="header-right"> <div class="header-right">
<a href="/browser" target="_blank" class="btn btn-sm btn-primary" title="Dateimanager in neuem Fenster öffnen">📂 Dateimanager</a>
<select id="theme-select" class="theme-select" onchange="wechsleTheme(this.value)">
<option value="auto">🎨 Auto</option>
<option value="dark">🌙 Dark</option>
<option value="breeze-dark">🌙 Breeze Dark</option>
<option value="breeze-light">☀️ Breeze Light</option>
</select>
<button class="btn btn-sm" onclick="zeigeStatistik()">📊 Statistik</button>
<button class="btn btn-sm btn-danger" onclick="dbZuruecksetzen()">🗑 DB Reset</button>
<span id="status-indicator"></span> <span id="status-indicator"></span>
</div> </div>
</header> </header>
@ -83,24 +92,77 @@
</div> </div>
</div> </div>
<!-- Regeln --> <!-- Schnell-Regeln (Typ-basiert für Grob-Sortierung) -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Sortier-Regeln</h3> <h3>Schnell-Regeln (Grob-Sortierung)</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeRegelModal()">+ Hinzufügen</button> <div>
<button class="btn btn-sm btn-primary" onclick="zeigeSchnellRegelModal()">+ Schnell-Regel</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="card-hint">Sortiert automatisch nach Dateityp/Eigenschaften. Wird <strong>vor</strong> den Fein-Regeln angewendet.</p>
<div id="schnell-regeln-liste">
<p class="empty-state">Keine Schnell-Regeln definiert</p>
</div>
</div>
</div>
<!-- Regeln (Fein-Sortierung) -->
<div class="card">
<div class="card-header">
<h3>Fein-Regeln (nach Inhalt)</h3>
<div>
<button class="btn btn-sm" onclick="zeigeRegelHilfe()">❓ Hilfe</button>
<button class="btn btn-sm btn-primary" onclick="zeigeRegelModal()">+ Hinzufügen</button>
</div>
</div>
<div class="card-body">
<p class="card-hint">Sortiert nach Textinhalt (Keywords, Regex). Höhere Priorität = wird später geprüft.</p>
<div id="regeln-liste"> <div id="regeln-liste">
<p class="empty-state">Keine Regeln definiert</p> <p class="empty-state">Keine Regeln definiert</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Sortierung starten --> <!-- OCR-Backup Einstellung -->
<div class="card">
<div class="card-header">
<h3>OCR-Einstellungen</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>
<input type="checkbox" id="ocr-backup-aktiv" onchange="toggleOcrBackup()">
Backup vor OCR-Einbettung erstellen
</label>
<small>Originale werden gesichert bevor Text eingebettet wird</small>
</div>
<div id="ocr-backup-ordner-gruppe" class="form-group hidden">
<label>Backup-Ordner</label>
<div class="input-with-btn">
<input type="text" id="ocr-backup-ordner" placeholder="/mnt/backup/pdf-originale">
<button class="btn" type="button" onclick="oeffneBrowserFuerOcrBackup()">📁</button>
</div>
<small>PDFs werden hierhin kopiert bevor OCR-Text eingebettet wird</small>
</div>
</div>
</div>
<!-- Sortierung starten/stoppen -->
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-success btn-large" onclick="sortierungStarten()"> <div class="action-buttons">
<button id="sortierung-start-btn" class="btn btn-success btn-large" onclick="sortierungStarten(false)">
▶ Sortierung starten ▶ Sortierung starten
</button> </button>
<button id="sortierung-test-btn" class="btn btn-large" onclick="sortierungStarten(true)" title="Analysiert Dateien ohne sie zu verschieben">
🔍 Testlauf (nur Vorschau)
</button>
<button id="sortierung-stopp-btn" class="btn btn-danger btn-large hidden" onclick="sortierungStoppen()">
◼ Sortierung stoppen
</button>
</div>
<small class="action-hint">Testlauf zeigt was passieren würde, ohne Dateien zu verschieben</small>
</div> </div>
<!-- Sortierungs-Log --> <!-- Sortierungs-Log -->
@ -168,6 +230,11 @@
<option value="true">Nur ungelesene Mails</option> <option value="true">Nur ungelesene Mails</option>
</select> </select>
</div> </div>
<div class="form-group">
<label>Mails ab Datum (optional)</label>
<input type="date" id="pf-ab-datum">
<small>Nur Mails ab diesem Datum abrufen (leer = alle)</small>
</div>
<div class="form-group"> <div class="form-group">
<label>Ziel-Ordner</label> <label>Ziel-Ordner</label>
<div class="input-with-btn"> <div class="input-with-btn">
@ -313,7 +380,10 @@
<div class="form-group"> <div class="form-group">
<label>Ziel-Unterordner (optional)</label> <label>Ziel-Unterordner (optional)</label>
<div class="input-with-btn">
<input type="text" id="regel-unterordner" placeholder="sonepar"> <input type="text" id="regel-unterordner" placeholder="sonepar">
<button class="btn" type="button" onclick="oeffneBrowserFuerRegel()">📁</button>
</div>
<small>Wird an den Ziel-Ordner des Quell-Ordners angehängt</small> <small>Wird an den Ziel-Ordner des Quell-Ordners angehängt</small>
</div> </div>
@ -354,6 +424,282 @@
</div> </div>
</div> </div>
<!-- Modal: Regel-Hilfe / Text-Analyse -->
<div id="hilfe-modal" class="modal hidden">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>Regel-Hilfe & Text-Analyse</h3>
<button class="modal-close" onclick="schliesseModal('hilfe-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="hilfe-section">
<h4>📋 Text einfügen zur Analyse</h4>
<p>Füge hier den Text eines Dokuments ein (z.B. aus einer PDF). Ich analysiere ihn und schlage eine Regel vor.</p>
<textarea id="hilfe-text" rows="10" placeholder="Text hier einfügen oder hochladen...&#10;&#10;Beispiel: Kopiere den Text aus einer Rechnung hier rein..."></textarea>
<div class="btn-group">
<button class="btn btn-primary" onclick="analysiereText()">Text analysieren</button>
<label class="btn btn-file">
PDF hochladen
<input type="file" id="hilfe-upload" accept=".pdf,.txt" onchange="ladeHilfeDatei(this)" hidden>
</label>
<button class="btn" onclick="zeigePdfBrowser()">PDF aus Ordner</button>
</div>
</div>
<div id="hilfe-ergebnis" class="hilfe-section hidden">
<h4>✨ Analyse-Ergebnis</h4>
<div id="hilfe-analyse"></div>
<h4 style="margin-top: 1rem;">🔧 Felder anpassen</h4>
<p>Passe die Regex-Muster an wenn die automatische Erkennung falsch ist:</p>
<div class="regex-editor">
<div class="regex-row">
<label>Firma/Ersteller:</label>
<input type="text" id="hilfe-firma" placeholder="z.B. Meine Firma GmbH">
<small>Fester Wert (kein Regex)</small>
</div>
<div class="regex-row">
<label>Datum Regex:</label>
<div class="regex-input-group">
<input type="text" id="hilfe-datum-regex" placeholder="z.B. Rechnungsdatum[:\s]*(\d{2}\.\d{2}\.\d{4})">
<select id="hilfe-datum-preset" onchange="setzeRegexPreset('datum')">
<option value="">-- Vorlage --</option>
<option value="(\d{2}\.\d{2}\.\d{4})">DD.MM.YYYY</option>
<option value="Rechnungsdatum[:\s]*(\d{2}\.\d{2}\.\d{4})">Rechnungsdatum: DD.MM.YYYY</option>
<option value="Datum[:\s]*(\d{2}\.\d{2}\.\d{4})">Datum: DD.MM.YYYY</option>
<option value="(\d{4}-\d{2}-\d{2})">YYYY-MM-DD</option>
</select>
</div>
<small>Gruppe 1 = Datum</small>
</div>
<div class="regex-row">
<label>Betrag Regex:</label>
<div class="regex-input-group">
<input type="text" id="hilfe-betrag-regex" placeholder="z.B. Gesamtbetrag[:\s]*([\d.,]+)">
<select id="hilfe-betrag-preset" onchange="setzeRegexPreset('betrag')">
<option value="">-- Vorlage --</option>
<option value="Gesamtbetrag[:\s]*([\d.,]+)">Gesamtbetrag: X,XX</option>
<option value="Summe[:\s]*([\d.,]+)">Summe: X,XX</option>
<option value="Rechnungsbetrag[:\s]*([\d.,]+)">Rechnungsbetrag: X,XX</option>
<option value="Total[:\s]*([\d.,]+)">Total: X,XX</option>
<option value="Brutto[:\s]*([\d.,]+)">Brutto: X,XX</option>
<option value="Netto[:\s]*([\d.,]+)">Netto: X,XX</option>
<option value="EUR\s*([\d.,]+)(?!.*EUR\s*[\d.,]+)">Letzter EUR-Betrag</option>
</select>
</div>
<small>Gruppe 1 = Betrag (fur Gesamtsumme)</small>
</div>
<div class="regex-row">
<label>Nummer Regex:</label>
<div class="regex-input-group">
<input type="text" id="hilfe-nummer-regex" placeholder="z.B. Rechnungsnummer[:\s]*(\w+)">
<select id="hilfe-nummer-preset" onchange="setzeRegexPreset('nummer')">
<option value="">-- Vorlage --</option>
<option value="Rechnungsnummer[:\s]*(\S+)">Rechnungsnummer: XXX</option>
<option value="Rechnung\s*(?:Nr\.?|Nummer)?[:\s]*(\S+)">Rechnung Nr. XXX</option>
<option value="Belegnummer[:\s]*(\S+)">Belegnummer: XXX</option>
<option value="Bestellnummer[:\s]*(\S+)">Bestellnummer: XXX</option>
<option value="Dokumentnummer[:\s]*(\S+)">Dokumentnummer: XXX</option>
</select>
</div>
<small>Gruppe 1 = Rechnungs-/Belegnummer</small>
</div>
<div class="regex-row">
<label>Keywords (Erkennung):</label>
<input type="text" id="hilfe-keywords" placeholder="z.B. rechnung, meinefirma">
<small>Komma-getrennt - alle müssen im Text vorkommen</small>
</div>
</div>
<div class="btn-group">
<button class="btn" onclick="testeMitRegex()">🔄 Mit Regex testen</button>
<button class="btn btn-primary" onclick="erstelleRegelAusHilfe()">✓ Regel erstellen</button>
</div>
<div id="hilfe-regel-vorschau" class="regel-vorschau hidden">
<h5>Generierte Regel:</h5>
<pre id="hilfe-regel-json"></pre>
</div>
</div>
<div class="hilfe-section">
<h4>Regex-Cheatsheet</h4>
<div class="doku-box regex-cheatsheet">
<div class="cheatsheet-grid">
<div class="cheatsheet-section">
<h5>Grundlagen</h5>
<table class="cheatsheet-table">
<tr><td><code>\d</code></td><td>Eine Ziffer (0-9)</td></tr>
<tr><td><code>\d+</code></td><td>Eine oder mehr Ziffern</td></tr>
<tr><td><code>\s</code></td><td>Leerzeichen/Tab</td></tr>
<tr><td><code>\S+</code></td><td>Nicht-Leerzeichen (Wort)</td></tr>
<tr><td><code>.*</code></td><td>Beliebige Zeichen</td></tr>
<tr><td><code>[:\s]*</code></td><td>Doppelpunkt und/oder Leerzeichen</td></tr>
<tr><td><code>(xxx)</code></td><td>Gruppe - wird extrahiert!</td></tr>
</table>
</div>
<div class="cheatsheet-section">
<h5>Datum-Muster</h5>
<table class="cheatsheet-table">
<tr><td><code>(\d{2}\.\d{2}\.\d{4})</code></td><td>31.12.2024</td></tr>
<tr><td><code>(\d{4}-\d{2}-\d{2})</code></td><td>2024-12-31</td></tr>
<tr><td><code>Datum[:\s]*(\d{2}\.\d{2}\.\d{4})</code></td><td>Datum: 31.12.2024</td></tr>
</table>
</div>
<div class="cheatsheet-section">
<h5>Betrag-Muster</h5>
<table class="cheatsheet-table">
<tr><td><code>([\d.,]+)</code></td><td>Beliebige Zahl</td></tr>
<tr><td><code>(\d+,\d{2})</code></td><td>123,45</td></tr>
<tr><td><code>EUR\s*([\d.,]+)</code></td><td>EUR 123,45</td></tr>
<tr><td><code>Summe[:\s]*([\d.,]+)</code></td><td>Summe: 123,45</td></tr>
</table>
</div>
<div class="cheatsheet-section">
<h5>Nummer-Muster</h5>
<table class="cheatsheet-table">
<tr><td><code>(\S+)</code></td><td>Ein Wort/Nummer</td></tr>
<tr><td><code>(\d+)</code></td><td>Nur Ziffern</td></tr>
<tr><td><code>([A-Z0-9-]+)</code></td><td>Grossbuchst./Ziffern</td></tr>
<tr><td><code>Nr\.?\s*(\S+)</code></td><td>Nr. 12345</td></tr>
</table>
</div>
</div>
<div class="cheatsheet-tip">
<strong>Tipp:</strong> Die <code>(Klammern)</code> bestimmen was extrahiert wird.
Alles davor dient nur zum Finden der richtigen Stelle im Text.
</div>
</div>
</div>
<div class="hilfe-section">
<h4>Regel-Dokumentation</h4>
<div class="doku-box">
<h5>Erkennungsmuster (muster)</h5>
<pre>{
"text_match_any": ["sonepar", "wuerth"], // Mindestens eins muss vorkommen
"text_match": ["rechnung"], // Alle müssen vorkommen
"keywords": "sonepar, rechnung" // Einfache Variante
}</pre>
<h5>Feld-Extraktion (extraktion)</h5>
<pre>{
"datum": {
"regex": "(\\d{2}[./]\\d{2}[./]\\d{4})",
"format": "%d.%m.%Y"
},
"rechnungsnummer": {
"regex": "Rechnungsnummer[:\\s]*(\\d+)"
},
"betrag": {
"regex": "Gesamtbetrag[:\\s]*([\\d.,]+)",
"typ": "betrag"
},
"ersteller": {
"wert": "Sonepar" // Fester Wert
}
}</pre>
<h5>Dateiname-Schema</h5>
<pre>Verfügbare Platzhalter:
{datum} -> 2024-01-15
{jahr} -> 2024
{monat} -> 01
{tag} -> 15
{ersteller} -> Sonepar
{firma} -> Sonepar
{rechnungsnummer}-> 12345
{betrag} -> 123,45
{original} -> Original-Dateiname
Beispiel:
{datum} - Rechnung - {ersteller} - {rechnungsnummer} - {betrag} EUR.pdf</pre>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('hilfe-modal')">Schließen</button>
</div>
</div>
</div>
<!-- Modal: Statistik -->
<div id="statistik-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>📊 Datenbank-Statistik</h3>
<button class="modal-close" onclick="schliesseModal('statistik-modal')">&times;</button>
</div>
<div class="modal-body">
<div id="statistik-inhalt">Wird geladen...</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('statistik-modal')">Schließen</button>
</div>
</div>
</div>
<!-- Modal: Schnell-Regel hinzufügen -->
<div id="schnell-regel-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Schnell-Regel hinzufügen</h3>
<button class="modal-close" onclick="schliesseModal('schnell-regel-modal')">&times;</button>
</div>
<div class="modal-body">
<p class="modal-hint">Schnell-Regeln sortieren nach Dateityp und Eigenschaften (z.B. alle Bilder in einen Ordner).</p>
<div class="form-group">
<label>Regel-Typ auswählen</label>
<select id="schnell-regel-typ" onchange="schnellRegelTypGeaendert()">
<option value="">-- Bitte wählen --</option>
</select>
</div>
<div id="schnell-regel-details" class="hidden">
<div class="info-box">
<strong id="schnell-regel-name"></strong>
<p id="schnell-regel-beschreibung"></p>
<small>Erkennung: <code id="schnell-regel-muster"></code></small>
</div>
<div class="form-group">
<label>Ziel-Unterordner</label>
<input type="text" id="schnell-regel-unterordner" placeholder="z.B. bilder">
<small>Dateien werden in diesen Unterordner des Zielordners verschoben</small>
</div>
<div class="form-group">
<label>Priorität (niedriger = wichtiger)</label>
<input type="number" id="schnell-regel-prioritaet" value="10">
<small>Regeln mit niedriger Priorität werden zuerst geprüft</small>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('schnell-regel-modal')">Abbrechen</button>
<button class="btn btn-primary" id="schnell-regel-speichern-btn" onclick="speichereSchnellRegel()" disabled>Speichern</button>
</div>
</div>
</div>
<!-- Modal: PDF-Browser -->
<div id="pdf-browser-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>PDF aus Quell-Ordner auswählen</h3>
<button class="modal-close" onclick="schliesseModal('pdf-browser-modal')">&times;</button>
</div>
<div class="modal-body">
<div id="pdf-browser-liste">Lade...</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="schliesseModal('pdf-browser-modal')">Abbrechen</button>
</div>
</div>
</div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden"> <div id="loading-overlay" class="loading-overlay hidden">
<div class="spinner"></div> <div class="spinner"></div>

View file

@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vorschau - Dateiverwaltung</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header */
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.preview-header h1 {
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-header .filename {
font-weight: normal;
color: var(--text-secondary);
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-header .controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--danger);
}
.status-indicator.connected {
background: var(--success);
}
.btn {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.btn:hover {
background: var(--bg-tertiary);
}
/* 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);
}
.preview-placeholder .icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.preview-placeholder p {
font-size: 1rem;
}
.preview-placeholder .hint {
font-size: 0.85rem;
margin-top: 0.5rem;
opacity: 0.7;
}
/* 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.85rem;
overflow: auto;
border-radius: var(--radius);
white-space: pre-wrap;
word-wrap: break-word;
}
/* No Preview */
.preview-unavailable {
text-align: center;
padding: 2rem;
}
.preview-unavailable .file-type-icon {
font-size: 5rem;
margin-bottom: 1rem;
}
.preview-unavailable p {
color: var(--text-secondary);
margin-bottom: 1rem;
}
.preview-unavailable .btn {
margin-top: 1rem;
}
/* Theme Select */
.theme-select {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
}
</style>
</head>
<body>
<header class="preview-header">
<h1>
👁 Vorschau
<span id="filename" class="filename">Keine Datei ausgewählt</span>
</h1>
<div class="controls">
<div id="status" class="status-indicator" title="Nicht verbunden"></div>
<select id="theme-select" class="theme-select" onchange="wechsleTheme(this.value)">
<option value="auto">🎨 Auto</option>
<option value="dark">🌙 Dark</option>
<option value="breeze-dark">🌙 Breeze Dark</option>
<option value="breeze-light">☀️ Breeze Light</option>
</select>
<button class="btn" onclick="window.close()">✕ Schließen</button>
</div>
</header>
<div id="preview-container" class="preview-container">
<div class="preview-placeholder">
<div class="icon">📂</div>
<p>Warte auf Dateiauswahl...</p>
<p class="hint">Wähle eine Datei im Hauptfenster aus</p>
</div>
</div>
<script>
// ============ BroadcastChannel für Kommunikation ============
const channel = new BroadcastChannel('dateiverwaltung-preview');
let currentFile = null;
// Status anzeigen
function setConnected(connected) {
const status = document.getElementById('status');
if (connected) {
status.classList.add('connected');
status.title = 'Verbunden mit Hauptfenster';
} else {
status.classList.remove('connected');
status.title = 'Nicht verbunden';
}
}
// Nachricht vom Hauptfenster empfangen
channel.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'preview':
ladeVorschau(data.path, data.name);
setConnected(true);
break;
case 'clear':
zeigeWartePlaceholder();
break;
case 'ping':
// Bestätigung senden
channel.postMessage({ type: 'pong' });
setConnected(true);
break;
}
};
// Beim Start Ping senden
channel.postMessage({ type: 'preview-window-ready' });
// ============ 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);
}
// ============ Vorschau ============
async function ladeVorschau(pfad, name) {
currentFile = { path: pfad, name: name };
document.getElementById('filename').textContent = name;
document.title = `${name} - Vorschau`;
const container = document.getElementById('preview-container');
const ext = name.split('.').pop().toLowerCase();
// PDF Vorschau - mit fit-to-page für erste Seite
if (ext === 'pdf') {
container.innerHTML = `<iframe class="preview-pdf" src="/api/file/preview?path=${encodeURIComponent(pfad)}&t=${Date.now()}#page=1&view=FitV"></iframe>`;
return;
}
// Bild Vorschau
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'].includes(ext)) {
container.innerHTML = `<img class="preview-image" src="/api/file/preview?path=${encodeURIComponent(pfad)}&t=${Date.now()}" alt="${name}">`;
return;
}
// Text-basierte Dateien
if (['txt', 'md', 'log', 'xml', 'json', 'csv', 'html', 'css', 'js'].includes(ext)) {
try {
const response = await fetch(`/api/file/text?path=${encodeURIComponent(pfad)}`);
const result = await response.json();
if (result.content) {
container.innerHTML = `<pre class="preview-text">${escapeHtml(result.content)}</pre>`;
} 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);
document.getElementById('preview-container').innerHTML = `
<div class="preview-unavailable">
<div class="file-type-icon">${icon}</div>
<p>Keine Vorschau für .${ext} Dateien</p>
<button class="btn" onclick="dateiExternOeffnen()">🔗 Extern öffnen</button>
</div>
`;
}
function zeigeWartePlaceholder() {
document.getElementById('filename').textContent = 'Keine Datei ausgewählt';
document.title = 'Vorschau - Dateiverwaltung';
document.getElementById('preview-container').innerHTML = `
<div class="preview-placeholder">
<div class="icon">📂</div>
<p>Warte auf Dateiauswahl...</p>
<p class="hint">Wähle eine Datei im Hauptfenster aus</p>
</div>
`;
}
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 escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function dateiExternOeffnen() {
if (currentFile) {
window.open(`/api/file/download?path=${encodeURIComponent(currentFile.path)}`, '_blank');
}
}
// ============ Init ============
document.addEventListener('DOMContentLoaded', () => {
ladeTheme();
// Falls Pfad als URL-Parameter übergeben wurde
const params = new URLSearchParams(window.location.search);
const path = params.get('path');
if (path) {
const name = path.split('/').pop();
ladeVorschau(path, name);
}
});
// Fenster-Schließen mitteilen
window.addEventListener('beforeunload', () => {
channel.postMessage({ type: 'preview-window-closed' });
});
</script>
</body>
</html>