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 21e1ffe9e2
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
TZ=Europe/Berlin
# ----------------------------------------------
# Pfade - WICHTIG: An dein System anpassen!
# ----------------------------------------------
# Wo die Datenbank gespeichert wird (persistent!)
# Hier werden alle Einstellungen, Regeln, Postfächer gespeichert
DATA_PATH=./data
# Quell-Ordner: Hier liegen die unsortieren Dateien
# Beispiele:
# /home/benutzer/Dokumente/Inbox
# /mnt/nas/scans
# /mnt/mailanhänge
INBOX_PATH=/mnt/inbox
# Ziel-Ordner: Hierhin werden sortierte Dateien verschoben
# Beispiele:
# /home/benutzer/Dokumente/Archiv
# /mnt/nas/archiv
ARCHIV_PATH=/mnt/archiv
# Backup-Ordner: Original-PDFs vor OCR-Einbettung
# WICHTIG: Falls OCR fehlschlägt, sind die Originale hier gesichert
BACKUP_PATH=/mnt/backup
# Zusätzliche Ordner (optional)
# Werden im Container unter /mnt/extra1, /mnt/extra2 verfügbar
# EXTRA_PATH_1=/mnt/dokumente
# EXTRA_PATH_2=/mnt/scans
# ----------------------------------------------
# Datenbank
# ----------------------------------------------
# SQLite Datenbank (Standard, keine Konfiguration nötig)
DATABASE_URL=sqlite:////app/data/dateiverwaltung.db
# PostgreSQL (optional, für größere Installationen)
# DATABASE_URL=postgresql://user:password@localhost/dateiverwaltung
# ----------------------------------------------
# OCR Einstellungen
# ----------------------------------------------
# Sprache für OCR (deu = Deutsch, eng = Englisch)
# Mehrere Sprachen: deu+eng
OCR_LANGUAGE=deu
# DPI für OCR-Verarbeitung (höher = besser, aber langsamer)
OCR_DPI=300
# Optional: Claude API für KI-Validierung (spätere Erweiterung)
# ----------------------------------------------
# Mail-Abruf (wird in der Web-UI konfiguriert)
# ----------------------------------------------
# Die Mail-Zugangsdaten werden in der Datenbank gespeichert,
# nicht in Umgebungsvariablen (sicherer).
# ----------------------------------------------
# Erweitert (optional)
# ----------------------------------------------
# Log-Level (DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO
# API-Key für KI-Validierung (zukünftige Erweiterung)
# CLAUDE_API_KEY=sk-ant-...

View file

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

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})
@app.get("/browser", response_class=HTMLResponse)
async def browser(request: Request):
"""Dateimanager / Dual-Pane Browser"""
return templates.TemplateResponse("browser.html", {"request": request})
@app.get("/browser/preview", response_class=HTMLResponse)
async def browser_preview(request: Request):
"""Separates Vorschau-Fenster für Dateimanager"""
return templates.TemplateResponse("preview.html", {"request": request})
@app.get("/health")
async def health():
"""Health Check für Docker"""

View file

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

View file

