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:
parent
013b037322
commit
e91d554ce1
19 changed files with 6324 additions and 108 deletions
79
.env.example
79
.env.example
|
|
@ -1,15 +1,84 @@
|
||||||
# Dateiverwaltung Umgebungsvariablen
|
# ==============================================
|
||||||
# Kopiere diese Datei nach .env und passe sie an
|
# Dateiverwaltung - Umgebungsvariablen
|
||||||
|
# ==============================================
|
||||||
|
# Kopiere diese Datei nach .env und passe sie an:
|
||||||
|
# cp .env.example .env
|
||||||
|
# nano .env
|
||||||
|
# ==============================================
|
||||||
|
|
||||||
# Datenbank
|
# ----------------------------------------------
|
||||||
DATABASE_URL=sqlite:///./data/dateiverwaltung.db
|
# Server-Einstellungen
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
# Port für die Web-Oberfläche
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
# Zeitzone
|
# 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-...
|
||||||
|
|
|
||||||
|
|
@ -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
489
INSTALLATION.md
Normal 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` |
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
# 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()
|
return domain.capitalize()
|
||||||
|
|
||||||
# 3. Regex-Muster
|
# 5. Website im Text suchen
|
||||||
|
web_match = re.search(r"(?:www\.|https?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", text, re.IGNORECASE)
|
||||||
|
if web_match:
|
||||||
|
domain = web_match.group(1)
|
||||||
|
if len(domain) > 2:
|
||||||
|
return domain.capitalize()
|
||||||
|
|
||||||
|
# 6. Regex-Muster als Fallback
|
||||||
muster_liste = (spezifische_muster or []) + FIRMA_MUSTER
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,8 +194,16 @@ 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
|
||||||
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
|
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)
|
status, messages = self.connection.search(None, search_criteria)
|
||||||
|
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
|
|
@ -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,11 +356,24 @@ 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)
|
||||||
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
|
# Suche mit optionalem Datum-Filter
|
||||||
|
if ab_datum:
|
||||||
|
datum_str = ab_datum.strftime("%d-%b-%Y")
|
||||||
|
if nur_ungelesen:
|
||||||
|
search_criteria = f'(UNSEEN SINCE {datum_str})'
|
||||||
|
else:
|
||||||
|
search_criteria = f'(SINCE {datum_str})'
|
||||||
|
else:
|
||||||
|
search_criteria = "(UNSEEN)" if nur_ungelesen else "ALL"
|
||||||
status, messages = self.connection.search(None, search_criteria)
|
status, messages = self.connection.search(None, search_criteria)
|
||||||
|
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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"]:
|
||||||
if ocr_erfolg:
|
ergebnis["ocr_uebersprungen_grund"] = "ZUGFeRD-Rechnung - keine Modifikation erlaubt"
|
||||||
ergebnis["text"] = ocr_text
|
logger.info(f"OCR übersprungen (ZUGFeRD): {pfad.name}")
|
||||||
ergebnis["hat_text"] = bool(ocr_text and len(ocr_text.strip()) > 50)
|
# Trotzdem versuchen Text zu extrahieren ohne einzubetten
|
||||||
ergebnis["ocr_durchgefuehrt"] = True
|
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
|
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():
|
||||||
# Original mit OCR-Version ersetzen
|
if in_place:
|
||||||
pfad.unlink()
|
# Original mit OCR-Version ersetzen
|
||||||
temp_pfad.rename(pfad)
|
pfad.unlink()
|
||||||
|
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 = {}
|
||||||
|
|
|
||||||
|
|
@ -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
81
docker-compose.portainer.yml
Normal file
81
docker-compose.portainer.yml
Normal 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 |
|
||||||
|
# ==============================================
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
608
frontend/static/css/browser.css
Normal file
608
frontend/static/css/browser.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
1079
frontend/static/js/browser.js
Normal file
1079
frontend/static/js/browser.js
Normal file
File diff suppressed because it is too large
Load diff
178
frontend/templates/browser.html
Normal file
178
frontend/templates/browser.html
Normal 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')">×</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')">×</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')">×</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>
|
||||||
|
|
@ -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">
|
||||||
▶ Sortierung starten
|
<button id="sortierung-start-btn" class="btn btn-success btn-large" onclick="sortierungStarten(false)">
|
||||||
</button>
|
▶ 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>
|
</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>
|
||||||
<input type="text" id="regel-unterordner" placeholder="sonepar">
|
<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>
|
<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')">×</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... 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')">×</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')">×</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')">×</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>
|
||||||
|
|
|
||||||
376
frontend/templates/preview.html
Normal file
376
frontend/templates/preview.html
Normal 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>
|
||||||
Loading…
Reference in a new issue