@ -186,21 +186,59 @@ def extrahiere_nummer(text: str, spezifische_muster: List[Dict] = None) -> Optio
# ============ FIRMA/ABSENDER ============
FIRMA_MUSTER = [
# Absender-Zeile
{"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG))", "context": True},
{"regex": r"Absender[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True},
{"regex": r"Von[:\s]*([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True},
# Rechtsformen direkt (GmbH, AG, etc.) - sehr zuverlässig
{"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\s+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR)", "context": True},
# Kopfzeile/Absender typisch erste Zeilen
{"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß0-9\s&\-\.]{2,50})$", "context": True, "multiline": True},
# Nach "von" / Absender
{"regex": r"(?:Absender|Von|From)[:\s]+([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True},
# Firmenname vor Adresse (PLZ Stadt)
{"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\n+[A-Za-zäöüÄÖÜß\s\d\-\.]+\n+\d{5}\s+[A-Za-zäöüÄÖÜß]+", "context": True},
# E-Mail Domain als Firmennamen
{"regex": r"(?:info|kontakt|rechnung|buchhaltung|office)@([a-zA-Z0-9\-]+)\.", "context": True},
# Website als Firmennamen
{"regex": r"(?:www\.|http[s]?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", "context": True},
]
# Bekannte Firmen (werden im Text gesucht)
BEKANNTE_FIRMEN = [
# Elektronik/IT
"Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt",
"Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg",
"Telekom", "Vodafone", "O2", "1&1",
"Allianz", "HUK", "Provinzial", "DEVK", "Gothaer",
"IKEA", "Poco", "XXXLutz", "Roller",
"Alternate", "Mindfactory", "Caseking", "Notebooksbilliger",
"DHL", "DPD", "Hermes", "UPS", "GLS",
"Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "Cyberport",
"Apple", "Microsoft", "Dell", "HP", "Lenovo", "ASUS", "Acer",
# Baumärkte
"Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Globus",
# Telekommunikation
"Telekom", "Vodafone", "O2", "1&1", "Congstar", "Drillisch",
# Versicherungen
"Allianz", "HUK", "Provinzial", "DEVK", "Gothaer", "AXA", "ERGO", "Zurich",
"Generali", "HDI", "VHV", "R+V", "Debeka", "Signal Iduna",
# Möbel
"IKEA", "Poco", "XXXLutz", "Roller", "Höffner", "Segmüller",
# Versand/Logistik
"DHL", "DPD", "Hermes", "UPS", "GLS", "FedEx",
# Lebensmittel/Drogerie
"REWE", "Edeka", "Aldi", "Lidl", "Rossmann", "dm", "Müller",
# Energie
"E.ON", "RWE", "EnBW", "Vattenfall", "Stadtwerke", "EWE", "ENTEGA",
# Banken
"Deutsche Bank", "Commerzbank", "Sparkasse", "Volksbank", "ING", "DKB", "Postbank",
# Sonstige
"ADAC", "TÜV", "Dekra", "Würth", "Grainger", "Festo", "Bosch",
]
@ -224,16 +262,44 @@ def extrahiere_firma(text: str, absender_email: str = "", spezifische_muster: Li
for firma in BEKANNTE_FIRMEN:
if firma.lower() == domain.lower():
return firma
# Domain als Firmenname verwenden (kapitalisiert)
if len(domain) > 2:
return domain.capitalize()
# 3. 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
for muster in muster_liste:
try:
match = re.search(muster["regex"], text, re.MULTILINE)
flags = re.MULTILINE if muster.get("multiline") else 0
match = re.search(muster["regex"], text, flags)
if match:
firma = match.group(1).strip()
if len(firma) >= 2:
# Filtern: zu kurz, nur Zahlen, etc.
if len(firma) >= 2 and not firma.isdigit():
return firma
except:
continue

View file

@ -7,14 +7,77 @@ import email
from email.header import decode_header
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Optional
from typing import List, Dict, Optional, Callable
import logging
import threading
from ..config import INBOX_DIR
logger = logging.getLogger(__name__)
# Globaler Manager für laufende Abrufe
class AbrufManager:
"""Verwaltet laufende Mail-Abrufe und ermöglicht Abbruch"""
def __init__(self):
self._aktive_abrufe: Dict[int, dict] = {} # postfach_id -> status
self._lock = threading.Lock()
def starten(self, postfach_id: int) -> bool:
"""Startet einen Abruf, gibt False zurück wenn bereits einer läuft"""
with self._lock:
if postfach_id in self._aktive_abrufe:
return False
self._aktive_abrufe[postfach_id] = {
"status": "running",
"gestartet": datetime.now(),
"abbrechen": False
}
return True
def stoppen(self, postfach_id: int) -> bool:
"""Markiert einen Abruf zum Abbruch"""
with self._lock:
if postfach_id in self._aktive_abrufe:
self._aktive_abrufe[postfach_id]["abbrechen"] = True
return True
return False
def beenden(self, postfach_id: int):
"""Entfernt einen Abruf aus der Liste"""
with self._lock:
if postfach_id in self._aktive_abrufe:
del self._aktive_abrufe[postfach_id]
def soll_abbrechen(self, postfach_id: int) -> bool:
"""Prüft ob ein Abruf abgebrochen werden soll"""
with self._lock:
if postfach_id in self._aktive_abrufe:
return self._aktive_abrufe[postfach_id].get("abbrechen", False)
return True # Nicht registriert = abbrechen
def ist_aktiv(self, postfach_id: int) -> bool:
"""Prüft ob ein Abruf läuft"""
with self._lock:
return postfach_id in self._aktive_abrufe
def alle_aktiven(self) -> Dict[int, dict]:
"""Gibt alle aktiven Abrufe zurück"""
with self._lock:
return dict(self._aktive_abrufe)
def stoppe_alle(self):
"""Stoppt alle laufenden Abrufe"""
with self._lock:
for postfach_id in self._aktive_abrufe:
self._aktive_abrufe[postfach_id]["abbrechen"] = True
# Globale Instanz
abruf_manager = AbrufManager()
class MailFetcher:
"""Holt Attachments aus einem IMAP-Postfach"""
@ -81,7 +144,8 @@ class MailFetcher:
nur_ungelesen: bool = False,
markiere_gelesen: bool = False,
alle_ordner: bool = False,
bereits_verarbeitet: set = None) -> List[Dict]:
bereits_verarbeitet: set = None,
ab_datum: datetime = None) -> List[Dict]:
"""
Holt alle Attachments die den Filtern entsprechen
@ -114,7 +178,7 @@ class MailFetcher:
for ordner in ordner_liste:
ergebnisse.extend(self._fetch_from_folder(
ordner, ziel, erlaubte_typen, max_groesse,
nur_ungelesen, markiere_gelesen, bereits_verarbeitet
nur_ungelesen, markiere_gelesen, bereits_verarbeitet, ab_datum
))
return ergebnisse
@ -122,7 +186,7 @@ class MailFetcher:
def _fetch_from_folder(self, ordner: str, ziel: Path,
erlaubte_typen: List[str], max_groesse: int,
nur_ungelesen: bool, markiere_gelesen: bool,
bereits_verarbeitet: set) -> List[Dict]:
bereits_verarbeitet: set, ab_datum: datetime = None) -> List[Dict]:
"""Holt Attachments aus einem einzelnen Ordner"""
ergebnisse = []
@ -130,7 +194,15 @@ class MailFetcher:
# Ordner auswählen
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"
status, messages = self.connection.search(None, search_criteria)
@ -252,12 +324,17 @@ class MailFetcher:
nur_ungelesen: bool = False,
markiere_gelesen: bool = False,
alle_ordner: bool = False,
bereits_verarbeitet: set = None):
bereits_verarbeitet: set = None,
abbruch_callback: Callable[[], bool] = None,
ab_datum: datetime = None):
"""
Generator-Version für Streaming - yielded Events während des Abrufs
Args:
abbruch_callback: Funktion die True zurückgibt wenn abgebrochen werden soll
Yields:
Dict mit type: "ordner", "mails", "datei", "skip", "fehler"
Dict mit type: "ordner", "mails", "datei", "skip", "fehler", "abgebrochen"
"""
if not self.connection:
if not self.connect():
@ -279,10 +356,23 @@ class MailFetcher:
ordner_liste = [self.config.get("ordner", "INBOX")]
for ordner in ordner_liste:
# Abbruch prüfen
if abbruch_callback and abbruch_callback():
yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"}
return
yield {"type": "ordner", "name": ordner}
try:
status, _ = self.connection.select(ordner)
# Suche mit optionalem Datum-Filter
if ab_datum:
datum_str = ab_datum.strftime("%d-%b-%Y")
if nur_ungelesen:
search_criteria = f'(UNSEEN SINCE {datum_str})'
else:
search_criteria = f'(SINCE {datum_str})'
else:
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
status, messages = self.connection.search(None, search_criteria)
@ -293,6 +383,11 @@ class MailFetcher:
yield {"type": "mails", "ordner": ordner, "anzahl": len(mail_ids)}
for mail_id in mail_ids:
# Abbruch prüfen bei jeder Mail
if abbruch_callback and abbruch_callback():
yield {"type": "abgebrochen", "nachricht": "Abruf wurde abgebrochen"}
return
try:
status, msg_data = self.connection.fetch(mail_id, "(RFC822)")
if status != "OK":

View file

@ -29,16 +29,24 @@ except ImportError:
class PDFProcessor:
"""Verarbeitet PDFs: Text-Extraktion, OCR, ZUGFeRD-Erkennung"""
def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300):
def __init__(self, ocr_language: str = "deu", ocr_dpi: int = 300, backup_ordner: str = None):
self.ocr_language = ocr_language
self.ocr_dpi = ocr_dpi
self.backup_ordner = backup_ordner # Optional: Ordner für Original-Backups vor OCR
def verarbeite(self, pdf_pfad: str) -> Dict:
def verarbeite(self, pdf_pfad: str, ocr_einbetten: bool = True, backup_erstellen: bool = None) -> Dict:
"""
Vollständige PDF-Verarbeitung
Args:
pdf_pfad: Pfad zur PDF-Datei
ocr_einbetten: Wenn True, wird OCR-Text permanent in die PDF eingebettet.
ACHTUNG: Wird bei signierten PDFs und ZUGFeRD automatisch deaktiviert!
backup_erstellen: Wenn True, wird vor OCR-Einbettung ein Backup erstellt.
None = verwendet self.backup_ordner als Indikator
Returns:
Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt
Dict mit: text, ist_zugferd, zugferd_xml, hat_text, ocr_durchgefuehrt, ist_signiert, backup_pfad
"""
pfad = Path(pdf_pfad)
if not pfad.exists():
@ -51,7 +59,10 @@ class PDFProcessor:
"zugferd_xml": None,
"hat_text": False,
"ocr_durchgefuehrt": False,
"seiten": 0
"ist_signiert": False,
"ocr_uebersprungen_grund": None,
"seiten": 0,
"backup_pfad": None
}
# 1. ZUGFeRD prüfen
@ -59,23 +70,148 @@ class PDFProcessor:
ergebnis["ist_zugferd"] = zugferd_result["ist_zugferd"]
ergebnis["zugferd_xml"] = zugferd_result.get("xml")
# 2. Text extrahieren
# 2. Digitale Signatur prüfen
ergebnis["ist_signiert"] = self.hat_digitale_signatur(pdf_pfad)
# 3. Text extrahieren
text, seiten = self.extrahiere_text(pdf_pfad)
ergebnis["text"] = text
ergebnis["seiten"] = seiten
ergebnis["hat_text"] = bool(text and len(text.strip()) > 50)
# 3. OCR falls kein Text (aber NICHT bei ZUGFeRD!)
if not ergebnis["hat_text"] and not ergebnis["ist_zugferd"]:
logger.info(f"Kein Text gefunden, starte OCR für {pfad.name}")
ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad)
# 4. OCR falls kein Text - aber NICHT bei geschützten PDFs!
if not ergebnis["hat_text"]:
# Prüfen ob OCR-Einbettung sicher ist
if ergebnis["ist_zugferd"]:
ergebnis["ocr_uebersprungen_grund"] = "ZUGFeRD-Rechnung - keine Modifikation erlaubt"
logger.info(f"OCR übersprungen (ZUGFeRD): {pfad.name}")
# Trotzdem versuchen Text zu extrahieren ohne einzubetten
ocr_text, _ = self.fuehre_ocr_aus(pdf_pfad, in_place=False)
if ocr_text:
ergebnis["text"] = ocr_text
ergebnis["hat_text"] = True
elif ergebnis["ist_signiert"]:
ergebnis["ocr_uebersprungen_grund"] = "Digital signiert - keine Modifikation erlaubt"
logger.info(f"OCR übersprungen (signiert): {pfad.name}")
# Trotzdem versuchen Text zu extrahieren ohne einzubetten
ocr_text, _ = self.fuehre_ocr_aus(pdf_pfad, in_place=False)
if ocr_text:
ergebnis["text"] = ocr_text
ergebnis["hat_text"] = True
elif ocr_einbetten:
# Sicher zu modifizieren - OCR einbetten
logger.info(f"Kein Text gefunden, starte OCR mit Einbettung für {pfad.name}")
# Backup erstellen wenn gewünscht
soll_backup = backup_erstellen if backup_erstellen is not None else bool(self.backup_ordner)
if soll_backup and self.backup_ordner:
backup_pfad = self._erstelle_backup(pdf_pfad)
if backup_pfad:
ergebnis["backup_pfad"] = backup_pfad
logger.info(f"Backup erstellt: {backup_pfad}")
ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=True)
if ocr_erfolg:
ergebnis["text"] = ocr_text
ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50)
ergebnis["ocr_durchgefuehrt"] = True
else:
# OCR ohne Einbettung (nur Text extrahieren)
ocr_text, ocr_erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=False)
if ocr_erfolg:
ergebnis["text"] = ocr_text
ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50)
return ergebnis
def _erstelle_backup(self, pdf_pfad: str) -> Optional[str]:
"""
Erstellt ein Backup der Original-PDF vor der OCR-Einbettung.
Returns:
Pfad zum Backup oder None bei Fehler
"""
import shutil
from datetime import datetime
if not self.backup_ordner:
return None
try:
pfad = Path(pdf_pfad)
backup_dir = Path(self.backup_ordner)
backup_dir.mkdir(parents=True, exist_ok=True)
# Dateiname mit Timestamp für Eindeutigkeit
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{pfad.stem}_original_{timestamp}{pfad.suffix}"
backup_pfad = backup_dir / backup_name
# Kopieren (nicht verschieben!)
shutil.copy2(pdf_pfad, backup_pfad)
logger.info(f"Backup erstellt: {backup_pfad}")
return str(backup_pfad)
except Exception as e:
logger.error(f"Backup-Erstellung fehlgeschlagen: {e}")
return None
def hat_digitale_signatur(self, pdf_pfad: str) -> bool:
"""
Prüft ob eine PDF eine digitale Signatur enthält.
Signierte PDFs dürfen NICHT verändert werden, da dies die Signatur ungültig macht!
Returns:
True wenn signiert, False sonst
"""
if not PYPDF_AVAILABLE:
return False
try:
reader = PdfReader(pdf_pfad)
# Methode 1: AcroForm mit SigFlags prüfen
if reader.trailer.get("/Root"):
root = reader.trailer["/Root"]
if hasattr(root, "get_object"):
root = root.get_object()
acro_form = root.get("/AcroForm")
if acro_form:
if hasattr(acro_form, "get_object"):
acro_form = acro_form.get_object()
sig_flags = acro_form.get("/SigFlags")
if sig_flags and int(sig_flags) > 0:
logger.info(f"Digitale Signatur gefunden (SigFlags): {Path(pdf_pfad).name}")
return True
# Methode 2: Nach Signatur-Feldern in Seiten suchen
for page in reader.pages:
if "/Annots" in page:
annots = page["/Annots"]
if hasattr(annots, "get_object"):
annots = annots.get_object()
if annots:
for annot in annots:
if hasattr(annot, "get_object"):
annot = annot.get_object()
if annot.get("/FT") == "/Sig":
logger.info(f"Signatur-Feld gefunden: {Path(pdf_pfad).name}")
return True
# Methode 3: Nach typischen Signatur-Strings suchen
# (Manche Signaturen sind nicht in AcroForm)
with open(pdf_pfad, 'rb') as f:
content = f.read(50000) # Erste 50KB lesen
if b'/Type /Sig' in content or b'/SubFilter /adbe.pkcs7' in content:
logger.info(f"Signatur-Marker gefunden: {Path(pdf_pfad).name}")
return True
except Exception as e:
logger.debug(f"Signaturprüfung Fehler: {e}")
return False
def extrahiere_text(self, pdf_pfad: str) -> Tuple[str, int]:
"""
Extrahiert Text aus PDF
@ -172,9 +308,14 @@ class PDFProcessor:
return ergebnis
def fuehre_ocr_aus(self, pdf_pfad: str) -> Tuple[str, bool]:
def fuehre_ocr_aus(self, pdf_pfad: str, in_place: bool = True) -> Tuple[str, bool]:
"""
Führt OCR mit ocrmypdf durch
Führt OCR mit ocrmypdf durch und bettet den Text permanent in die PDF ein.
Danach ist die PDF durchsuchbar und Copy&Paste funktioniert.
Args:
pdf_pfad: Pfad zur PDF-Datei
in_place: Wenn True, wird die Original-PDF ersetzt (Standard)
Returns:
Tuple von (text, erfolg)
@ -183,31 +324,39 @@ class PDFProcessor:
temp_pfad = pfad.with_suffix(".ocr.pdf")
try:
# ocrmypdf ausführen
# ocrmypdf ausführen - Text wird permanent eingebettet
result = subprocess.run(
[
"ocrmypdf",
"--language", self.ocr_language,
"--deskew", # Schräge Scans korrigieren
"--clean", # Bild verbessern
"--skip-text", # Seiten mit Text überspringen
"--force-ocr", # OCR erzwingen falls nötig
"--rotate-pages", # Seiten automatisch drehen
"--skip-text", # Seiten mit vorhandenem Text überspringen
"--output-type", "pdfa", # PDF/A für bessere Kompatibilität
str(pfad),
str(temp_pfad)
],
capture_output=True,
text=True,
timeout=120 # 2 Minuten Timeout
timeout=180 # 3 Minuten Timeout
)
if result.returncode == 0 and temp_pfad.exists():
if in_place:
# Original mit OCR-Version ersetzen
pfad.unlink()
temp_pfad.rename(pfad)
logger.info(f"OCR erfolgreich eingebettet: {pfad.name}")
# Text aus OCR-PDF extrahieren
text, _ = self.extrahiere_text(str(pfad))
return text, True
else:
# Nur Text extrahieren, temp löschen
text, _ = self.extrahiere_text(str(temp_pfad))
temp_pfad.unlink()
return text, True
else:
logger.error(f"OCR Fehler: {result.stderr}")
if temp_pfad.exists():
@ -228,6 +377,46 @@ class PDFProcessor:
temp_pfad.unlink()
return "", False
def ocr_einbetten(self, pdf_pfad: str) -> Dict:
"""
Bettet OCR-Text permanent in eine PDF ein (macht sie durchsuchbar).
Kann unabhängig von der Sortierung verwendet werden.
Returns:
Dict mit: erfolg, text, nachricht
"""
pfad = Path(pdf_pfad)
if not pfad.exists():
return {"erfolg": False, "nachricht": f"Datei nicht gefunden: {pdf_pfad}"}
# Prüfen ob bereits Text vorhanden
text, seiten = self.extrahiere_text(pdf_pfad)
if text and len(text.strip()) > 50:
return {
"erfolg": True,
"text": text,
"nachricht": "PDF enthält bereits durchsuchbaren Text",
"ocr_durchgefuehrt": False
}
# OCR durchführen und einbetten
ocr_text, erfolg = self.fuehre_ocr_aus(pdf_pfad, in_place=True)
if erfolg:
return {
"erfolg": True,
"text": ocr_text,
"nachricht": "OCR erfolgreich eingebettet - PDF ist jetzt durchsuchbar",
"ocr_durchgefuehrt": True
}
else:
return {
"erfolg": False,
"text": "",
"nachricht": "OCR fehlgeschlagen",
"ocr_durchgefuehrt": False
}
def extrahiere_metadaten(self, pdf_pfad: str) -> Dict:
"""Extrahiert PDF-Metadaten"""
metadaten = {}

View file

@ -51,6 +51,70 @@ class Sorter:
text = dokument_info.get("text", "").lower()
original_name = dokument_info.get("original_name", "").lower()
absender = dokument_info.get("absender", "").lower()
dateityp = dokument_info.get("dateityp", "").lower() # z.B. ".pdf", ".jpg"
# ========== TYP-BASIERTE REGELN (Stufe 1: Grob-Sortierung) ==========
# dateityp_ist - Nur bestimmte Dateitypen (z.B. [".pdf", ".PDF"])
if "dateityp_ist" in muster:
erlaubte = muster["dateityp_ist"]
if isinstance(erlaubte, str):
erlaubte = [erlaubte]
erlaubte_lower = [t.lower() for t in erlaubte]
if dateityp not in erlaubte_lower:
return False
# dateityp_nicht - Ausschluss bestimmter Dateitypen
if "dateityp_nicht" in muster:
verbotene = muster["dateityp_nicht"]
if isinstance(verbotene, str):
verbotene = [verbotene]
verbotene_lower = [t.lower() for t in verbotene]
if dateityp in verbotene_lower:
return False
# ist_zugferd - Nur ZUGFeRD/E-Rechnungen
if "ist_zugferd" in muster:
ist_zugferd = dokument_info.get("ist_zugferd", False)
if muster["ist_zugferd"] and not ist_zugferd:
return False
if not muster["ist_zugferd"] and ist_zugferd:
return False
# ist_signiert - Nur signierte PDFs
if "ist_signiert" in muster:
ist_signiert = dokument_info.get("ist_signiert", False)
if muster["ist_signiert"] and not ist_signiert:
return False
if not muster["ist_signiert"] and ist_signiert:
return False
# hat_text - Nur PDFs mit/ohne Text
if "hat_text" in muster:
hat_text = dokument_info.get("hat_text", False)
if muster["hat_text"] and not hat_text:
return False
if not muster["hat_text"] and hat_text:
return False
# ist_bild - Prüft ob Datei ein Bild ist
if "ist_bild" in muster:
bild_typen = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"]
ist_bild = dateityp in bild_typen
if muster["ist_bild"] and not ist_bild:
return False
if not muster["ist_bild"] and ist_bild:
return False
# ist_pdf - Prüft ob Datei ein PDF ist
if "ist_pdf" in muster:
ist_pdf = dateityp == ".pdf"
if muster["ist_pdf"] and not ist_pdf:
return False
if not muster["ist_pdf"] and ist_pdf:
return False
# ========== INHALT-BASIERTE REGELN (Stufe 2: Fein-Sortierung) ==========
# keywords (einfache Komma-getrennte Liste - für UI)
if "keywords" in muster:
@ -321,3 +385,147 @@ def liste_dokumenttypen() -> List[Dict]:
{"id": key, "name": config["name"], "schema": config["schema"]}
for key, config in DOKUMENTTYPEN.items()
]
# ============ TYP-BASIERTE STANDARD-REGELN ============
# Diese Regeln sortieren nach Dateityp/Eigenschaften (Stufe 1: Grob-Sortierung)
TYP_REGELN = {
"zugferd": {
"name": "E-Rechnungen (ZUGFeRD/XRechnung)",
"beschreibung": "Elektronische Rechnungen mit maschinenlesbaren XML-Daten",
"prioritaet": 5, # Sehr hohe Priorität - vor anderen PDF-Regeln
"muster": {
"ist_pdf": True,
"ist_zugferd": True
},
"schema": "{original}", # Originalname behalten
"unterordner": "e-rechnungen",
"ist_fallback": False
},
"signierte_pdfs": {
"name": "Signierte PDFs",
"beschreibung": "Digital signierte PDF-Dokumente (Verträge, Bescheide)",
"prioritaet": 10,
"muster": {
"ist_pdf": True,
"ist_signiert": True
},
"schema": "{original}",
"unterordner": "signierte_dokumente",
"ist_fallback": False
},
"bilder": {
"name": "Bilder",
"beschreibung": "Alle Bilddateien (JPG, PNG, TIFF, etc.)",
"prioritaet": 20,
"muster": {
"ist_bild": True
},
"schema": "{original}",
"unterordner": "bilder",
"ist_fallback": False
},
"pdfs_ohne_text": {
"name": "Gescannte PDFs (ohne OCR)",
"beschreibung": "PDFs ohne durchsuchbaren Text (Scans)",
"prioritaet": 30,
"muster": {
"ist_pdf": True,
"hat_text": False
},
"schema": "{original}",
"unterordner": "scans",
"ist_fallback": False
},
"alle_pdfs": {
"name": "Alle PDFs (Fallback)",
"beschreibung": "Alle PDF-Dokumente die keiner anderen Regel entsprechen",
"prioritaet": 900, # Sehr niedrige Priorität - als Fallback
"muster": {
"ist_pdf": True
},
"schema": "{original}",
"unterordner": "dokumente",
"ist_fallback": True
},
"alle_bilder_fallback": {
"name": "Alle Bilder (Fallback)",
"beschreibung": "Alle Bilddateien die keiner anderen Regel entsprechen",
"prioritaet": 910,
"muster": {
"ist_bild": True
},
"schema": "{original}",
"unterordner": "bilder",
"ist_fallback": True
},
"alle_dateien_fallback": {
"name": "Alle anderen Dateien (Fallback)",
"beschreibung": "Alle Dateien die keiner Regel entsprechen - letzte Auffang-Regel",
"prioritaet": 999, # Absolut letzte Regel
"muster": {}, # Leeres Muster = passt auf alles
"schema": "{original}",
"unterordner": "sonstiges",
"ist_fallback": True
}
}
def liste_typ_regeln(nur_fallback: bool = None) -> List[Dict]:
"""
Gibt Liste aller Typ-basierten Regeln für UI zurück
Args:
nur_fallback: None = alle, True = nur Fallbacks, False = keine Fallbacks
"""
result = []
for key, config in TYP_REGELN.items():
ist_fallback = config.get("ist_fallback", False)
# Filtern nach Fallback-Status
if nur_fallback is True and not ist_fallback:
continue
if nur_fallback is False and ist_fallback:
continue
result.append({
"id": key,
"name": config["name"],
"beschreibung": config["beschreibung"],
"prioritaet": config["prioritaet"],
"muster": config["muster"],
"unterordner": config["unterordner"],
"ist_fallback": ist_fallback
})
# Nach Priorität sortieren
return sorted(result, key=lambda x: x["prioritaet"])
def erstelle_typ_regel(typ_id: str, unterordner: str = None, prioritaet: int = None) -> Dict:
"""
Erstellt eine Typ-basierte Regel
Args:
typ_id: ID aus TYP_REGELN (z.B. "zugferd", "bilder")
unterordner: Optionaler Unterordner (überschreibt Standard)
prioritaet: Optionale Priorität (überschreibt Standard)
Returns:
Regel-Dict für die Datenbank
"""
if typ_id not in TYP_REGELN:
raise ValueError(f"Unbekannter Typ: {typ_id}")
typ_config = TYP_REGELN[typ_id]
return {
"name": typ_config["name"],
"prioritaet": prioritaet or typ_config["prioritaet"],
"aktiv": True,
"muster": typ_config["muster"].copy(),
"extraktion": {},
"schema": typ_config["schema"],
"unterordner": unterordner or typ_config["unterordner"]
}

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
# Regeln können außerhalb bearbeitet werden
- ./regeln:/app/regeln
# Archiv auf Host mounten (optional, für direkten Zugriff)
# - /mnt/user/archiv:/archiv
# Host /mnt einbinden für Zugriff auf Dateien
- /mnt:/mnt
# Dev: Source code einbinden
- ./backend:/app/backend
- ./frontend:/app/frontend
# Zugriff auf /srv für Dateimanager
- /srv:/srv
environment:
- TZ=Europe/Berlin
- DATABASE_URL=sqlite:////app/data/dateiverwaltung.db

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 ============ */
/* Default Theme (Original Dark) */
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
@ -15,6 +16,76 @@
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* KDE Breeze Dark Theme */
[data-theme="breeze-dark"] {
--primary: #3daee9;
--primary-dark: #2980b9;
--success: #27ae60;
--danger: #da4453;
--warning: #f67400;
--bg: #1b1e20;
--bg-secondary: #232629;
--bg-tertiary: #31363b;
--text: #eff0f1;
--text-secondary: #7f8c8d;
--border: #3d4349;
--radius: 4px;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
/* KDE Breeze Light Theme */
[data-theme="breeze-light"] {
--primary: #2980b9;
--primary-dark: #1d5a8a;
--success: #27ae60;
--danger: #da4453;
--warning: #f67400;
--bg: #eff0f1;
--bg-secondary: #fcfcfc;
--bg-tertiary: #e3e5e7;
--text: #232629;
--text-secondary: #7f8c8d;
--border: #bdc3c7;
--radius: 4px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
/* Explicit Dark Theme (overrides system preference) */
[data-theme="dark"] {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--bg: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--border: #475569;
--radius: 8px;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* System preference detection (only when no theme is explicitly set) */
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--primary: #2980b9;
--primary-dark: #1d5a8a;
--success: #27ae60;
--danger: #da4453;
--warning: #f67400;
--bg: #eff0f1;
--bg-secondary: #fcfcfc;
--bg-tertiary: #e3e5e7;
--text: #232629;
--text-secondary: #7f8c8d;
--border: #bdc3c7;
--radius: 4px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
}
/* ============ Reset & Base ============ */
* {
margin: 0;
@ -173,6 +244,20 @@ body {
margin-bottom: 1rem;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.action-hint {
display: block;
margin-top: 0.5rem;
color: var(--text-secondary);
font-size: 0.75rem;
}
/* ============ Config Items ============ */
.config-item {
display: flex;
@ -382,6 +467,76 @@ body {
.badge-warning { background: var(--warning); color: #000; }
.badge-danger { background: var(--danger); }
.badge-info { background: var(--primary); }
.badge-secondary { background: var(--bg-tertiary); }
.badge-typ { background: #7c3aed; }
/* ============ Schnell-Regeln (Typ-basiert) ============ */
.card-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
padding: 0.5rem;
background: var(--bg-tertiary);
border-radius: var(--radius);
border-left: 3px solid var(--primary);
}
.config-item.typ-regel {
border-left: 3px solid #7c3aed;
}
.config-item.fallback-regel {
border-left: 3px solid var(--warning);
opacity: 0.85;
}
.config-item.fallback-regel h4::after {
content: " (Fallback)";
font-size: 0.7rem;
color: var(--warning);
font-weight: normal;
}
.info-box {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.info-box strong {
display: block;
margin-bottom: 0.5rem;
color: var(--text);
}
.info-box p {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.info-box small {
display: block;
color: var(--text-secondary);
font-size: 0.75rem;
}
.info-box code {
background: var(--bg);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.7rem;
}
.modal-hint {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: var(--radius);
}
/* ============ Loading Overlay ============ */
.loading-overlay {
@ -541,3 +696,411 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* ============ Statistik ============ */
.statistik-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.stat-item {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: var(--radius);
text-align: center;
}
.stat-label {
display: block;
color: var(--text-secondary);
font-size: 0.75rem;
margin-bottom: 0.5rem;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 600;
color: var(--primary);
}
/* ============ Hilfe Bereich ============ */
.hilfe-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.hilfe-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.hilfe-section h4 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.hilfe-section p {
color: var(--text-secondary);
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.hilfe-section textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-tertiary);
color: var(--text);
font-size: 0.875rem;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.doku-box {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: var(--radius);
font-size: 0.8rem;
}
.doku-box h5 {
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--primary);
font-size: 0.875rem;
}
.doku-box h5:first-child {
margin-top: 0;
}
.doku-box pre {
background: var(--bg);
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.5;
}
#hilfe-analyse {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: var(--radius);
}
#hilfe-analyse h5 {
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--success);
}
#hilfe-analyse h5:first-child {
margin-top: 0;
}
#hilfe-analyse pre {
background: var(--bg);
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.75rem;
}
#hilfe-analyse ul {
list-style: none;
padding: 0;
}
#hilfe-analyse li {
padding: 0.25rem 0;
font-size: 0.8rem;
}
/* ============ Header Right ============ */
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ============ Theme Selector ============ */
.theme-select {
background: var(--bg-tertiary);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
outline: none;
}
.theme-select:hover {
border-color: var(--primary);
}
.theme-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.theme-select option {
background: var(--bg-secondary);
color: var(--text);
}
/* ============ Regex Editor ============ */
.regex-editor {
background: var(--bg);
padding: 1rem;
border-radius: var(--radius);
margin: 1rem 0;
}
.regex-row {
margin-bottom: 0.75rem;
}
.regex-row:last-child {
margin-bottom: 0;
}
.regex-row label {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.25rem;
color: var(--text);
}
.regex-row input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8rem;
}
.regex-row small {
display: block;
color: var(--text-secondary);
font-size: 0.7rem;
margin-top: 0.25rem;
}
.regel-vorschau {
margin-top: 1rem;
padding: 1rem;
background: var(--bg);
border-radius: var(--radius);
border-left: 3px solid var(--success);
}
.regel-vorschau h5 {
margin-bottom: 0.5rem;
color: var(--success);
}
.regel-vorschau pre {
background: var(--bg-tertiary);
padding: 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
overflow-x: auto;
white-space: pre-wrap;
}
/* ============ Regex Input mit Dropdown ============ */
.regex-input-group {
display: flex;
gap: 0.5rem;
}
.regex-input-group input {
flex: 1;
}
.regex-input-group select {
width: auto;
min-width: 120px;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text);
font-size: 0.75rem;
cursor: pointer;
}
/* ============ Regex Cheatsheet ============ */
.regex-cheatsheet {
font-size: 0.8rem;
}
.cheatsheet-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.cheatsheet-section {
background: var(--bg);
padding: 0.75rem;
border-radius: 4px;
}
.cheatsheet-section h5 {
margin-top: 0 !important;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.cheatsheet-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.cheatsheet-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border);
}
.cheatsheet-table td:first-child {
font-family: 'Consolas', 'Monaco', monospace;
color: var(--success);
white-space: nowrap;
}
.cheatsheet-table tr:last-child td {
border-bottom: none;
}
.cheatsheet-tip {
margin-top: 1rem;
padding: 0.75rem;
background: rgba(59, 130, 246, 0.2);
border-left: 3px solid var(--primary);
border-radius: 4px;
font-size: 0.8rem;
}
.cheatsheet-tip code {
background: var(--bg);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.75rem;
}
/* ============ PDF Browser ============ */
.pdf-ordner-auswahl {
margin-bottom: 1rem;
}
.pdf-ordner-auswahl label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.pdf-ordner-auswahl select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-tertiary);
color: var(--text);
font-size: 0.875rem;
}
.pdf-dateien-liste {
max-height: 300px;
overflow-y: auto;
}
.pdf-file-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.pdf-file-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
}
.pdf-file-item:hover {
background: var(--bg-tertiary);
}
.pdf-file-item:last-child {
border-bottom: none;
}
/* ============ Permission Badges ============ */
.perm-badge {
display: inline-block;
padding: 0.125rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
margin-left: 0.5rem;
background: var(--bg-tertiary);
}
.perm-badge-small {
display: inline-block;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 500;
margin-left: auto;
background: var(--bg);
color: var(--text-secondary);
}
.file-browser-item.perm-ok .perm-badge-small {
color: var(--success);
}
.file-browser-item.perm-no-write .perm-badge-small {
color: var(--warning);
}
.file-browser-item.perm-no-read {
opacity: 0.6;
}
.file-browser-item.perm-no-read .perm-badge-small {
color: var(--danger);
}

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>
</div>
<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>
</div>
</header>
@ -83,24 +92,77 @@
</div>
</div>
<!-- Regeln -->
<!-- Schnell-Regeln (Typ-basiert für Grob-Sortierung) -->
<div class="card">
<div class="card-header">
<h3>Sortier-Regeln</h3>
<button class="btn btn-sm btn-primary" onclick="zeigeRegelModal()">+ Hinzufügen</button>
<h3>Schnell-Regeln (Grob-Sortierung)</h3>
<div>
<button class="btn btn-sm btn-primary" onclick="zeigeSchnellRegelModal()">+ Schnell-Regel</button>
</div>
</div>
<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">
<p class="empty-state">Keine Regeln definiert</p>
</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">
<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
</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>
<!-- Sortierungs-Log -->
@ -168,6 +230,11 @@
<option value="true">Nur ungelesene Mails</option>
</select>
</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">
<label>Ziel-Ordner</label>
<div class="input-with-btn">
@ -313,7 +380,10 @@
<div class="form-group">
<label>Ziel-Unterordner (optional)</label>
<div class="input-with-btn">
<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>
</div>
@ -354,6 +424,282 @@
</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 -->
<div id="loading-overlay" class="loading-overlay hidden">
<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>