Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e24aa2ecd | |||
| 2062476252 | |||
| 8da0b7da1c | |||
| 578472872e | |||
| cbdc9d6664 | |||
| a23a3968ef | |||
| 9cdc9ae443 | |||
| 6f7cadfc63 |
@@ -4,6 +4,7 @@ __pycache__/
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
+247
-54
@@ -1,8 +1,15 @@
|
||||
# AI Agent Briefing — PDF OCR Hotfolder
|
||||
|
||||
**Zuletzt aktualisiert:** 2026-04-08
|
||||
**Version:** 0.2.0
|
||||
**Status:** Multi-Instanz-Support, nicht produktiv getestet
|
||||
**Zuletzt aktualisiert:** 2026-09-22
|
||||
**Version:** 0.6.0
|
||||
**Status:** Multi-Instanz-Betrieb, Preflight-Checks, Fehlerzählung, Wiederaufnahme aus `working/` und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation). Test-Suite grün (135 pytest-Tests). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6 und aus Vorbereitungen auf Debian 13, nicht aus einem belegten Dauerbetrieb.
|
||||
|
||||
> **Betriebsabläufe stehen nicht hier**, sondern in:
|
||||
> [README.md](README.md) (Einstieg, Layout, Config-Überblick) ·
|
||||
> [docs/INSTALLATION.md](docs/INSTALLATION.md) (Erstinstallation, Instanzen, Konfigurationsreferenz) ·
|
||||
> [docs/UPDATE.md](docs/UPDATE.md) (Update, Backup, Rollback, `--check-config`) ·
|
||||
> [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md) (Debian-Major-Upgrade, venv-Rebuild, Pins).
|
||||
> Dieses Briefing beschreibt **wie der Code aufgebaut ist und warum** — Schritt-für-Schritt-Anleitungen gehören in die drei Dokumente, nicht hierher.
|
||||
|
||||
## 🎯 Projektziel
|
||||
|
||||
@@ -13,21 +20,41 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in
|
||||
```
|
||||
pdf-ocr-hotfolder/
|
||||
├── pdf_ocr_hotfolder/
|
||||
│ ├── __init__.py # Versionsstring
|
||||
│ ├── __main__.py # CLI-Entrypoint (argparse, --once, --config)
|
||||
│ ├── config.py # TOML-Loader, Dataclasses
|
||||
│ ├── service.py # Hauptservice (watchdog + ThreadPool)
|
||||
│ ├── processor.py # ocrmypdf + veraPDF
|
||||
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, email
|
||||
│ ├── __init__.py # Versionsstring (__version__)
|
||||
│ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
|
||||
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
|
||||
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler
|
||||
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung
|
||||
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
|
||||
├── tests/ # pytest-Suite (135 Tests, ocrmypdf wird gemockt)
|
||||
│ ├── conftest.py # Fixtures tmp_config / dummy_pdf
|
||||
│ ├── test_check_config.py # --check-config, Exit 0/1/2
|
||||
│ ├── test_config_errors.py
|
||||
│ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen
|
||||
│ ├── test_error_counting.py
|
||||
│ ├── test_ghostscript_version.py
|
||||
│ ├── test_ocr_timeout.py
|
||||
│ ├── test_once_exit_code.py
|
||||
│ ├── test_output_naming.py
|
||||
│ ├── test_preflight.py
|
||||
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
|
||||
│ └── test_upload_folder.py
|
||||
├── systemd/
|
||||
│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i)
|
||||
│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300
|
||||
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
|
||||
├── docs/
|
||||
│ ├── INSTALLATION.md # Erstinstallation + Konfigurationsreferenz
|
||||
│ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift
|
||||
│ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins
|
||||
├── pytest.ini # testpaths = tests
|
||||
├── config.example.toml
|
||||
├── install.sh # Interaktiver Installer
|
||||
├── update.sh # Update aus Repo
|
||||
├── requirements.txt
|
||||
├── install.sh # Interaktiver Installer + Instanz-Manager
|
||||
├── update.sh # Updater (--help, --rebuild-venv), ~860 Zeilen
|
||||
├── requirements.txt # feste Pins (ocrmypdf 16.x!)
|
||||
├── VERSION
|
||||
├── CHANGELOG.md
|
||||
└── README.md
|
||||
├── README.md
|
||||
└── AI_AGENT_BRIEFING.md
|
||||
```
|
||||
|
||||
## 🔧 Stack
|
||||
@@ -35,25 +62,42 @@ pdf-ocr-hotfolder/
|
||||
| Komponente | Technologie |
|
||||
|------------|-------------|
|
||||
| Sprache | Python 3.11+ (für `tomllib` aus stdlib) |
|
||||
| OCR | `ocrmypdf` (als Library, nicht via Subprozess) |
|
||||
| OCR | `ocrmypdf` (als Library, nicht via Subprozess; Import ist lazy) |
|
||||
| Engine | Tesseract |
|
||||
| Watcher | `watchdog` |
|
||||
| HTTP | `requests` (Nextcloud WebDAV) |
|
||||
| SFTP | `paramiko` |
|
||||
| Email | `smtplib` (stdlib) |
|
||||
| Service | systemd |
|
||||
| Tests | `pytest` |
|
||||
| Service | systemd (Template-Unit) |
|
||||
|
||||
Die vier Python-Deps sind in `requirements.txt` **fest gepinnt** (`==`), damit ein
|
||||
Update nicht ungefragt einen Major-Sprung einzieht (ocrmypdf 16 → 17 würde alle
|
||||
Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13
|
||||
(Debian 13), Wheels für beide vorhanden. Anheben nur mit Testmaschine —
|
||||
[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md#pins-in-requirementstxt).
|
||||
|
||||
## 🖥️ Installations-Layout (Multi-Instanz)
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|------|--------|
|
||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
||||
| `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) |
|
||||
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (mode 640, root:<service-group>) |
|
||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit |
|
||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf` | Drop-in für Container (optional) |
|
||||
| `/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) |
|
||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz |
|
||||
| `/var/log/pdf-ocr-hotfolder/` | Logs |
|
||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
|
||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) |
|
||||
|
||||
Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom
|
||||
Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne
|
||||
FileHandler, alles geht nach stdout → journald.
|
||||
|
||||
```bash
|
||||
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
|
||||
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
|
||||
```
|
||||
|
||||
## 👤 Service-User
|
||||
|
||||
@@ -63,51 +107,175 @@ pdf-ocr-hotfolder/
|
||||
- Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via `id -gn` ermittelt
|
||||
- Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — transparent
|
||||
|
||||
## 🗂️ Instanz-Management
|
||||
## 🗂️ Instanz-Management (Kurzfassung)
|
||||
|
||||
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**:
|
||||
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**. Der komplette
|
||||
Ablauf inklusive aller fünf Abfragen steht in
|
||||
[docs/INSTALLATION.md](docs/INSTALLATION.md#die-abfragen-pro-instanz). Für die
|
||||
Arbeit am Code zählt:
|
||||
|
||||
- Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht)
|
||||
- Folgender Lauf: Basis-Install wird übersprungen, bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden
|
||||
- Eingaben pro Instanz: Name (`[a-z0-9-]+`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`), Service-User
|
||||
- `config.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert
|
||||
- Instanz wird sofort `enable --now` gestartet
|
||||
- Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer**
|
||||
die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut
|
||||
(`venv_is_healthy()` in `install.sh`, schlankere Variante der Prüfung in `update.sh`).
|
||||
- Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**,
|
||||
**Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in
|
||||
`create_instance()`, gelten also **instanz-lokal** und nicht global.
|
||||
- Sprachprüfung gegen `tesseract --list-langs`, fehlende Pakete werden als
|
||||
`tesseract-ocr-<code>` angeboten (Unterstrich → Bindestrich). Ablehnung führt
|
||||
nicht zum Abbruch, sondern zurück zur Sprach-Abfrage.
|
||||
- Das Archiv-Verzeichnis darf **nicht** `incoming/`/`outgoing/`/`working/`/`error/`
|
||||
sein — im Eingang würde das Original endlos neu aufgegriffen.
|
||||
- `<instanz>.toml` wird aus `config.example.toml` per `sed` erzeugt. Substituiert
|
||||
werden die vier `[paths]`-Zeilen **sowie** `[ocr].languages`,
|
||||
`[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind
|
||||
am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen
|
||||
Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen
|
||||
vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf
|
||||
liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe.
|
||||
- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` zwischen den
|
||||
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
|
||||
`pdf_ocr_apt_packages()`. **`update.sh` schneidet diesen Block per `sed` heraus
|
||||
und evaluiert ihn** — Marken und Funktionsname dürfen sich nicht ändern, ohne
|
||||
`update.sh` anzupassen.
|
||||
- Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer
|
||||
nicht, das steht als Handgriff in
|
||||
[docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen).
|
||||
|
||||
Manuelles Löschen einer Instanz:
|
||||
```bash
|
||||
systemctl disable --now pdf-ocr-hotfolder@<name>
|
||||
rm /etc/pdf-ocr-hotfolder/<name>.toml
|
||||
rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
|
||||
systemctl daemon-reload
|
||||
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
|
||||
```
|
||||
## 🔄 Update-Verhalten (Kurzfassung)
|
||||
|
||||
## 🔄 Update-Verhalten
|
||||
Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
|
||||
|
||||
`update.sh`:
|
||||
1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units
|
||||
2. Stoppt diese
|
||||
3. Backup nach `/var/backups/pdf-ocr-hotfolder/`
|
||||
4. Kopiert Code + requirements + VERSION + config.example aus dem Repo
|
||||
5. `pip install --upgrade` im venv
|
||||
6. Aktualisiert Template-Unit + `daemon-reload`
|
||||
7. Startet alle zuvor aktiven Instanzen wieder
|
||||
8. Exit 1 wenn eine Instanz nicht mehr hochkommt
|
||||
- `update.sh` hat `--help` und `--rebuild-venv`, läuft mit `set -Eeuo pipefail`
|
||||
und hat ab dem Stoppen der Instanzen einen **ERR/INT/TERM-Trap**: er sagt, ob
|
||||
auf der Platte schon getauscht wurde (`TOUCHED`), startet die vorher laufenden
|
||||
Instanzen wieder und nennt Backup + Rollback-Befehl.
|
||||
- Reihenfolge: Instanzen erfassen → apt-Sync → venv-Health → stoppen → Backup →
|
||||
Code → Deps/venv → Units → chown → `--check-config` → starten + verifizieren →
|
||||
Zusammenfassung (Soll gegen Ist, Exit 1 bei Regression/Config-Fehler).
|
||||
- **Instanz-Erfassung** deckt `list-units --all` (inkl. `activating`/`failed`),
|
||||
`list-unit-files` und die Configs unter `/etc/pdf-ocr-hotfolder/` ab. Drei
|
||||
Gruppen: `PREV_OK`, `PREV_BROKEN`, `PREV_STOPPED` — bewusst gestoppte bleiben
|
||||
gestoppt.
|
||||
- **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft
|
||||
`is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei
|
||||
`Type=simple` als Erfolg durchgehen. Vorher `reset-failed`.
|
||||
- **venv-Health** (`venv_is_healthy()` in `update.sh`): Verzeichnis, ausführbarer
|
||||
Interpreter, Interpreter **läuft** überhaupt, `major.minor` == System-Python,
|
||||
`pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird auch ohne
|
||||
`--rebuild-venv` neu gebaut.
|
||||
- **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach
|
||||
`venv.old-<ts>`, neu bauen, Requirements installieren, **erst bei Erfolg** die
|
||||
alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen.
|
||||
`pip_install_requirements()` übersetzt pip-Fehler in eine Ansage mit dem
|
||||
gescheiterten Paketnamen und dem Hinweis "requirements.txt anheben".
|
||||
- **apt-Sync** läuft auch beim Update (`sync_system_packages()`), ist idempotent
|
||||
und fasst nachinstallierte Sprachpakete nicht an (kein purge/autoremove).
|
||||
Fehlschläge setzen nur `APT_WARN`, sie brechen nicht ab.
|
||||
- **Backup** (`create_backup()`): Code, `/etc/pdf-ocr-hotfolder/`, Template-Unit,
|
||||
alle Drop-ins und ein `pip-freeze.txt` der alten venv. **Ohne** venv und
|
||||
**ohne** Datenverzeichnisse. `umask 077` + `chmod 600 root:root`, weil die
|
||||
Configs Klartext-Passwörter enthalten. Rotation: letzte `BACKUP_KEEP` = 5.
|
||||
- **LXC-Drop-in** wird beim Update aus dem Repo nachgezogen, **falls es
|
||||
installiert ist** — sonst würde ein neu ergänzter Hardening-Schalter in der
|
||||
Template-Unit alle Container-Instanzen reißen (Issue #4 redux).
|
||||
- Configs unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben. Das Repo
|
||||
muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`).
|
||||
- `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne
|
||||
irgendetwas zu tun — dafür sind `INSTALL_DIR`, `CONFIG_DIR`, `SYSTEMD_DIR`,
|
||||
`BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar.
|
||||
|
||||
Config-Dateien werden **nie** überschrieben.
|
||||
## ⚙️ Konfiguration (Überblick)
|
||||
|
||||
Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
|
||||
Vollständiges Beispiel mit Kommentaren: `config.example.toml`.
|
||||
|
||||
| Sektion | Zweck |
|
||||
|---------|-------|
|
||||
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, fehlt einer → `ConfigError` + Exit 2 |
|
||||
| `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
|
||||
| `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` |
|
||||
| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI |
|
||||
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op) |
|
||||
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
|
||||
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
||||
| `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` |
|
||||
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
|
||||
|
||||
Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**:
|
||||
`_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format
|
||||
`[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets),
|
||||
`unknown_key_warnings()` macht Meldungen daraus. Ein Tippfehler fällt damit auf.
|
||||
|
||||
### Warnungen statt Überraschungen
|
||||
|
||||
`config.py` kennt zwei Warnungsquellen, beide über `config_warnings()` gebündelt
|
||||
— der Text steht **nur dort**, weil ihn sowohl der Dienststart
|
||||
(`_log_config_warnings()` → `log.warning`) als auch `--check-config` ausgibt:
|
||||
|
||||
- `legacy_warnings()`: `[ocr].timeout >= LEGACY_TIMEOUT_THRESHOLD` (900) deutet
|
||||
auf den alten Gesamt-Timeout-Wert 1800 hin (Richtwert
|
||||
`RECOMMENDED_PAGE_TIMEOUT` = 300); gesetztes `pdfa_level` weist auf den
|
||||
Ghostscript-Bug hin.
|
||||
- `unknown_key_warnings()`: siehe oben.
|
||||
|
||||
### `--check-config`
|
||||
|
||||
`python -m pdf_ocr_hotfolder --check-config --config <datei>` lädt die Config,
|
||||
zeigt Pfade/Sprachen/Timeout/PDF/A, fährt `check_preflight()` und
|
||||
`check_output_config()` und gibt die Warnungen aus. Exit-Codes:
|
||||
`CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat Vorrang vor `--once`.
|
||||
`update.sh` wertet genau diese Codes aus und erkennt an der argparse-Meldung,
|
||||
wenn der installierte Code das Flag noch nicht kennt.
|
||||
|
||||
## 🔄 Verarbeitungs-Flow
|
||||
|
||||
1. `watchdog` triggert auf Datei-Event in `incoming/`
|
||||
2. `_wait_until_stable()` wartet, bis Datei nicht mehr wächst (Scanner schreibt mehrmals)
|
||||
3. Move nach `working/`
|
||||
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF — schneller)
|
||||
5. Optional: veraPDF-Validierung (CLI-Subprozess)
|
||||
6. Move nach `outgoing/` als `OCR_<originalname>.pdf`
|
||||
7. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
|
||||
8. Optional E-Mail-Notify
|
||||
**Beim Start (`run()` wie `run_once()`), vor allem anderen:**
|
||||
1. `check_preflight()` — `tesseract` und `gs` müssen im PATH sein; ist `pdfa_level` gesetzt, wird zusätzlich die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft
|
||||
2. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
|
||||
3. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/fehlender Config)
|
||||
4. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`
|
||||
|
||||
Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten Bash-Tool).
|
||||
**Wiederaufnahme aus `working/` (`_scan_working()`):**
|
||||
`process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der
|
||||
Dienst dort abgeschossen (SIGKILL nach `TimeoutStopSec`), blieb es früher
|
||||
liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt:
|
||||
|
||||
- Dateien mit dem Präfix `OCR_TEMP_PREFIX` (`__ocr_`) sind **unvollständige
|
||||
Fragmente** des abgebrochenen ocrmypdf-Laufs: als Eingabe unbrauchbar, als
|
||||
Ergebnis wertlos → werden **gelöscht** (mit `log.warning`).
|
||||
- Echte PDFs werden **an Ort und Stelle** wiederaufgenommen; `process_pdf()`
|
||||
erkennt das über `_is_same_file()` und verschiebt nicht erneut.
|
||||
- Liegt in `incoming/` eine gleichnamige, andere Datei, bekommt die
|
||||
wiederaufgenommene per `_free_resume_name()` einen Zeitstempel angehängt —
|
||||
sonst würden beide dieselbe working- und outgoing-Datei beanspruchen.
|
||||
- Liegt in `working/` bereits eine **andere** Datei desselben Namens, bricht
|
||||
`process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den
|
||||
laufenden Vorgang stillschweigend zu überschreiben.
|
||||
|
||||
**Pro Datei:**
|
||||
1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/`
|
||||
2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s)
|
||||
3. Move nach `working/` (entfällt bei Wiederaufnahme)
|
||||
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_<zielname>`
|
||||
5. Optional: veraPDF-Validierung (CLI-Subprozess) — bei FAIL geht das OCR-Ergebnis nach `error/`, das Original folgt `original_on_success` (wird also bei `archive` **nicht** gelöscht)
|
||||
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`)
|
||||
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix)
|
||||
8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
|
||||
9. E-Mail-Notify je nach `[notify.email].on`
|
||||
|
||||
**Fehlerbehandlung:**
|
||||
|
||||
| Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach |
|
||||
|------------|------------------|----------------------------|
|
||||
| Stabilitäts-Check läuft in den Timeout | ja | bleibt in `incoming/`, wird beim nächsten Lauf erneut versucht |
|
||||
| Datei verschwindet vor der Verarbeitung | nein | — |
|
||||
| In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` |
|
||||
| OCR wirft (ocrmypdf) | ja | `error/` |
|
||||
| veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) |
|
||||
| Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` |
|
||||
| Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele |
|
||||
|
||||
Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool). Im `--once`-Modus liefert die CLI **Exit-Code 1**, sobald `error_count > 0` ist, sonst 0.
|
||||
|
||||
## 🧠 Performance-Entscheidungen
|
||||
|
||||
@@ -116,8 +284,20 @@ Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten
|
||||
- **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs
|
||||
- **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet
|
||||
- **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke)
|
||||
- **`upload_folder()` nutzt `shutil.copyfile()`** statt `read_bytes()`/`write_bytes()` — große PDFs landen nicht komplett im RAM
|
||||
- veraPDF nur wenn `enabled=true` (JVM-Start ist teuer)
|
||||
|
||||
## ⚠️ Fallstricke
|
||||
|
||||
- **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. In Kombination aus `[ocr].pdfa_level` + `skip_text = true` blockiert ocrmypdf komplett (Issue #3). Deshalb ist `pdfa_level = ""` der sichere Default, der Preflight bricht mit Exit 2 ab, wenn `pdfa_level` gesetzt **und** die GS-Version betroffen ist, und `--check-config` warnt bei gesetztem `pdfa_level` grundsätzlich. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an).
|
||||
- **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift.
|
||||
- **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf.
|
||||
- **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen `install.sh` und `update.sh` das je mit einem eigenen `venv_is_healthy()` (die Variante in `update.sh` ist die gründlichere und schaut zusätzlich in `pyvenv.cfg`).
|
||||
- **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an, `update.sh` zieht ein vorhandenes Drop-in nach.
|
||||
- **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). Auch `update.sh` ruft `--check-config` deshalb mit `cd "$INSTALL_DIR"` auf.
|
||||
- **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/<instanz>.toml`. Deshalb `chmod 640` und `chown root:<service-gruppe>`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. **Das Update-Backup enthält diese Configs** und ist deshalb `0600 root:root` in einem `700`-Verzeichnis. Beim Debuggen weder Config noch Backup in ein Ticket kopieren.
|
||||
- **Das Update-Backup enthält die venv NICHT.** Ein Rollback per `tar -xzf … -C /` holt den Paketstand also nicht zurück, und `tar` löscht nichts, was neu hinzugekommen ist. Grenzen des Rollbacks: [docs/UPDATE.md](docs/UPDATE.md#grenzen-des-rollbacks).
|
||||
|
||||
## 🛠️ Entwicklung
|
||||
|
||||
Lokaler Test ohne Installation:
|
||||
@@ -130,11 +310,23 @@ cp config.example.toml /tmp/config.toml
|
||||
python -m pdf_ocr_hotfolder --config /tmp/config.toml
|
||||
```
|
||||
|
||||
Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`):
|
||||
```bash
|
||||
pytest # aktuell 135 Tests
|
||||
```
|
||||
|
||||
`ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene.
|
||||
|
||||
## 📋 Roadmap / TODO
|
||||
|
||||
- [ ] Tests (`pytest`) für `processor` und `uploaders`
|
||||
- [x] Tests (`pytest`) für `processor` und `uploaders` — 135 Tests
|
||||
- [x] Wiederaufnahme abgebrochener Läufe aus `working/`
|
||||
- [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater
|
||||
- [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
|
||||
- [ ] Test-Lücken schließen: der watchdog-Eventpfad (`_Handler`/`Observer`) wird nirgends getestet, `run_verapdf()` ebenso wenig (der FAIL-*Pfad* in `process_pdf()` ist getestet, die veraPDF-CLI-Anbindung selbst nicht), und `run_ocr()` nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Auch `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt.
|
||||
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
|
||||
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
|
||||
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
|
||||
- [ ] Optional: S3/MinIO Upload-Target
|
||||
- [ ] Docker-Image für Setups ohne systemd
|
||||
|
||||
@@ -142,6 +334,7 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
|
||||
|
||||
- **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||
- **Owner:** sonith_ug
|
||||
- **SSH-User ist `gitea`, nicht `git`:** `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git`
|
||||
- **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell)
|
||||
- **Tags:** `v{VERSION}`, automatischer Push nach Commit
|
||||
|
||||
|
||||
+293
@@ -1,5 +1,298 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.0] - 2026-09-22
|
||||
|
||||
### Added
|
||||
- **Wiederaufnahme aus `working/` beim Start.** `process_pdf()` verschiebt das
|
||||
Original vor dem OCR nach `working/`. Wurde der Dienst dort hart abgeschossen
|
||||
(SIGKILL nach `TimeoutStopSec`), blieb die Datei liegen und wurde **nie wieder
|
||||
angefasst** — stiller Datenverlust. `_scan_working()` greift sie jetzt beim
|
||||
Start auf (vor `incoming/`), das OCR laeuft fuer sie neu. Liegt in `incoming/`
|
||||
eine gleichnamige, andere Datei, bekommt die wiederaufgenommene einen
|
||||
Zeitstempel angehaengt, damit sich beide nicht ueberschreiben. Liegt in
|
||||
`working/` bereits eine andere Datei desselben Namens, bricht `process_pdf()`
|
||||
fuer die neue ab und laesst sie in `incoming/` liegen, statt den laufenden
|
||||
Vorgang stillschweigend zu ueberschreiben.
|
||||
- **Unvollstaendige OCR-Fragmente werden geloescht.** Die Zwischendatei, in die
|
||||
ocrmypdf schreibt, traegt jetzt das Praefix `__ocr_` (`OCR_TEMP_PREFIX`).
|
||||
Bleibt so eine Datei nach einem harten Stopp in `working/` liegen, ist sie als
|
||||
Eingabe unbrauchbar und als Ergebnis wertlos — sie wird beim Start mit einer
|
||||
Warnung entfernt, damit sie niemand fuer ein fertiges PDF haelt.
|
||||
- **`--check-config`**: prueft eine Instanz-Config, ohne irgendetwas zu
|
||||
verarbeiten (hat Vorrang vor `--once`). Zeigt die vier Pfade inkl. Hinweis auf
|
||||
noch fehlende Verzeichnisse, Sprachen, Seiten-Timeout und PDF/A-Level, faehrt
|
||||
Preflight und `[output]`-Validierung und gibt alle Warnungen aus.
|
||||
Exit **0** = sauber, **1** = nur Warnungen, **2** = Fehler (Dienst wuerde nicht
|
||||
starten).
|
||||
- **Legacy-Warnungen fuer `[ocr].timeout` und `[ocr].pdfa_level`.** Ein
|
||||
`timeout >= 900` stammt fast sicher aus einer Config vor 0.4.0, wo der Wert ein
|
||||
wirkungsloses Gesamt-Timeout mit Default 1800 war — seither sind es Sekunden
|
||||
**pro Seite** (Richtwert 300). Ein gesetztes `pdfa_level` weist auf den
|
||||
Ghostscript-Bug hin. Die Texte stehen nur in `config.py`
|
||||
(`legacy_warnings()`), weil sie sowohl beim Dienststart ins Log gehen als auch
|
||||
von `--check-config` ausgegeben werden.
|
||||
- **Unbekannte Config-Keys werden gemeldet** statt still verworfen.
|
||||
`_collect_unknown_keys()` sammelt Tippfehler (`[ocr].langauges`), Optionen aus
|
||||
aelteren Versionen, unbekannte Sektionen und unbekannte Upload-/Notify-Targets
|
||||
in `Config.unknown_keys`; die Meldung nennt den vollen Pfad. Warnung, kein
|
||||
Fehler — der Dienst startet, der Eintrag tut nur nichts.
|
||||
- **`TimeoutStopSec=300` in der Template-Unit**: ein laufendes OCR darf beim
|
||||
Stoppen zu Ende laufen. Ein `systemctl stop` kann dadurch pro Instanz bis zu
|
||||
5 Minuten dauern — das ist gewollt, ein SIGKILL wuerde den Durchlauf kosten.
|
||||
- **Feste Pins in `requirements.txt`** (`ocrmypdf==16.13.0`, `watchdog==6.0.0`,
|
||||
`requests==2.33.1`, `paramiko==4.0.0`). Ohne Pins zieht ein
|
||||
`pip install --upgrade` beim Update ungefragt einen Major-Sprung ein; ocrmypdf
|
||||
16 -> 17 wuerde alle Instanzen auf einmal reissen. Geprueft gegen Python 3.11
|
||||
(Debian 12) und 3.13 (Debian 13), Wheels fuer beide vorhanden.
|
||||
- 40 neue Tests (Wiederaufnahme aus `working/`, `--check-config`,
|
||||
Config-Warnungen). Suite jetzt **135 Tests**.
|
||||
|
||||
### Changed
|
||||
- **Die Betriebsdoku ist in drei Dokumente aufgeteilt.** Der README ist wieder
|
||||
der Einstieg (Kurzbeschreibung, Features, Schnellstart, Verzeichnis-Layout,
|
||||
Config-Ueberblick) und verlinkt:
|
||||
- `docs/INSTALLATION.md` — Erstinstallation, Basis-Install vs. Instanz-Anlage,
|
||||
die Abfragen pro Instanz, Multi-Instanz-Betrieb, LXC (Error 226/NAMESPACE),
|
||||
Ghostscript auf Debian 12, Instanz manuell loeschen und die vollstaendige
|
||||
**Konfigurationsreferenz**.
|
||||
- `docs/UPDATE.md` — Ablauf von `update.sh`, was es nicht anfasst,
|
||||
Backup-Inhalt/-Rechte/-Rotation, Rollback und dessen Grenzen,
|
||||
`--check-config` mit den Exit-Codes und Config-Drift.
|
||||
- `docs/OS-UPGRADE.md` — Debian-Major-Upgrade als eigener Ablauf.
|
||||
`AI_AGENT_BRIEFING.md` bleibt der Agent-Kontext (Aufbau und Begruendungen) und
|
||||
verweist fuer Ablaeufe auf die drei Dokumente, statt sie zu wiederholen.
|
||||
Nichts wird doppelt gepflegt.
|
||||
- **`update.sh` komplett ueberarbeitet** (`--help`, `--rebuild-venv`,
|
||||
`set -Eeuo pipefail`):
|
||||
- **venv-Health-Check und Neubau.** Geprueft werden Existenz, Lauffaehigkeit
|
||||
des Interpreters, `major.minor` gegen das System-Python und `pyvenv.cfg`.
|
||||
Passt etwas nicht — typisch nach einem Debian-Major-Upgrade, systemd meldet
|
||||
dann `203/EXEC` —, wird die venv neu gebaut, auch ohne `--rebuild-venv`. Der
|
||||
Neubau ist ganz oder gar nicht: alte venv weg sichern, neu bauen,
|
||||
Requirements installieren, **erst bei Erfolg** die alte loeschen; scheitert
|
||||
etwas, wird zurueckgerollt und hart abgebrochen. Scheitert pip an einem Pin,
|
||||
nennt das Skript das gescheiterte Paket und den naechsten Schritt
|
||||
("requirements.txt anheben").
|
||||
- **apt-Sync auch beim Update.** Die Paketliste wird aus `install.sh`
|
||||
extrahiert (einzige Quelle, Marken `BEGIN/END apt-packages`) und
|
||||
installiert; nachinstallierte Tesseract-Sprachpakete bleiben unangetastet
|
||||
(kein purge, kein autoremove). Fehlschlaege warnen nur.
|
||||
- **Haertere Verifikation.** Nach dem Start prueft `verify_unit()` nicht nur
|
||||
`is-active`, sondern auch `is-failed` und den Restart-Zaehler — ein
|
||||
Crash-Loop galt bei `Type=simple` bisher als Erfolg. Die Zusammenfassung
|
||||
stellt Soll gegen Ist und meldet eine **Regression** namentlich.
|
||||
- **Vollstaendiges Backup.** Gesichert werden Code, alle Instanz-Configs, die
|
||||
Template-Unit, alle Drop-ins und ein `pip-freeze.txt` der alten venv — ohne
|
||||
venv und ohne Datenverzeichnisse. Weil die Configs Klartext-Passwoerter
|
||||
enthalten, wird das Archiv mit `umask 077` erzeugt und auf `0600 root:root`
|
||||
gesetzt, das Verzeichnis auf `700`. Rotation: die letzten 5 Archive bleiben.
|
||||
- **ERR-Trap.** Bricht das Update ab (Fehler, Strg-C, `kill`), sagt das Skript,
|
||||
ob auf der Platte schon getauscht wurde, startet die vorher laufenden
|
||||
Instanzen wieder und nennt Backup-Datei und Rollback-Befehl.
|
||||
- **Instanz-Erfassung** deckt jetzt auch `activating` und `failed` ab (ueber
|
||||
`list-units --all`, `list-unit-files` und die vorhandenen Configs). Vorher
|
||||
kaputte Instanzen werden mitgestartet, gelten aber erst als Erfolg, wenn sie
|
||||
danach wirklich laufen; bewusst gestoppte bleiben gestoppt.
|
||||
- **Config-Pruefung vor dem Start**: `--check-config` je Instanz, Exit 2 zaehlt
|
||||
als Fehler (Update-Exit 1), Exit 1 wird als Warnung samt Nachstell-Befehl
|
||||
ausgegeben. Kennt der installierte Code das Flag noch nicht, wird die
|
||||
Pruefung uebersprungen und das Update laeuft weiter.
|
||||
- **`install.sh` repariert eine kaputte venv.** Bisher reichte das blosse
|
||||
Vorhandensein von `venv/`, um den Basis-Install zu ueberspringen — nach einem
|
||||
Distributions-Upgrade hat der Installer damit gar nichts repariert. Jetzt wird
|
||||
die venv gegen das System-Python geprueft und bei Drift nach
|
||||
`venv.old-<timestamp>` gesichert und neu gebaut.
|
||||
- Die apt-Paketliste steht als **einzige Quelle** in `install.sh` in der Funktion
|
||||
`pdf_ocr_apt_packages()` zwischen den Marken `# --- BEGIN apt-packages` /
|
||||
`# --- END apt-packages`. `update.sh` schneidet den Block heraus und wertet ihn
|
||||
aus — Marken und Funktionsname duerfen sich nicht ohne Anpassung aendern.
|
||||
|
||||
### Fixed
|
||||
- **Dateien in `working/` gingen nach einem harten Stopp still verloren.** Siehe
|
||||
Wiederaufnahme oben — der Fall trat bei jedem SIGKILL waehrend eines OCR-Laufs
|
||||
auf, also auch bei einem Update ohne `TimeoutStopSec`.
|
||||
- **Tippfehler in Config-Keys fielen nicht auf.** `load_config()` filterte
|
||||
stumm gegen die Dataclass-Annotationen; `[ocr].langauges` lief damit
|
||||
wirkungslos mit. Jetzt gibt es eine Warnung mit vollem Key-Pfad.
|
||||
|
||||
## [0.5.0] - 2026-09-22
|
||||
|
||||
### Added
|
||||
- Der Installer weist einen Archiv-Pfad ab, der auf `incoming/`, `outgoing/`,
|
||||
`working/` oder `error/` der Instanz zeigt — im Eingang wuerde das Original
|
||||
sonst endlos neu aufgegriffen.
|
||||
- **`install.sh` fragt beim Anlegen einer Instanz die OCR-Sprachen ab**
|
||||
(`Tesseract-Sprachen [deu+eng]:`). Die Wahl gilt bewusst **pro Instanz** —
|
||||
ein Hotfolder `buchhaltung` kann mit `deu` laufen, ein Hotfolder `export` mit
|
||||
`deu+eng+fra`. Der Installer weist vorher darauf hin, dass jede zusaetzliche
|
||||
Sprache Laufzeit **und** Erkennungsqualitaet kostet, die Liste also eng
|
||||
gehalten werden sollte. Das Eingabeformat wird geprueft (Sprachcodes mit `+`
|
||||
verbunden, `chi_sim` & Co. erlaubt); bei Unsinn wird erneut gefragt statt
|
||||
abzubrechen.
|
||||
- **Sprachpakete werden nachinstalliert.** Jeder eingegebene Code wird gegen
|
||||
`tesseract --list-langs` geprueft. Fehlt eine Sprachdatei, bietet der
|
||||
Installer das passende apt-Paket an (`tesseract-ocr-<code>`, Unterstrich wird
|
||||
zum Bindestrich: `chi_sim` → `tesseract-ocr-chi-sim`). Lehnt der User ab oder
|
||||
laesst sich das Paket nicht installieren, warnt der Installer, dass OCR mit
|
||||
dieser Sprache **bei jeder Datei** scheitern wuerde, und fragt die Sprachen
|
||||
erneut ab — so kann die Sprache einfach wieder rausgeworfen werden. Ist
|
||||
`tesseract` nicht aufrufbar, wird die Pruefung uebersprungen und die Eingabe
|
||||
unveraendert uebernommen.
|
||||
- **Abfrage `Original nach erfolgreichem OCR archivieren? [j/N]:`** — Default
|
||||
nein, also weiterhin `original_on_success = "delete"`. Bei ja wird der
|
||||
Archiv-Pfad abgefragt (Vorschlag `<basis>/archive`), angelegt und auf den
|
||||
Service-User gechownt; ein Archiv ausserhalb des Instanz-Basis-Pfads bekommt
|
||||
ein eigenes `chown -R`.
|
||||
|
||||
### Changed
|
||||
- Die Instanz-Config wird weiterhin per `sed` aus `config.example.toml`
|
||||
erzeugt, substituiert jetzt aber zusaetzlich `[ocr].languages`,
|
||||
`[output].original_on_success` und `[output].archive_dir` — bisher waren das
|
||||
die Beispiel-Defaults, `archive_dir` musste von Hand nachgetragen werden.
|
||||
Die Ausdruecke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die
|
||||
deutschen Kommentarzeilen ueber den Keys unangetastet bleiben, und
|
||||
Pfad-Variablen laufen durch `sed_escape_repl()` (maskiert `\`, `&`, `|`) —
|
||||
Pfade mit Sonderzeichen landen damit korrekt in der Config.
|
||||
- Nach dem sed-Lauf liest der Installer die drei Keys aus der erzeugten Config
|
||||
zurueck und vergleicht sie mit der Eingabe. Erst wenn das passt, nennt die
|
||||
Abschluss-Zusammenfassung zusaetzlich die gewaehlten **Sprachen** und (bei
|
||||
Archivierung) das **Archiv-Verzeichnis**; sonst gibt es eine Warnung.
|
||||
|
||||
## [0.4.1] - 2026-09-22
|
||||
|
||||
### Fixed
|
||||
- **veraPDF-FAIL hat das Original immer gelöscht.** Schlug die PDF/A-Validierung
|
||||
fehl, wanderte das OCR-Ergebnis nach `error/` und das Original wurde per
|
||||
`unlink()` entfernt — unabhängig von `[output].original_on_success`. Wer
|
||||
`archive` konfiguriert hatte, verlor die Datei also ausgerechnet im
|
||||
Fehlerfall. Der FAIL-Pfad nutzt jetzt dieselbe `_dispose_original()`-Logik
|
||||
wie der Erfolgsfall: `archive` legt das Original samt
|
||||
Timestamp-Kollisionsschutz im `archive_dir` ab, `delete` verhält sich wie
|
||||
bisher. Die Log-Meldung nennt jetzt beides — wohin das OCR-Ergebnis ging und
|
||||
was mit dem Original passiert ist.
|
||||
|
||||
### Removed
|
||||
- Das nie benutzte Logverzeichnis `/var/log/pdf-ocr-hotfolder/` wird nicht mehr
|
||||
vom Installer angelegt und ist aus README und Briefing entfernt. Es hat nie
|
||||
ein Logfile enthalten: `_setup_logging()` nutzt `logging.basicConfig()` ohne
|
||||
FileHandler, der Dienst loggt nach stdout → journald. **journald ist damit die
|
||||
einzige Log-Quelle** (`journalctl -u pdf-ocr-hotfolder@<instanz> -f`).
|
||||
Weder Installer noch Updater fassen das Verzeichnis an: ein vorhandenes,
|
||||
leeres `/var/log/pdf-ocr-hotfolder/` kann auf bestehenden Installationen
|
||||
gefahrlos von Hand entfernt werden (`sudo rmdir /var/log/pdf-ocr-hotfolder`).
|
||||
|
||||
### Added
|
||||
- 3 neue Tests für den veraPDF-FAIL-Pfad (`delete`, `archive`,
|
||||
Archiv-Namenskollision); veraPDF wird dabei gemockt. Suite jetzt 95 Tests.
|
||||
|
||||
## [0.4.0] - 2026-09-22
|
||||
|
||||
### Added
|
||||
- `[ocr].timeout` ist jetzt wirksam: der Wert wird als `tesseract_timeout`
|
||||
(Sekunden pro Seite) an ocrmypdf durchgereicht. Bisher war der Key zwar
|
||||
dokumentiert, wurde aber nirgends gelesen.
|
||||
- `check_output_config()` validiert zusätzlich `[output].name_mode`. Ein Tippfehler
|
||||
führt jetzt beim Start zum Abbruch mit Exit-Code 2, statt erst pro Datei
|
||||
zuzuschlagen — und zwar bisher **nach** dem Verschieben nach `working/`,
|
||||
wo die Datei dann liegen blieb.
|
||||
- Neue Exception `ConfigError` in `pdf_ocr_hotfolder.config` — fehlende
|
||||
`[paths]`-Sektion oder ein fehlender Pfad-Eintrag liefern eine deutsche
|
||||
Fehlermeldung mit Datei- und Key-Nennung statt eines nackten `KeyError`-Tracebacks.
|
||||
Die CLI bricht damit sauber mit Exit-Code 2 ab.
|
||||
- `pytest.ini` mit `testpaths = tests`, damit `pytest` aus dem Repo-Root läuft.
|
||||
- 35 neue Tests: Fehlerzählung (Exception, Upload, Stabilitäts-Timeout),
|
||||
Config-Fehlermeldungen, `tesseract_timeout`-Durchreichung (ocrmypdf gemockt)
|
||||
und `upload_folder()`.
|
||||
|
||||
### Changed
|
||||
- **`[ocr].timeout` hat eine neue Bedeutung — für bestehende Installationen relevant!**
|
||||
Der Wert ist kein (nie implementiertes) Gesamt-Timeout pro PDF mehr, sondern
|
||||
das Limit **pro Seite** für Tesseract. Der Default sinkt entsprechend von
|
||||
`1800` auf `300`. Wer den alten Wert `1800` in seiner `config.toml` stehen hat,
|
||||
gibt Tesseract damit 30 Minuten **je Seite** — bitte auf einen Seiten-Wert
|
||||
anpassen (Richtwert 300).
|
||||
`0` bedeutet "kein eigenes Limit": der Wert wird dann gar nicht erst
|
||||
durchgereicht, weil ocrmypdf `tesseract_timeout=0` als "OCR komplett
|
||||
überspringen" interpretiert.
|
||||
- `_dispatch_uploads()` liefert jetzt die Namen der fehlgeschlagenen Upload-Ziele
|
||||
zurück; die doppelte `enabled`-Prüfung (Service + Uploader) ist entfallen —
|
||||
die Uploader prüfen das selbst.
|
||||
- `upload_folder()` kopiert mit `shutil.copyfile()` statt
|
||||
`read_bytes()`/`write_bytes()` — große PDFs landen nicht mehr komplett im
|
||||
Speicher. Die Selbst-Ziel-Erkennung bleibt unverändert.
|
||||
|
||||
### Fixed
|
||||
- `OcrConfig.pdfa_level` hatte im Code noch den Default `"2"`, obwohl
|
||||
`config.example.toml` seit 0.2.2 bewusst `""` setzt (Ghostscript-Bug, Issue #3).
|
||||
Eine Config ohne `[ocr]`-Sektion bzw. ohne den Key lief damit ungewollt in
|
||||
PDF/A. Default im Code jetzt ebenfalls `""`.
|
||||
- Eine Exception **nach** dem OCR (z.B. ein fehlgeschlagener
|
||||
`shutil.move()` nach `outgoing/`) wurde nur im Worker-Callback geloggt.
|
||||
`error_count` blieb 0 und `--once` lieferte trotz Fehlschlag Exit-Code 0.
|
||||
Jede Exception aus `process_pdf()` zählt jetzt als Fehler, wird geloggt,
|
||||
löst eine Fehler-Mail aus und die Datei wandert — soweit noch auffindbar
|
||||
(`incoming/` oder `working/`) — nach `error/`.
|
||||
- Fehlgeschlagene Uploads waren folgenlos: die Rückgabewerte der Uploader wurden
|
||||
verworfen, es ging sogar eine Erfolgs-Mail raus. Jetzt zählt mindestens ein
|
||||
fehlgeschlagenes Ziel als Fehler und die E-Mail geht als **FEHLER** raus, mit
|
||||
Nennung der betroffenen Ziele. Das OCR-PDF bleibt bewusst in `outgoing/`
|
||||
liegen (das OCR selbst war ja erfolgreich) — das steht so auch im Log.
|
||||
- Lief der Stabilitäts-Check einer Datei in den 60-Sekunden-Timeout, gab es nur
|
||||
ein `log.warning`; `--once` meldete Exit-Code 0. Jetzt `log.error` +
|
||||
`error_count`. Die Datei bleibt bewusst in `incoming/` liegen und wird beim
|
||||
nächsten Lauf erneut versucht. Eine zwischenzeitlich *verschwundene* Datei
|
||||
wird davon unterschieden und zählt weiterhin nicht als Fehler.
|
||||
|
||||
## [0.3.1] - 2026-04-10
|
||||
|
||||
### Fixed
|
||||
- **Issue #4**: LXC/Container-Kompatibilität — systemd-Hardening (`PrivateTmp`, `ProtectSystem`, etc.)
|
||||
verursacht Error 226/NAMESPACE in LXC-Containern. Installer erkennt Container-Umgebung automatisch
|
||||
und bietet ein Drop-in an. Zusätzlich liegt `systemd/lxc-compat.conf` als Vorlage im Repo.
|
||||
- **Issue #5**: `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der systemd Template-Unit ergänzt —
|
||||
ohne diesen Eintrag konnte das Python-Modul nicht gefunden werden.
|
||||
- **Issue #6**: Auf Debian 12 bietet der Installer bei betroffenen Ghostscript-Versionen (10.0.0–10.02.0)
|
||||
jetzt automatisch an, bookworm-backports zu aktivieren und GS zu upgraden (statt nur zu warnen).
|
||||
|
||||
## [0.3.0] - 2026-04-09
|
||||
|
||||
### Added
|
||||
- Neue Config-Sektion `[output]` mit:
|
||||
- `name_mode` — Platzierung des Tags im Dateinamen: `"prefix"`, `"suffix"` (vor Extension), `"none"`
|
||||
- `name_tag` — verbatim einzufügender String, z.B. `"OCR_"` oder `"_OCR"`
|
||||
- `original_on_success` — `"delete"` (alter Default) oder `"archive"`
|
||||
- `archive_dir` — Zielverzeichnis für `"archive"`, mit Kollisions-Schutz (Timestamp-Suffix)
|
||||
- Runtime-Validierung der Output-Config in `check_output_config()`
|
||||
- 20 neue Tests für `build_output_name()`, `check_output_config()` und `process_pdf()`
|
||||
mit allen Kombinationen aus Modus + Original-Behandlung
|
||||
|
||||
### Changed
|
||||
- `process_pdf()` nimmt jetzt `output_cfg: OutputConfig` als Pflicht-Argument
|
||||
|
||||
## [0.2.2] - 2026-04-09
|
||||
|
||||
### Fixed
|
||||
- **Issue #3**: Ghostscript 10.0.0–10.02.0 (Debian 12 default) zerschießen OCR mit PDF/A + `skip_text=true`.
|
||||
- `config.example.toml`: `pdfa_level = ""` als sicherer Default
|
||||
- Runtime-Preflight: Prüft `gs --version` wenn `pdfa_level` gesetzt ist, bricht mit klarer Fehlermeldung ab
|
||||
- `install.sh`: warnt bei betroffenen GS-Versionen mit Upgrade-Hinweis auf bookworm-backports
|
||||
|
||||
### Added
|
||||
- `is_ghostscript_broken()` / `detect_ghostscript_version()` in `pdf_ocr_hotfolder.service`
|
||||
- 19 weitere pytest-Tests für GS-Versions-Detection (parametrisiert) und Preflight-Kombinationen
|
||||
|
||||
## [0.2.1] - 2026-04-09
|
||||
|
||||
### Fixed
|
||||
- **Issue #1**: Preflight-Check beim Start prüft jetzt `tesseract` und `gs` (Ghostscript). Fehlt eine Abhängigkeit, beendet sich der Service sofort mit Exit-Code 2 und klarer Fehlermeldung statt erst bei der ersten Datei.
|
||||
- **Issue #2**: `--once`-Modus liefert jetzt Exit-Code `1`, sobald **mindestens ein** PDF fehlgeschlagen ist. Exit-Code `0` nur bei vollständigem Erfolg (inkl. "keine Dateien vorhanden"). Exit-Code `2` bei Preflight-Fehler.
|
||||
|
||||
### Added
|
||||
- Public API: `HotfolderService.run_once()`, `.success_count`, `.error_count`, `.ensure_dirs()`
|
||||
- `check_preflight()` / `PreflightError` in `pdf_ocr_hotfolder.service`
|
||||
- pytest-Test-Suite (`tests/`) mit 11 Tests — deckt alle Szenarien aus Issue #1 und #2 ab
|
||||
- `ocrmypdf`-Import in `processor.py` ist jetzt lazy (Tests ohne ocrmypdf-Installation möglich)
|
||||
|
||||
## [0.2.0] - 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,33 +2,43 @@
|
||||
|
||||
Verwandelt eingehende gescannte PDFs automatisch in **durchsuchbare PDFs** (PDF/A optional) per OCR. Hauptanwendung: Kunden-Scanner schiebt PDF in einen Ordner — Sekunden später liegt die OCR-Version im Ausgang oder wird in Nextcloud / per SFTP weitergeleitet.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| **[docs/INSTALLATION.md](docs/INSTALLATION.md)** | Erstinstallation, Instanzen anlegen, LXC, Ghostscript, **Konfigurationsreferenz**, Troubleshooting |
|
||||
| **[docs/UPDATE.md](docs/UPDATE.md)** | Update mit `update.sh`: Ablauf, Backup, Rollback, `--check-config`, Config-Drift |
|
||||
| **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)** | Debian-Major-Upgrade (12 → 13): venv neu bauen, Pins anheben |
|
||||
|
||||
Weiter: [CHANGELOG.md](CHANGELOG.md) · [AI_AGENT_BRIEFING.md](AI_AGENT_BRIEFING.md) · [config.example.toml](config.example.toml)
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead)
|
||||
- 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events
|
||||
- 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat
|
||||
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
|
||||
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
|
||||
- ✅ **PDF/A-Output** (1, 2 oder 3) optional
|
||||
- 🛡️ **veraPDF-Validierung** optional
|
||||
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
|
||||
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
|
||||
- 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind)
|
||||
- ⚙️ Saubere systemd-Integration mit auto-Restart
|
||||
- ⚙️ Saubere systemd-Integration mit auto-Restart, **Multi-Instanz** über eine Template-Unit
|
||||
- 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
|
||||
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
||||
cd pdf-ocr-hotfolder
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
Der Installer:
|
||||
1. Installiert einmalig Code + venv + systemd-Template-Unit
|
||||
2. Fragt nach Instanz-Name, Basis-Pfad, Service-User
|
||||
3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`)
|
||||
|
||||
Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen.
|
||||
Der Installer legt einmalig Code, venv und die systemd-Template-Unit an und
|
||||
fragt danach **pro Instanz** Name, Basis-Pfad, Service-User, OCR-Sprachen und
|
||||
die Original-Behandlung ab. Bei jedem erneuten Aufruf erkennt er bestehende
|
||||
Instanzen und fragt nur nach neuen.
|
||||
|
||||
Test:
|
||||
|
||||
@@ -39,85 +49,56 @@ journalctl -u pdf-ocr-hotfolder@<instanz> -f
|
||||
|
||||
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz.
|
||||
|
||||
## Multi-Instanz-Betrieb
|
||||
Alle Details zu den Abfragen, zum Multi-Instanz-Betrieb und zu den Fallstricken
|
||||
(LXC, Ghostscript): **[docs/INSTALLATION.md](docs/INSTALLATION.md)**.
|
||||
|
||||
Das Tool arbeitet komplett **instanzbasiert** über eine systemd Template-Unit `pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat:
|
||||
|
||||
- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/<name>.toml`
|
||||
- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/`
|
||||
- eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service`
|
||||
- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d/user.conf`)
|
||||
|
||||
Beispiel für 3 Hotfolder:
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
# → legt z.B. kunde-a, kunde-b, buchhaltung an
|
||||
|
||||
systemctl status 'pdf-ocr-hotfolder@*'
|
||||
journalctl -u pdf-ocr-hotfolder@kunde-a -f
|
||||
```
|
||||
|
||||
Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut starten, er fragt wieder nach.
|
||||
Update: `git pull && sudo ./update.sh` — siehe **[docs/UPDATE.md](docs/UPDATE.md)**.
|
||||
Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
|
||||
|
||||
## Verzeichnisse
|
||||
|
||||
| Pfad | Zweck |
|
||||
|------|-------|
|
||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
||||
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz |
|
||||
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (640, root:\<service-gruppe\>) |
|
||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
|
||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
|
||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
|
||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
|
||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs |
|
||||
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
|
||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
|
||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) |
|
||||
|
||||
## Konfiguration
|
||||
Ein eigenes Logverzeichnis gibt es nicht — der Dienst loggt nach stdout und
|
||||
damit ins journal.
|
||||
|
||||
Vollständiges Beispiel: [`config.example.toml`](config.example.toml). Wichtigste Sektionen:
|
||||
## Konfiguration im Überblick
|
||||
|
||||
### `[ocr]`
|
||||
```toml
|
||||
languages = "deu+eng" # Tesseract-Sprachen
|
||||
jobs = 4 # Threads pro PDF
|
||||
skip_text = true # bereits OCR-haltige Seiten überspringen
|
||||
pdfa_level = "2" # "1", "2", "3" oder "" für reines PDF
|
||||
deskew = true
|
||||
max_workers = 2 # parallele PDFs
|
||||
timeout = 1800
|
||||
Jede Instanz hat ihre eigene TOML unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
|
||||
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](config.example.toml).
|
||||
Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
|
||||
|
||||
| Sektion | Zweck |
|
||||
|---------|-------|
|
||||
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** |
|
||||
| `[ocr]` | Sprachen, `jobs`, `skip_text`, `pdfa_level`, `deskew`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
|
||||
| `[output]` | Dateibenennung (`name_mode`/`name_tag`) und Original-Behandlung (`delete`/`archive`) |
|
||||
| `[verapdf]` | optionale PDF/A-Validierung per CLI |
|
||||
| `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` | Upload-Ziele, beliebig viele gleichzeitig |
|
||||
| `[notify.email]` | SMTP-Benachrichtigung: `always` \| `errors` \| `never` |
|
||||
| `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR |
|
||||
|
||||
Die Instanz-Configs enthalten **Klartext-Passwörter** (SMTP, Nextcloud, SFTP) —
|
||||
deshalb `640 root:<service-gruppe>` und beim Debuggen nicht in Tickets kopieren.
|
||||
|
||||
Config prüfen, ohne etwas zu verarbeiten:
|
||||
|
||||
```bash
|
||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||
```
|
||||
|
||||
### `[upload.nextcloud]`
|
||||
```toml
|
||||
enabled = true
|
||||
url = "https://cloud.example.com"
|
||||
username = "scanuser"
|
||||
password = "app-password"
|
||||
remote_path = "Scans/Inbox"
|
||||
```
|
||||
|
||||
### `[upload.sftp]`
|
||||
```toml
|
||||
enabled = true
|
||||
host = "sftp.example.com"
|
||||
username = "scanuser"
|
||||
key_file = "/etc/pdf-ocr-hotfolder/sftp_key"
|
||||
remote_path = "/uploads"
|
||||
```
|
||||
|
||||
### `[notify.email]`
|
||||
```toml
|
||||
enabled = true
|
||||
smtp_host = "smtp.example.com"
|
||||
smtp_port = 587
|
||||
smtp_user = "alerts@example.com"
|
||||
smtp_password = "secret"
|
||||
from_addr = "PDF OCR <alerts@example.com>"
|
||||
to_addrs = ["admin@example.com"]
|
||||
on = "errors" # always | errors | never
|
||||
```
|
||||
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
|
||||
[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
|
||||
|
||||
## Service-Verwaltung
|
||||
|
||||
@@ -130,52 +111,11 @@ journalctl -u pdf-ocr-hotfolder@kunde-a -f
|
||||
# Alle Instanzen
|
||||
sudo systemctl status 'pdf-ocr-hotfolder@*'
|
||||
sudo systemctl restart 'pdf-ocr-hotfolder@*'
|
||||
journalctl -u 'pdf-ocr-hotfolder@*' --since today
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
cd /pfad/zum/repo
|
||||
git pull
|
||||
sudo ./update.sh
|
||||
```
|
||||
|
||||
`update.sh`:
|
||||
1. Stoppt alle laufenden Instanzen
|
||||
2. Sichert den alten Code nach `/var/backups/pdf-ocr-hotfolder/`
|
||||
3. Aktualisiert Code + venv + systemd-Template-Unit in `/opt/pdf-ocr-hotfolder/`
|
||||
4. Startet alle zuvor laufenden Instanzen neu
|
||||
|
||||
Config-Dateien unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben.
|
||||
Das Repo muss bestehen bleiben — `update.sh` kopiert daraus.
|
||||
|
||||
## Manueller Lauf (One-Shot)
|
||||
|
||||
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden:
|
||||
|
||||
```bash
|
||||
sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tesseract findet die Sprache nicht
|
||||
```bash
|
||||
sudo apt install tesseract-ocr-deu tesseract-ocr-eng
|
||||
```
|
||||
|
||||
### "PriorOcrFoundError"
|
||||
ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config setzen.
|
||||
|
||||
### Berechtigungsprobleme bei AD-User
|
||||
Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-hotfolder/`. Bei AD-User mit lokaler UID:
|
||||
```bash
|
||||
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder
|
||||
```
|
||||
|
||||
### veraPDF-Validierung schlägt immer fehl
|
||||
veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`.
|
||||
Ein laufendes OCR darf beim Stoppen zu Ende laufen (`TimeoutStopSec=300`) — ein
|
||||
`stop` kann deshalb pro Instanz bis zu 5 Minuten dauern.
|
||||
|
||||
## Architektur
|
||||
|
||||
@@ -199,11 +139,24 @@ veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `ena
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
Beim Start wird `working/` zuerst durchsucht: was ein harter Stopp dort liegen
|
||||
ließ, wird wiederaufgenommen; unvollständige OCR-Fragmente (`__ocr_*`) werden
|
||||
gelöscht.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest # 135 Tests
|
||||
```
|
||||
|
||||
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
||||
den Tests gemockt.
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT — © Sonith UG
|
||||
|
||||
---
|
||||
|
||||
**Version:** 0.2.0
|
||||
**Version:** 0.6.0
|
||||
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||
|
||||
+28
-4
@@ -1,5 +1,5 @@
|
||||
# PDF OCR Hotfolder — Konfiguration
|
||||
# Speichern als /etc/pdf-ocr-hotfolder/config.toml
|
||||
# Vorlage — install.sh erzeugt daraus pro Instanz /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||
|
||||
[paths]
|
||||
# Eingangsverzeichnis: hier landen gescannte PDFs
|
||||
@@ -21,15 +21,39 @@ skip_text = true
|
||||
# Auflösung für gerasterte Seiten
|
||||
oversample = 300
|
||||
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
|
||||
pdfa_level = "2"
|
||||
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug,
|
||||
# der mit pdfa_level + skip_text=true ocrmypdf komplett blockiert.
|
||||
# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
|
||||
pdfa_level = ""
|
||||
# Schiefe Scans automatisch begradigen
|
||||
deskew = true
|
||||
# Hintergrund säubern
|
||||
clean = false
|
||||
# Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig)
|
||||
max_workers = 2
|
||||
# Timeout pro PDF in Sekunden
|
||||
timeout = 1800
|
||||
# Max. Sekunden, die Tesseract pro SEITE laufen darf (0 = kein Limit).
|
||||
# ocrmypdf kennt kein Gesamt-Timeout pro Dokument, nur dieses Seiten-Limit
|
||||
# (ocrmypdf-Option --tesseract-timeout). Läuft eine Seite in den Timeout,
|
||||
# wird sie ohne Textebene ins Ergebnis übernommen; die Verarbeitung der
|
||||
# restlichen Seiten läuft weiter.
|
||||
# 0 = wir geben kein Limit vor und überlassen es dem ocrmypdf-Default.
|
||||
timeout = 300
|
||||
|
||||
[output]
|
||||
# Wie soll die Ziel-Datei im outgoing/-Ordner benannt werden?
|
||||
# "prefix" : name_tag wird vor den Dateinamen gestellt (OCR_scan.pdf)
|
||||
# "suffix" : name_tag wird vor die Extension gestellt (scan_OCR.pdf)
|
||||
# "none" : Dateiname bleibt wie das Original
|
||||
name_mode = "prefix"
|
||||
# Verbatim einzufügender String. Leerer String = kein Tag (wie mode="none").
|
||||
# Beispiele: "OCR_", "[OCR]_", "_OCR", "_searchable"
|
||||
name_tag = "OCR_"
|
||||
# Was passiert mit dem Original, wenn OCR erfolgreich war?
|
||||
# "delete" : Original wird gelöscht (alter Standard)
|
||||
# "archive" : Original wird in archive_dir verschoben
|
||||
original_on_success = "delete"
|
||||
# Absoluter Pfad; nur relevant wenn original_on_success = "archive"
|
||||
archive_dir = ""
|
||||
|
||||
[verapdf]
|
||||
# PDF/A-Validierung (optional)
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
# Installation
|
||||
|
||||
Erstinstallation und Anlage von Hotfolder-Instanzen mit `install.sh`.
|
||||
|
||||
Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Major-Upgrade](OS-UPGRADE.md)
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Punkt | Anforderung |
|
||||
|-------|-------------|
|
||||
| Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt |
|
||||
| Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution |
|
||||
| Rechte | `root` (`sudo ./install.sh`) |
|
||||
| Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv |
|
||||
| Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) |
|
||||
|
||||
Die System-Pakete installiert der Installer selbst. Die Liste steht als
|
||||
einzige Quelle in `install.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen den
|
||||
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`) und wird von
|
||||
`update.sh` von dort ausgelesen:
|
||||
|
||||
```
|
||||
python3 python3-venv python3-pip
|
||||
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
|
||||
ghostscript qpdf unpaper pngquant icc-profiles-free
|
||||
ca-certificates curl
|
||||
```
|
||||
|
||||
Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz
|
||||
nach (siehe [OCR-Sprachen](#3-ocr-sprachen)).
|
||||
|
||||
Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt`
|
||||
(ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins
|
||||
anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
||||
cd pdf-ocr-hotfolder
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent —
|
||||
jeder weitere Aufruf überspringt, was schon steht.
|
||||
|
||||
### Basis-Install vs. Instanz-Anlage
|
||||
|
||||
Der Installer unterscheidet zwei Ebenen:
|
||||
|
||||
| Ebene | Wann | Was passiert |
|
||||
|-------|------|--------------|
|
||||
| **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung, Default-User `pdfocr`, Code nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit |
|
||||
| **Instanz-Anlage** | bei jedem Lauf, beliebig oft | Abfragen pro Instanz, Datenverzeichnisse, `<instanz>.toml`, optionales User-Drop-in, `enable --now` |
|
||||
|
||||
Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum
|
||||
System-Python (typisch nach einem Distributions-Upgrade), läuft der Basis-Install
|
||||
**zur Reparatur erneut**: die alte venv wird nach `venv.old-<timestamp>`
|
||||
weggesichert und neu gebaut. Für den geplanten Weg über ein Debian-Major-Upgrade
|
||||
ist aber `update.sh --rebuild-venv` gedacht, siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
||||
|
||||
Beim Erstlauf ist mindestens **eine** Instanz Pflicht. Danach fragt der
|
||||
Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`.
|
||||
|
||||
---
|
||||
|
||||
## Die Abfragen pro Instanz
|
||||
|
||||
`create_instance()` stellt fünf Fragen. Alle Antworten gelten **nur für diese
|
||||
Instanz** — nichts davon ist global.
|
||||
|
||||
### 1. Instanz-Name
|
||||
|
||||
```
|
||||
Instanz-Name (nur a-z, 0-9, -):
|
||||
```
|
||||
|
||||
Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix
|
||||
(`pdf-ocr-hotfolder@<name>.service`) und zum Config-Dateinamen
|
||||
(`/etc/pdf-ocr-hotfolder/<name>.toml`). Existiert die Config schon, bricht die
|
||||
Anlage ab — ein versehentliches Überschreiben gibt es nicht.
|
||||
|
||||
### 2. Basis-Pfad für die Daten
|
||||
|
||||
```
|
||||
Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/<name>]:
|
||||
```
|
||||
|
||||
Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf
|
||||
den Service-User gechownt.
|
||||
|
||||
### 3. Service-User
|
||||
|
||||
```
|
||||
Service-User [pdfocr]:
|
||||
```
|
||||
|
||||
- **Existiert der User** (lokal oder als **AD-User via SSSD/Winbind**), wird er
|
||||
übernommen; die primäre Gruppe ermittelt der Installer per `id -gn`.
|
||||
- **Existiert er nicht**, bietet der Installer an, ihn lokal als System-User
|
||||
anzulegen. Wird das abgelehnt, bricht die Instanz-Anlage ab — der User muss
|
||||
dann erst über AD/SSSD bereitstehen.
|
||||
- Ist der gewählte User **nicht** `pdfocr`, legt der Installer das Drop-in
|
||||
`/etc/systemd/system/pdf-ocr-hotfolder@<instanz>.service.d/user.conf` mit
|
||||
`User=`/`Group=` an.
|
||||
|
||||
Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID
|
||||
gesetzt — das läuft transparent.
|
||||
|
||||
### 4. OCR-Sprachen
|
||||
|
||||
```
|
||||
Tesseract-Sprachen [deu+eng]:
|
||||
```
|
||||
|
||||
Die Sprachen werden bewusst **je Instanz** abgefragt: ein Hotfolder
|
||||
`buchhaltung` sieht nur deutsche Belege, ein Hotfolder `export`
|
||||
internationale Korrespondenz.
|
||||
|
||||
```toml
|
||||
# /etc/pdf-ocr-hotfolder/buchhaltung.toml
|
||||
languages = "deu"
|
||||
|
||||
# /etc/pdf-ocr-hotfolder/export.toml
|
||||
languages = "deu+eng+fra"
|
||||
```
|
||||
|
||||
**Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet
|
||||
Laufzeit *und* Erkennungsqualität: Tesseract wägt mehr Modelle gegeneinander ab
|
||||
und verwechselt dabei Wörter, die in einer Sprache eindeutig wären.
|
||||
`deu+eng+fra` auf reinen Deutsch-Scans ist kein Sicherheitsnetz, sondern ein
|
||||
Rückschritt.
|
||||
|
||||
Was der Installer damit macht:
|
||||
|
||||
1. **Format prüfen** — Sprachcodes mit `+` verbunden
|
||||
(`^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`), also `deu`, `deu+eng`, `chi_sim+eng`.
|
||||
Bei Unsinn wird erneut gefragt, nicht abgebrochen.
|
||||
2. **Jeden Code gegen `tesseract --list-langs` prüfen.** Fehlt eine
|
||||
Sprachdatei, bietet er das passende apt-Paket an: `tesseract-ocr-<code>`,
|
||||
Unterstrich wird zum Bindestrich (`chi_sim` → `tesseract-ocr-chi-sim`).
|
||||
3. **Lehnt man ab oder scheitert die Installation**, warnt er, dass OCR mit
|
||||
dieser Sprache **bei jeder Datei** scheitern würde, und fragt die Sprachen
|
||||
erneut ab — die fehlende Sprache kann man dann einfach weglassen.
|
||||
4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die
|
||||
Eingabe unverändert übernommen.
|
||||
|
||||
### 5. Original archivieren?
|
||||
|
||||
```
|
||||
Original nach erfolgreichem OCR archivieren? [j/N]:
|
||||
```
|
||||
|
||||
- **Nein** (Default) → `original_on_success = "delete"`, das Original wird nach
|
||||
erfolgreichem OCR gelöscht.
|
||||
- **Ja** → `original_on_success = "archive"`, danach:
|
||||
|
||||
```
|
||||
Archiv-Verzeichnis [<basis>/archive]:
|
||||
```
|
||||
|
||||
Der Pfad muss **absolut** sein und darf **nicht** `incoming/`, `outgoing/`,
|
||||
`working/` oder `error/` der Instanz sein — im Eingang würde das Original sonst
|
||||
endlos neu aufgegriffen, in den übrigen kollidiert es mit der Verarbeitung. Das
|
||||
Verzeichnis wird angelegt und auf den Service-User gechownt; innerhalb des
|
||||
Basis-Pfads erledigt das bestehende `chown -R` das mit, ein Archiv **außerhalb**
|
||||
bekommt ein eigenes.
|
||||
|
||||
### Was danach passiert
|
||||
|
||||
Die Instanz-Config entsteht per `sed` aus `config.example.toml`. Substituiert
|
||||
werden die vier `[paths]`-Zeilen sowie `[ocr].languages`,
|
||||
`[output].original_on_success` und `[output].archive_dir`. Anschließend liest
|
||||
der Installer diese drei Keys aus der erzeugten Datei zurück und vergleicht sie
|
||||
mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und
|
||||
Archiv-Verzeichnis. Sonst gibt es eine Warnung mit der Bitte, die Config von
|
||||
Hand nachzuziehen.
|
||||
|
||||
Die Config bekommt `chmod 640` und `chown root:<service-gruppe>`, das
|
||||
Verzeichnis `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr` — in den
|
||||
Instanz-Configs stehen **Klartext-Passwörter** für SMTP, Nextcloud und SFTP.
|
||||
|
||||
Zum Schluss: `daemon-reload` und `systemctl enable --now
|
||||
pdf-ocr-hotfolder@<instanz>.service`.
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
|
||||
journalctl -u pdf-ocr-hotfolder@<instanz> -f
|
||||
```
|
||||
|
||||
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/` der Instanz.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Instanz-Betrieb
|
||||
|
||||
Das Tool arbeitet komplett instanzbasiert über die systemd-Template-Unit
|
||||
`pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat eigene Config, eigene
|
||||
Datenverzeichnisse, eigene Unit, optional eigenen Service-User — und eigene
|
||||
OCR-Sprachen und Original-Behandlung.
|
||||
|
||||
```bash
|
||||
sudo ./install.sh # legt z.B. kunde-a, kunde-b, buchhaltung an
|
||||
|
||||
systemctl status 'pdf-ocr-hotfolder@*'
|
||||
journalctl -u pdf-ocr-hotfolder@kunde-a -f
|
||||
```
|
||||
|
||||
Der Code unter `/opt/pdf-ocr-hotfolder/` (inkl. venv) ist für **alle** Instanzen
|
||||
gemeinsam. Ein Update trifft damit immer alle Instanzen auf einmal — siehe
|
||||
[UPDATE.md](UPDATE.md).
|
||||
|
||||
Das vollständige Verzeichnis-Layout steht im
|
||||
[README](../README.md#verzeichnisse).
|
||||
|
||||
---
|
||||
|
||||
## LXC/Container: Error 226/NAMESPACE
|
||||
|
||||
In LXC-Containern schlagen die systemd-Hardening-Optionen der Unit
|
||||
(`PrivateTmp`, `ProtectSystem`, `ProtectKernelTunables`, …) fehl; systemd
|
||||
quittiert das mit `Error 226/NAMESPACE`.
|
||||
|
||||
Der Installer erkennt Container über `systemd-detect-virt --container` und
|
||||
bietet das Drop-in automatisch an. Manuell:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/
|
||||
sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \
|
||||
/etc/systemd/system/pdf-ocr-hotfolder@.service.d/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart 'pdf-ocr-hotfolder@*'
|
||||
```
|
||||
|
||||
Das Drop-in setzt alle betroffenen Hardening-Schalter auf `false`. Es liegt auf
|
||||
Template-Ebene (`pdf-ocr-hotfolder@.service.d/`) und gilt damit für alle
|
||||
Instanzen. Ist es installiert, zieht `update.sh` es bei jedem Update aus dem
|
||||
Repo nach, damit ein neu ergänzter Hardening-Schalter nicht alle
|
||||
Container-Instanzen reißt.
|
||||
|
||||
---
|
||||
|
||||
## Ghostscript-Bug auf Debian 12
|
||||
|
||||
Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** —
|
||||
zerschießt OCR in der Kombination `[ocr].pdfa_level` + `skip_text = true`:
|
||||
ocrmypdf blockiert komplett.
|
||||
|
||||
Deshalb:
|
||||
|
||||
- `pdfa_level = ""` ist der sichere Default (kein PDF/A-Output).
|
||||
- Der Preflight beim Dienststart bricht mit **Exit 2** ab, wenn `pdfa_level`
|
||||
gesetzt **und** die installierte Ghostscript-Version betroffen ist.
|
||||
- `--check-config` meldet ein gesetztes `pdfa_level` als Warnung (siehe
|
||||
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
|
||||
|
||||
Der Installer erkennt betroffene Versionen und bietet auf Debian 12
|
||||
bookworm-backports an. Manuell:
|
||||
|
||||
```bash
|
||||
echo 'deb http://deb.debian.org/debian bookworm-backports main' | \
|
||||
sudo tee /etc/apt/sources.list.d/bookworm-backports.list
|
||||
sudo apt update && sudo apt install -t bookworm-backports ghostscript
|
||||
```
|
||||
|
||||
Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet
|
||||
werden.
|
||||
|
||||
---
|
||||
|
||||
## Instanz manuell löschen
|
||||
|
||||
Der Installer legt Instanzen an, löscht aber keine. Von Hand:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now pdf-ocr-hotfolder@<name>
|
||||
sudo rm /etc/pdf-ocr-hotfolder/<name>.toml
|
||||
sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
|
||||
sudo systemctl daemon-reload
|
||||
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
|
||||
```
|
||||
|
||||
Das Datenverzeichnis bleibt **bewusst** liegen: dort können noch unverarbeitete
|
||||
PDFs in `incoming/`, Fehlerfälle in `error/` oder Originale im Archiv liegen.
|
||||
Erst hineinsehen, dann löschen.
|
||||
|
||||
Solange die Config unter `/etc/pdf-ocr-hotfolder/` liegt, zählt `update.sh` die
|
||||
Instanz weiter mit — auch wenn sie gestoppt ist.
|
||||
|
||||
---
|
||||
|
||||
## Konfigurationsreferenz
|
||||
|
||||
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](../config.example.toml).
|
||||
Jede Instanz hat ihre eigene Kopie unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
|
||||
|
||||
Unbekannte Keys werden beim Laden ignoriert, aber **gemeldet** — beim
|
||||
Dienststart im Log und von `--check-config`. Ein Tippfehler wie
|
||||
`[ocr].langauges` fällt damit auf.
|
||||
|
||||
### `[paths]` — Pflicht
|
||||
|
||||
| Key | Bedeutung |
|
||||
|-----|-----------|
|
||||
| `incoming` | Eingang, hier schreibt der Scanner hinein |
|
||||
| `outgoing` | Ausgang, fertige OCR-PDFs |
|
||||
| `working` | Arbeitsverzeichnis während der Verarbeitung |
|
||||
| `error` | fehlgeschlagene PDFs |
|
||||
|
||||
Fehlt die Sektion oder einer der vier Einträge, gibt es eine deutsche
|
||||
`ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst
|
||||
startet nicht.
|
||||
|
||||
### `[ocr]`
|
||||
|
||||
| Key | Default | Bedeutung |
|
||||
|-----|---------|-----------|
|
||||
| `languages` | `"deu+eng"` | Tesseract-Sprachen; der Installer fragt sie pro Instanz ab |
|
||||
| `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt |
|
||||
| `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen |
|
||||
| `oversample` | `300` | Auflösung für gerasterte Seiten |
|
||||
| `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12) |
|
||||
| `deskew` | `true` | schiefe Scans begradigen |
|
||||
| `clean` | `false` | Hintergrund säubern (unpaper) |
|
||||
| `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden |
|
||||
| `timeout` | `300` | max. Sekunden, die Tesseract **pro Seite** laufen darf; `0` = kein eigenes Limit |
|
||||
|
||||
**`timeout` ist ein Seiten-Timeout, kein Gesamt-Timeout.** Der Wert geht als
|
||||
`tesseract_timeout` an ocrmypdf; ein Dokument-Timeout kennt ocrmypdf nicht. Wer
|
||||
noch den alten Default `1800` aus einer Config vor 0.4.0 stehen hat, gibt
|
||||
Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Läuft eine Seite in den
|
||||
Timeout, landet sie ohne Textebene im Ergebnis, die übrigen Seiten laufen
|
||||
weiter. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu
|
||||
überspringen**, deshalb wird `0` (oder negativ) gar nicht erst übergeben und der
|
||||
ocrmypdf-Default greift. Siehe auch
|
||||
[Config-Drift](UPDATE.md#config-drift-nach-einem-update).
|
||||
|
||||
### `[output]`
|
||||
|
||||
| Key | Default | Bedeutung |
|
||||
|-----|---------|-----------|
|
||||
| `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert |
|
||||
| `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` |
|
||||
| `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab |
|
||||
| `archive_dir` | `""` | absoluter Pfad, **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix |
|
||||
|
||||
Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum
|
||||
Abbruch mit **Exit 2**, nicht erst bei der ersten Datei.
|
||||
|
||||
### `[verapdf]`
|
||||
|
||||
| Key | Default | Bedeutung |
|
||||
|-----|---------|-----------|
|
||||
| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI |
|
||||
| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary |
|
||||
| `flavour` | `"1b"` | PDF/A-Flavour |
|
||||
|
||||
veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die
|
||||
Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach
|
||||
`error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also
|
||||
erhalten).
|
||||
|
||||
### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]`
|
||||
|
||||
Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige
|
||||
PDF einfach in `outgoing/` liegen.
|
||||
|
||||
| Sektion | Keys |
|
||||
|---------|------|
|
||||
| `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op |
|
||||
| `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` |
|
||||
| `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` |
|
||||
|
||||
Schlägt mindestens ein Ziel fehl, zählt das als Fehler und die Mail geht als
|
||||
FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR
|
||||
selbst war ja erfolgreich.
|
||||
|
||||
### `[notify.email]`
|
||||
|
||||
| Key | Default | Bedeutung |
|
||||
|-----|---------|-----------|
|
||||
| `enabled` | `false` | E-Mail-Benachrichtigung an/aus |
|
||||
| `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `use_starttls` | — | SMTP-Zugang |
|
||||
| `from_addr` | — | Absender |
|
||||
| `to_addrs` | `[]` | Empfängerliste |
|
||||
| `on` | `"errors"` | `always` \| `errors` \| `never` |
|
||||
|
||||
### `[logging]`
|
||||
|
||||
| Key | Default | Bedeutung |
|
||||
|-----|---------|-----------|
|
||||
| `level` | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` |
|
||||
|
||||
Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit
|
||||
ins journal.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tesseract findet die Sprache nicht
|
||||
|
||||
```bash
|
||||
sudo apt install tesseract-ocr-deu tesseract-ocr-eng
|
||||
```
|
||||
|
||||
Danach `[ocr].languages` prüfen. Der Installer nimmt einem das beim Anlegen
|
||||
einer Instanz ab, ein nachträglich in die Config geschriebener Sprachcode wird
|
||||
aber nicht geprüft — `--check-config` zeigt die eingestellten Sprachen an.
|
||||
|
||||
### "PriorOcrFoundError"
|
||||
|
||||
ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config
|
||||
setzen (Default).
|
||||
|
||||
### Berechtigungsprobleme bei AD-User
|
||||
|
||||
Der Service-User braucht **rw** auf alle vier Verzeichnisse der Instanz (und auf
|
||||
das Archiv, falls konfiguriert):
|
||||
|
||||
```bash
|
||||
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/<instanz>
|
||||
```
|
||||
|
||||
### veraPDF-Validierung schlägt immer fehl
|
||||
|
||||
`[verapdf].binary` prüfen. Wenn die Validierung nicht zwingend gebraucht wird:
|
||||
`enabled = false`.
|
||||
|
||||
### Dienst startet nicht (Exit 2)
|
||||
|
||||
Exit 2 heißt immer: Config oder Preflight. Die Ursache steht im journal und
|
||||
ausführlicher in:
|
||||
|
||||
```bash
|
||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||
```
|
||||
|
||||
### Dienst startet nicht (203/EXEC)
|
||||
|
||||
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
||||
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
||||
|
||||
---
|
||||
|
||||
## Manueller Lauf (One-Shot)
|
||||
|
||||
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch
|
||||
Dateien auf, die in `working/` liegen geblieben sind:
|
||||
|
||||
```bash
|
||||
sudo -u pdfocr /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
|
||||
```
|
||||
|
||||
Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine
|
||||
Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Debian-Major-Upgrade
|
||||
|
||||
Wie der Hotfolder ein Distributions-Upgrade übersteht (12 → 13, später 13 → 14).
|
||||
|
||||
Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Update](UPDATE.md)
|
||||
|
||||
---
|
||||
|
||||
## Warum das ein eigener Ablauf ist
|
||||
|
||||
Die venv unter `/opt/pdf-ocr-hotfolder/venv/` hängt an der **Python-Version der
|
||||
Distribution**. Ein `apt full-upgrade` von Debian 12 auf 13 tauscht Python 3.11
|
||||
gegen 3.13 aus. Danach zeigt `venv/bin/python` auf einen Interpreter, den es so
|
||||
nicht mehr gibt — systemd quittiert den Start jeder Instanz mit `203/EXEC`, und
|
||||
auch wenn der Interpreter noch existiert, passen die installierten Pakete nicht
|
||||
mehr zum System-Python.
|
||||
|
||||
**Die venv muss nach dem Sprung neu gebaut werden.** Ein normales
|
||||
`sudo ./update.sh` genügt dafür nicht sicher genug — es gibt den ausdrücklichen
|
||||
Schalter `--rebuild-venv`.
|
||||
|
||||
---
|
||||
|
||||
## Der Ablauf
|
||||
|
||||
### 1. Vorher updaten
|
||||
|
||||
```bash
|
||||
cd /pfad/zum/repo
|
||||
git pull
|
||||
sudo ./update.sh
|
||||
```
|
||||
|
||||
Das bringt die Installation auf den aktuellen Stand und erzeugt vor allem ein
|
||||
**frisches Backup** inklusive `pip-freeze.txt` — die Liste der Paketversionen,
|
||||
die auf dem alten System liefen. Inhalt und Ort des Backups:
|
||||
[UPDATE.md](UPDATE.md#backup).
|
||||
|
||||
### 2. Instanzen stoppen
|
||||
|
||||
```bash
|
||||
sudo systemctl stop 'pdf-ocr-hotfolder@*'
|
||||
```
|
||||
|
||||
Während des Upgrades darf kein OCR laufen: Ghostscript, Tesseract und die
|
||||
Python-Pakete werden mitten im Betrieb ausgetauscht.
|
||||
|
||||
> **Geduld.** Die Unit hat `TimeoutStopSec=300`, damit ein laufendes OCR sauber
|
||||
> zu Ende kommt. **Ein Stop kann pro Instanz bis zu 5 Minuten dauern** — bei
|
||||
> mehreren Instanzen entsprechend länger. Nicht mit `kill -9` nachhelfen; ein
|
||||
> harter Stopp lässt das Original in `working/` liegen (der Dienst nimmt es beim
|
||||
> nächsten Start zwar wieder auf, aber der Durchlauf ist verloren).
|
||||
|
||||
Prüfen, dass wirklich alles steht:
|
||||
|
||||
```bash
|
||||
systemctl status 'pdf-ocr-hotfolder@*'
|
||||
```
|
||||
|
||||
### 3. Distribution upgraden
|
||||
|
||||
Der übliche Debian-Weg — Sources auf das neue Release umstellen, dann:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt full-upgrade
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Die Instanzen sind `enabled` und starten nach dem Reboot mit; mit der alten venv
|
||||
scheitern sie (`203/EXEC`). Das ist erwartet und wird im nächsten Schritt
|
||||
behoben.
|
||||
|
||||
### 4. venv neu bauen
|
||||
|
||||
```bash
|
||||
cd /pfad/zum/repo
|
||||
git pull
|
||||
sudo ./update.sh --rebuild-venv
|
||||
```
|
||||
|
||||
`--rebuild-venv` erzwingt den Neubau. Der Rest des Updates läuft wie gewohnt
|
||||
(siehe [UPDATE.md](UPDATE.md#was-das-skript-tut--in-dieser-reihenfolge)) — die
|
||||
System-Pakete werden dabei ebenfalls abgeglichen, auch `python3-venv` für das
|
||||
neue Python.
|
||||
|
||||
Der Neubau ist **ganz oder gar nicht**:
|
||||
|
||||
1. Die alte venv wird nach `venv.old-<timestamp>` verschoben.
|
||||
2. `python3 -m venv` baut neu.
|
||||
3. Die Requirements werden installiert.
|
||||
4. **Erst bei Erfolg** wird die alte venv gelöscht.
|
||||
|
||||
### 5. Wenn ein Pin nicht mehr passt
|
||||
|
||||
Scheitert `pip install` an einer gepinnten Version, bricht das Skript ab und
|
||||
sagt genau, was los ist:
|
||||
|
||||
- die letzten 25 Zeilen der pip-Ausgabe,
|
||||
- das **gescheiterte Paket** ("Gescheitertes Paket: …"),
|
||||
- die Diagnose: "eine in requirements.txt fest gepinnte Version gibt es für
|
||||
Python \<version\> nicht (mehr)",
|
||||
- den nächsten Schritt: **requirements.txt anheben** und
|
||||
`update.sh --rebuild-venv` erneut laufen lassen.
|
||||
|
||||
**Die alte venv ist dann zurückgerollt** — es liegt keine halb gefüllte venv
|
||||
herum. Sie hängt zwar weiterhin am alten Interpreter und die Instanzen laufen
|
||||
damit nicht (das sagt das Skript auch), aber der Zustand ist eindeutig.
|
||||
|
||||
Also:
|
||||
|
||||
```bash
|
||||
# im Repo, auf einer Testmaschine
|
||||
vim requirements.txt # Version des genannten Pakets anheben
|
||||
pytest # Suite muss grün bleiben
|
||||
git commit -am 'requirements: <paket> auf <version> anheben'
|
||||
git push
|
||||
|
||||
# auf dem Zielsystem
|
||||
git pull
|
||||
sudo ./update.sh --rebuild-venv
|
||||
```
|
||||
|
||||
### 6. `--rebuild-venv` vergessen?
|
||||
|
||||
Halb so wild: `update.sh` prüft die venv auch ohne den Schalter und baut sie bei
|
||||
Versions-Drift von selbst neu. Geprüft wird
|
||||
|
||||
- ob das Verzeichnis und `venv/bin/python` überhaupt existieren,
|
||||
- ob der Interpreter der venv noch **läuft** (toter Symlink nach dem Upgrade),
|
||||
- ob seine `major.minor` zum System-`python3` passt,
|
||||
- ob `pyvenv.cfg` dieselbe Version nennt wie der Interpreter.
|
||||
|
||||
Stimmt eines davon nicht, nennt das Skript den Grund und baut neu.
|
||||
`--rebuild-venv` ist also nicht der einzige, aber der **ausdrückliche** Weg —
|
||||
und der, den man nach einem Distributions-Upgrade nimmt, statt sich auf die
|
||||
Erkennung zu verlassen.
|
||||
|
||||
---
|
||||
|
||||
## Danach prüfen
|
||||
|
||||
**Laufen alle Instanzen?**
|
||||
|
||||
```bash
|
||||
systemctl status 'pdf-ocr-hotfolder@*'
|
||||
```
|
||||
|
||||
`update.sh` hat das schon verifiziert (Wartezeit, `is-failed`, Crash-Loop) und
|
||||
in der Zusammenfassung Soll gegen Ist gestellt — ein Exit 0 heißt, dass jede
|
||||
Instanz, die vorher lief, auch wieder läuft.
|
||||
|
||||
**Sind die Configs sauber?**
|
||||
|
||||
```bash
|
||||
for f in /etc/pdf-ocr-hotfolder/*.toml; do
|
||||
sudo /opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--check-config --config "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Exit 0/1/2 und was bei Warnungen zu tun ist:
|
||||
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config).
|
||||
|
||||
**Läuft eine echte PDF durch?**
|
||||
|
||||
```bash
|
||||
cp test.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
|
||||
journalctl -u pdf-ocr-hotfolder@<instanz> -f
|
||||
```
|
||||
|
||||
Im `outgoing/` muss das OCR-PDF liegen, im Journal steht `OCR done`. Das ist der
|
||||
einzige Test, der die neue venv **und** die neuen System-Binaries (Tesseract,
|
||||
Ghostscript) wirklich anfasst — `systemctl status` sagt darüber nichts.
|
||||
|
||||
**Ghostscript-Version auf dem neuen Release ansehen:**
|
||||
|
||||
```bash
|
||||
gs --version
|
||||
```
|
||||
|
||||
Auf Debian 13 ist der Ghostscript-Bug aus Debian 12 kein Thema mehr; ein
|
||||
bookworm-backports-Eintrag unter `/etc/apt/sources.list.d/` gehört nach dem
|
||||
Upgrade entfernt. Hintergrund:
|
||||
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||
|
||||
---
|
||||
|
||||
## Pins in `requirements.txt`
|
||||
|
||||
Die Python-Abhängigkeiten sind **bewusst fest gepinnt**:
|
||||
|
||||
```
|
||||
ocrmypdf==16.13.0
|
||||
watchdog==6.0.0
|
||||
requests==2.33.1
|
||||
paramiko==4.0.0
|
||||
```
|
||||
|
||||
Ohne Pins würde ein `pip install --upgrade` bei jedem Update ungefragt eine neue
|
||||
Major-Version ziehen — ein Sprung von ocrmypdf **16 auf 17** reißt sonst alle
|
||||
Instanzen auf einmal, und zwar im Moment des Updates, nicht zu einem Zeitpunkt,
|
||||
den man sich ausgesucht hat.
|
||||
|
||||
Die aktuellen Pins sind gegen Python 3.11 (Debian 12) und 3.13 (Debian 13)
|
||||
geprüft; für beide gibt es fertige Wheels, es wird nichts kompiliert.
|
||||
|
||||
**Beim Anheben:**
|
||||
|
||||
1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder.
|
||||
2. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
|
||||
aufgelöst werden.
|
||||
3. `pytest` muss grün bleiben (135 Tests).
|
||||
4. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung
|
||||
fällt dort also nicht auf.
|
||||
5. Erst dann committen und auf die produktiven Systeme geben.
|
||||
|
||||
Der ocrmypdf-Sprung 16 → 17 ist ein **Major-Sprung** und gehört in einen eigenen
|
||||
Vorgang mit eigenem Test, nicht in ein OS-Upgrade.
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
# Update
|
||||
|
||||
Aktualisieren des OCR-Tools mit `update.sh` — Code, venv, System-Pakete und
|
||||
systemd-Unit.
|
||||
|
||||
Verwandte Dokumente: [README](../README.md) · [Installation](INSTALLATION.md) · [Debian-Major-Upgrade](OS-UPGRADE.md)
|
||||
|
||||
> Für ein **Debian-Major-Upgrade** (12 → 13) gilt ein eigener Ablauf — die venv
|
||||
> muss danach neu gebaut werden. Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
||||
|
||||
---
|
||||
|
||||
## Der Ablauf
|
||||
|
||||
```bash
|
||||
cd /pfad/zum/repo
|
||||
git pull
|
||||
sudo ./update.sh
|
||||
```
|
||||
|
||||
```
|
||||
sudo ./update.sh --help # Optionen anzeigen
|
||||
sudo ./update.sh --rebuild-venv # venv zwingend neu bauen (nach dist-upgrade)
|
||||
```
|
||||
|
||||
`update.sh` muss aus dem Repo laufen. Findet es sich nicht selbst im Repo, liest
|
||||
es den gespeicherten Pfad aus `/opt/pdf-ocr-hotfolder/.repo_path` — **das Repo
|
||||
muss also liegen bleiben**, das Tool kopiert daraus.
|
||||
|
||||
## Was das Skript tut — in dieser Reihenfolge
|
||||
|
||||
| # | Schritt | Anmerkung |
|
||||
|---|---------|-----------|
|
||||
| 1 | **Instanzen erfassen** | aktiv / kaputt / bewusst gestoppt, siehe [unten](#instanz-erfassung) |
|
||||
| 2 | **System-Pakete abgleichen** | Liste wird aus `install.sh` extrahiert, `apt-get install` ist idempotent |
|
||||
| 3 | **venv prüfen** | passt sie noch zum System-Python? Läuft **vor** dem Stoppen, damit man es früh sieht |
|
||||
| 4 | **Instanzen stoppen** | nur die, die vorher liefen oder kaputt waren |
|
||||
| 5 | **Backup** | [Inhalt und Ort](#backup) |
|
||||
| 6 | **Code kopieren** | `pdf_ocr_hotfolder/`, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
|
||||
| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig |
|
||||
| 8 | **systemd-Units** | Template-Unit aus dem Repo, LXC-Drop-in nachziehen, `daemon-reload` |
|
||||
| 9 | **Berechtigungen** | Code gehört dem primären User (i.d.R. `pdfocr`) |
|
||||
| 10 | **Configs prüfen** | `--check-config` je Instanz, siehe [unten](#config-prüfung-per---check-config) |
|
||||
| 11 | **Instanzen starten + verifizieren** | mit Wartezeit und Crash-Loop-Erkennung |
|
||||
| 12 | **Zusammenfassung** | Soll gegen Ist |
|
||||
|
||||
Ab Schritt 2 gilt: **System-Pakete werden auch beim Update nachgezogen**, nicht
|
||||
nur bei der Installation. Bereits installierte Tesseract-Sprachpakete bleiben
|
||||
unangetastet — es gibt kein `purge` und kein `autoremove`.
|
||||
|
||||
Die Schritte 1–3 verändern nichts auf der Platte. Erst ab Schritt 4 wird
|
||||
angefasst.
|
||||
|
||||
## Was das Skript NICHT anfasst
|
||||
|
||||
| Bleibt unverändert | Warum |
|
||||
|--------------------|-------|
|
||||
| `/etc/pdf-ocr-hotfolder/*.toml` | Instanz-Configs werden **nie** überschrieben — weder neu geschrieben noch gemerged. Neue Config-Keys greifen über ihre Defaults, siehe [Config-Drift](#config-drift-nach-einem-update) |
|
||||
| `/var/lib/pdf-ocr-hotfolder/…` | Datenverzeichnisse (`incoming`, `working`, `outgoing`, `error`, Archiv) — nichts wird verschoben oder gelöscht |
|
||||
| Instanz-Drop-ins (`…@<instanz>.service.d/user.conf`) | Service-User pro Instanz bleibt |
|
||||
| Nachinstallierte Tesseract-Sprachpakete | werden nicht entfernt |
|
||||
| Bewusst gestoppte Instanzen | bleiben gestoppt |
|
||||
|
||||
Neue Config-Optionen muss man also selbst nachtragen, wenn man sie nutzen will.
|
||||
`config.example.toml` liegt nach dem Update aktuell unter
|
||||
`/opt/pdf-ocr-hotfolder/config.example.toml` und ist die Vorlage dafür.
|
||||
|
||||
---
|
||||
|
||||
## Instanz-Erfassung
|
||||
|
||||
Eine Instanz gilt als bekannt, wenn sie **irgendwo** auftaucht: als geladene
|
||||
Unit (`systemctl list-units --all`, also auch `activating` und `failed`), in
|
||||
`list-unit-files` (enabled), oder als Config unter `/etc/pdf-ocr-hotfolder/`.
|
||||
Damit fällt auch eine Instanz auf, die gerade in einem Crash-Loop hängt.
|
||||
|
||||
Aus dem Zustand vor dem Update ergeben sich drei Gruppen:
|
||||
|
||||
| Gruppe | Zustand vorher | Behandlung |
|
||||
|--------|----------------|------------|
|
||||
| **lief sauber** | `active`, nicht `failed` | wird gestoppt und muss nachher wieder laufen — sonst ist das eine **Regression** und das Update meldet Exit 1 |
|
||||
| **war kaputt** | `failed`, `activating`, `reloading`, `deactivating` | wird mitgestartet; läuft sie danach, meldet das Skript "vorher kaputt, läuft jetzt". Läuft sie weiterhin nicht, Exit 1, aber ohne Regressions-Alarm |
|
||||
| **bewusst gestoppt** | `inactive` und nicht `failed` | bleibt gestoppt |
|
||||
|
||||
### Verifikation nach dem Start
|
||||
|
||||
Ein `systemctl start` sagt bei `Type=simple` noch nichts. Deshalb prüft
|
||||
`verify_unit()` nach einer Wartezeit (`VERIFY_WAIT`, Default 6 s) drei Dinge:
|
||||
|
||||
1. `is-active` muss `active` sein
|
||||
2. `is-failed` darf nicht `failed` melden
|
||||
3. `NRestarts` darf nicht gestiegen sein — das entlarvt den Crash-Loop, der sich
|
||||
hinter einem sofortigen "active" versteckt
|
||||
|
||||
Vorher wird `systemctl reset-failed` gefahren, damit der alte Zustand die
|
||||
Prüfung nicht verfälscht. Scheitert eine Instanz, nennt das Skript direkt den
|
||||
passenden `journalctl`-Aufruf.
|
||||
|
||||
Die Zusammenfassung stellt am Ende **Soll gegen Ist** und liefert Exit 1, wenn
|
||||
eine Instanz fehlt, eine Config einen Fehler hat oder eine vorher kaputte
|
||||
Instanz immer noch kaputt ist.
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
Vor dem ersten Eingriff auf der Platte schreibt `update.sh` ein Archiv:
|
||||
|
||||
```
|
||||
/var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz
|
||||
```
|
||||
|
||||
| Enthalten | Nicht enthalten |
|
||||
|-----------|-----------------|
|
||||
| `/opt/pdf-ocr-hotfolder/` (Code) | die **venv** (`venv/`, `venv.old-*`) |
|
||||
| `/etc/pdf-ocr-hotfolder/` (alle Instanz-Configs) | die **Datenverzeichnisse** `/var/lib/pdf-ocr-hotfolder/` |
|
||||
| Template-Unit `pdf-ocr-hotfolder@.service` | `__pycache__`, `*.pyc` |
|
||||
| alle Drop-in-Verzeichnisse `…@*.service.d` | |
|
||||
| `pip-freeze.txt` — `pip freeze` der **alten** venv plus Zeitstempel und Versionssprung | |
|
||||
|
||||
`pip-freeze.txt` ist die Versicherung für den Fall, dass ein neuer Pin Ärger
|
||||
macht: man sieht schwarz auf weiß, welche Paketversionen vorher liefen.
|
||||
|
||||
**Rechte:** Das Archiv enthält die Instanz-Configs und damit **Klartext-Passwörter**
|
||||
(SMTP, Nextcloud, SFTP). Es wird deshalb mit `umask 077` erzeugt und danach auf
|
||||
`0600 root:root` gesetzt; das Verzeichnis selbst bekommt `700`. Backups nicht in
|
||||
Tickets anhängen und nicht in allgemein lesbare Pfade kopieren.
|
||||
|
||||
**Rotation:** Es werden die **letzten 5** Archive behalten (`BACKUP_KEEP`),
|
||||
ältere löscht das Skript nach dem Schreiben des neuen. Was entfernt wurde, steht
|
||||
im Log.
|
||||
|
||||
Scheitert das Backup (typisch: volle Platte), bricht das Update ab, **bevor**
|
||||
etwas getauscht wurde.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Das Backup-Archiv ist wurzelrelativ gepackt und lässt sich direkt zurückspielen:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop 'pdf-ocr-hotfolder@*'
|
||||
sudo tar -xzf /var/backups/pdf-ocr-hotfolder/backup-YYYYmmdd-HHMMSS.tar.gz -C /
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start 'pdf-ocr-hotfolder@<instanz>'
|
||||
```
|
||||
|
||||
Das Skript nennt diesen Befehl mit dem konkreten Archivnamen selbst — sowohl
|
||||
beim Abbruch als auch bei einer erkannten Regression.
|
||||
|
||||
### Grenzen des Rollbacks
|
||||
|
||||
Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen:
|
||||
|
||||
- **Die venv ist nicht im Backup.** Wurde sie beim Update neu gebaut oder
|
||||
hat `pip install --upgrade` Pakete angehoben, holt das Rollback den alten
|
||||
Stand der Pakete **nicht** zurück. Dafür ist `pip-freeze.txt` aus dem Archiv
|
||||
da: die dort genannten Versionen lassen sich von Hand wiederherstellen
|
||||
(`venv/bin/pip install -r …`).
|
||||
- **Dateien, die es vorher nicht gab, bleiben liegen.** `tar -x` legt nur an und
|
||||
überschreibt; es löscht nichts. Eine mit dem neuen Stand hinzugekommene Datei
|
||||
im Code-Verzeichnis überlebt das Rollback. Sauberer ist deshalb
|
||||
`rm -rf /opt/pdf-ocr-hotfolder/pdf_ocr_hotfolder` **vor** dem Entpacken.
|
||||
- **Die Datenverzeichnisse sind nicht im Backup** — gewollt. Ein Rollback
|
||||
verändert keine PDFs, weder in `incoming/` noch in `error/`.
|
||||
- **System-Pakete werden nicht zurückgenommen.** Ein per apt angehobenes
|
||||
Ghostscript oder ein neues Sprachpaket bleibt.
|
||||
|
||||
Der einfachere Weg zurück ist deshalb in den meisten Fällen: alten Stand im Repo
|
||||
auschecken (`git checkout v<version>`) und `sudo ./update.sh` erneut fahren.
|
||||
|
||||
### Der ERR-Trap
|
||||
|
||||
`update.sh` läuft mit `set -Eeuo pipefail` und hat ab dem Moment, in dem
|
||||
Instanzen gestoppt werden, einen Trap auf `ERR`, `INT` und `TERM`. Bricht
|
||||
irgendetwas ab — Fehler, Strg-C, `kill` —, dann:
|
||||
|
||||
1. sagt das Skript laut, bei welchem Exit-Code und in welcher Zeile es aufhörte,
|
||||
2. sagt es, **ob auf der Platte schon getauscht wurde** oder ob der alte Stand
|
||||
unverändert ist,
|
||||
3. **startet es die vorher laufenden Instanzen wieder** und meldet jede einzeln,
|
||||
4. nennt es das Backup-Archiv und den Rollback-Befehl — oder sagt ausdrücklich,
|
||||
dass noch kein Backup geschrieben wurde.
|
||||
|
||||
Ein abgebrochenes Update lässt also keinen Hotfolder stumm gestoppt zurück.
|
||||
|
||||
---
|
||||
|
||||
## Config-Prüfung per `--check-config`
|
||||
|
||||
Nach dem Code-Update und vor dem Start prüft `update.sh` jede Instanz-Config mit
|
||||
dem neuen Code:
|
||||
|
||||
```bash
|
||||
/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder \
|
||||
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||
```
|
||||
|
||||
`--check-config` verarbeitet nichts, es hat sogar Vorrang vor `--once`. Es lädt
|
||||
die Config, zeigt die vier Pfade (inkl. Hinweis, falls ein Verzeichnis noch
|
||||
fehlt), Sprachen, Seiten-Timeout und PDF/A-Level, fährt den Preflight
|
||||
(`tesseract`, `gs`, Ghostscript-Version bei gesetztem `pdfa_level`) und
|
||||
validiert die `[output]`-Sektion.
|
||||
|
||||
| Exit | Bedeutung | Was der Admin tun soll |
|
||||
|------|-----------|------------------------|
|
||||
| **0** | Config sauber | nichts |
|
||||
| **1** | Config nutzbar, aber mit **Warnungen** | Kein Abbruchgrund, der Dienst läuft. Die Warnungen aber **nachziehen** — sie nennen entweder einen Key, dessen Bedeutung sich geändert hat, oder einen Eintrag, der ins Leere läuft (s. [Config-Drift](#config-drift-nach-einem-update)) |
|
||||
| **2** | Config **unbrauchbar** — der Dienst würde nicht starten | Sofort korrigieren. Die Instanz gilt als **nicht** erfolgreich aktualisiert, das Update endet mit Exit 1 |
|
||||
|
||||
Kennt der installierte Code `--check-config` noch nicht (Update von einem Stand
|
||||
vor 0.6.0), erkennt `update.sh` das an der argparse-Meldung, überspringt die
|
||||
Prüfung mit einer Warnung und läuft weiter.
|
||||
|
||||
Dieselben Warnungen schreibt der Dienst beim Start ins Journal — man sieht sie
|
||||
also auch ohne Update:
|
||||
|
||||
```bash
|
||||
journalctl -u pdf-ocr-hotfolder@<instanz> | grep 'Config-Warnung'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config-Drift nach einem Update
|
||||
|
||||
Instanz-Configs werden nie überschrieben. Das ist bequem, hat aber zwei
|
||||
Konsequenzen.
|
||||
|
||||
**Neue Keys sind unkritisch.** Fehlt ein Key, greift der Default aus der
|
||||
Dataclass — genau der Wert, der auch in `config.example.toml` steht. Eine Config
|
||||
von 0.2.x läuft unter 0.6.0 weiter, ohne dass etwas nachgetragen werden muss.
|
||||
Wer die neue Option nutzen will, trägt sie nach; die aktuelle Vorlage liegt
|
||||
nach jedem Update unter `/opt/pdf-ocr-hotfolder/config.example.toml`.
|
||||
|
||||
Zwei Fälle brauchen aber Handarbeit — beide meldet `--check-config` von selbst:
|
||||
|
||||
### `[ocr].timeout` — Bedeutung geändert seit 0.4.0
|
||||
|
||||
Vor 0.4.0 war `timeout` ein **Gesamt**-Timeout pro PDF mit Default `1800` — und
|
||||
wurde nirgends ausgewertet, war also wirkungslos. Seit 0.4.0 geht der Wert als
|
||||
`tesseract_timeout` an ocrmypdf und ist damit das Limit **pro Seite**; ein
|
||||
Dokument-Timeout kennt ocrmypdf nicht.
|
||||
|
||||
Wer den Altwert `1800` stehen hat, gibt Tesseract jetzt **30 Minuten je Seite**.
|
||||
Ab `900` meldet `--check-config` deshalb eine Warnung. **Richtwert: 300.**
|
||||
|
||||
```toml
|
||||
[ocr]
|
||||
timeout = 300 # Sekunden pro SEITE
|
||||
```
|
||||
|
||||
`0` heißt "kein eigenes Limit": der Wert wird dann gar nicht durchgereicht, weil
|
||||
ocrmypdf `tesseract_timeout=0` als "OCR komplett überspringen" interpretiert.
|
||||
|
||||
### `[ocr].pdfa_level` — sollte leer sein
|
||||
|
||||
`pdfa_level` gehört auf `""` (reines PDF, kein PDF/A). Ist es gesetzt, warnt
|
||||
`--check-config`, weil Ghostscript 10.0.0–10.02.0 — der Debian-12-Default — in
|
||||
Kombination mit `skip_text` das OCR blockiert. Der Preflight bricht in dem Fall
|
||||
mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch. Hintergrund:
|
||||
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||
|
||||
### Unbekannte Keys
|
||||
|
||||
Einträge, die zu keiner Sektion und keinem Key gehören, werden beim Laden
|
||||
ignoriert — aber **gemeldet**, mit Pfad (`[ocr].langauges`). Das deckt
|
||||
Tippfehler und Optionen aus älteren Versionen ab. Die Meldung ist eine Warnung,
|
||||
kein Fehler: der Dienst startet, der Eintrag tut nur nichts.
|
||||
|
||||
---
|
||||
|
||||
## Nach dem Update prüfen
|
||||
|
||||
```bash
|
||||
systemctl status 'pdf-ocr-hotfolder@*'
|
||||
journalctl -u 'pdf-ocr-hotfolder@*' --since '5 min ago'
|
||||
```
|
||||
|
||||
Und einmal eine Test-PDF durchschieben:
|
||||
|
||||
```bash
|
||||
cp test.pdf /var/lib/pdf-ocr-hotfolder/<instanz>/incoming/
|
||||
journalctl -u pdf-ocr-hotfolder@<instanz> -f
|
||||
```
|
||||
|
||||
Im `outgoing/` muss das OCR-PDF auftauchen.
|
||||
|
||||
---
|
||||
|
||||
## Wiederaufnahme aus `working/`
|
||||
|
||||
Beim Start greift der Dienst nicht nur `incoming/` auf, sondern zuerst
|
||||
`working/`: Dateien, die ein harter Stopp dort liegen ließ, werden
|
||||
wiederaufgenommen und das OCR läuft für sie neu. Unvollständige Zwischendateien
|
||||
des abgebrochenen Laufs (Präfix `__ocr_`) werden dabei gelöscht.
|
||||
|
||||
Für das Update heißt das: ein `systemctl stop` mitten im OCR kostet den
|
||||
angefangenen Durchlauf, aber keine Datei. Die Unit gibt einem laufenden OCR
|
||||
`TimeoutStopSec=300` Zeit, sauber fertig zu werden — **ein Stop kann damit pro
|
||||
Instanz bis zu 5 Minuten dauern.** Bei mehreren Instanzen entsprechend länger;
|
||||
`update.sh` stoppt sie nacheinander.
|
||||
+283
-13
@@ -26,7 +26,6 @@ fi
|
||||
INSTALL_DIR="/opt/pdf-ocr-hotfolder"
|
||||
CONFIG_DIR="/etc/pdf-ocr-hotfolder"
|
||||
DATA_ROOT="/var/lib/pdf-ocr-hotfolder"
|
||||
LOG_DIR="/var/log/pdf-ocr-hotfolder"
|
||||
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||
DEFAULT_USER="pdfocr"
|
||||
|
||||
@@ -38,20 +37,114 @@ if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# System-Pakete — einzige Quelle der Wahrheit
|
||||
# ============================================================
|
||||
# Der folgende Block wird von update.sh aus dieser Datei herausgeschnitten
|
||||
# (sed auf die BEGIN/END-Marken) und dort ausgewertet, damit ein Update
|
||||
# neue Pakete nachzieht. Die Marken und der Funktionsname duerfen sich
|
||||
# deshalb nicht aendern, ohne update.sh anzupassen.
|
||||
# --- BEGIN apt-packages (wird von update.sh extrahiert) ---
|
||||
pdf_ocr_apt_packages() {
|
||||
cat <<'PKGLIST'
|
||||
python3
|
||||
python3-venv
|
||||
python3-pip
|
||||
tesseract-ocr
|
||||
tesseract-ocr-deu
|
||||
tesseract-ocr-eng
|
||||
ghostscript
|
||||
qpdf
|
||||
unpaper
|
||||
pngquant
|
||||
icc-profiles-free
|
||||
ca-certificates
|
||||
curl
|
||||
PKGLIST
|
||||
}
|
||||
# --- END apt-packages ---
|
||||
|
||||
# Prueft, ob die venv noch zum aktuellen System-Python passt.
|
||||
# Zwei Faelle: (1) der Interpreter der venv laeuft gar nicht mehr (toter
|
||||
# Symlink nach einem Distributions-Upgrade, systemd: 203/EXEC), (2) er laeuft
|
||||
# noch, ist aber eine andere Version als das System-Python (Debian 12 -> 13).
|
||||
# Beides heisst: neu bauen. Keine Versionsnummer ist hier hartcodiert.
|
||||
venv_is_healthy() {
|
||||
local venv="$1" venv_mm sys_mm
|
||||
[ -x "$venv/bin/python" ] || return 1
|
||||
venv_mm="$("$venv/bin/python" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
|
||||
[ -n "$venv_mm" ] || return 1
|
||||
sys_mm="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
|
||||
[ -n "$sys_mm" ] || return 1
|
||||
[ "$venv_mm" = "$sys_mm" ]
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Basis-Installation (idempotent)
|
||||
# ============================================================
|
||||
|
||||
install_base() {
|
||||
log_step "System-Pakete installieren"
|
||||
local -a PKGS
|
||||
mapfile -t PKGS < <(pdf_ocr_apt_packages)
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-venv python3-pip \
|
||||
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
|
||||
ghostscript qpdf unpaper pngquant \
|
||||
icc-profiles-free ca-certificates curl
|
||||
apt-get install -y --no-install-recommends "${PKGS[@]}"
|
||||
log_info "System-Pakete ok ✓"
|
||||
|
||||
# Ghostscript-Versions-Check (Issue #3 + Issue #6)
|
||||
if command -v gs >/dev/null 2>&1; then
|
||||
GS_VER="$(gs --version 2>/dev/null || echo 0.0)"
|
||||
log_info "Ghostscript: $GS_VER"
|
||||
case "$GS_VER" in
|
||||
10.0.0|10.00.0|10.01.*|10.02.0)
|
||||
echo
|
||||
log_warn "═══════════════════════════════════════════════════════════════"
|
||||
log_warn "Ghostscript $GS_VER ist vom PDF/A-Bug betroffen (10.0.0–10.02.0)."
|
||||
log_warn "Mit pdfa_level + skip_text=true kann ocrmypdf KEINE PDFs verarbeiten."
|
||||
log_warn "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
# Prüfe ob Debian bookworm (12) — Backports anbieten
|
||||
if grep -q 'bookworm' /etc/os-release 2>/dev/null; then
|
||||
read -r -p "Ghostscript via bookworm-backports upgraden? [J/n]: " UPGRADE_GS
|
||||
UPGRADE_GS="${UPGRADE_GS:-J}"
|
||||
if [[ "$UPGRADE_GS" =~ ^[JjYy]$ ]]; then
|
||||
log_info "Aktiviere bookworm-backports..."
|
||||
if ! grep -q 'bookworm-backports' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
|
||||
echo 'deb http://deb.debian.org/debian bookworm-backports main' \
|
||||
> /etc/apt/sources.list.d/bookworm-backports.list
|
||||
apt-get update -qq
|
||||
fi
|
||||
apt-get install -y -t bookworm-backports ghostscript
|
||||
GS_VER_NEW="$(gs --version 2>/dev/null || echo '?')"
|
||||
log_info "Ghostscript aktualisiert: $GS_VER → $GS_VER_NEW ✓"
|
||||
else
|
||||
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
|
||||
fi
|
||||
else
|
||||
log_warn "Kein Debian bookworm erkannt — manuelles Upgrade nötig."
|
||||
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
|
||||
fi
|
||||
echo
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# LXC/Container-Erkennung (Issue #4)
|
||||
if systemd-detect-virt --container -q 2>/dev/null; then
|
||||
VIRT_TYPE="$(systemd-detect-virt --container 2>/dev/null || echo 'container')"
|
||||
log_warn "Container-Umgebung erkannt ($VIRT_TYPE)."
|
||||
log_warn "systemd-Hardening kann in Containern fehlschlagen (Error 226/NAMESPACE)."
|
||||
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
|
||||
LXC_FIX="${LXC_FIX:-J}"
|
||||
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
|
||||
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
|
||||
mkdir -p "$LXC_DROPIN_DIR"
|
||||
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf"
|
||||
systemctl daemon-reload
|
||||
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_step "Default-User '$DEFAULT_USER' prüfen"
|
||||
if id "$DEFAULT_USER" &>/dev/null; then
|
||||
log_info "'$DEFAULT_USER' existiert bereits"
|
||||
@@ -61,7 +154,7 @@ install_base() {
|
||||
fi
|
||||
|
||||
log_step "Verzeichnisse anlegen"
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" "$LOG_DIR"
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT"
|
||||
chown root:"$DEFAULT_USER" "$CONFIG_DIR"
|
||||
chmod 750 "$CONFIG_DIR"
|
||||
|
||||
@@ -74,11 +167,25 @@ install_base() {
|
||||
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
|
||||
|
||||
log_step "Python venv"
|
||||
# Eine vorhandene, aber kaputte venv (z.B. nach Debian 12 -> 13) wird
|
||||
# weggesichert und neu gebaut — ein reines "-d"-Vorhandensein reicht nicht.
|
||||
if [ -d "$INSTALL_DIR/venv" ] && ! venv_is_healthy "$INSTALL_DIR/venv"; then
|
||||
local VENV_SAVED
|
||||
VENV_SAVED="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
|
||||
log_warn "Vorhandene venv passt nicht mehr zum System-Python (Distributions-Upgrade?)."
|
||||
log_warn "Sie wird gesichert nach: $VENV_SAVED"
|
||||
mv "$INSTALL_DIR/venv" "$VENV_SAVED"
|
||||
fi
|
||||
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||
python3 -m venv "$INSTALL_DIR/venv"
|
||||
fi
|
||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
|
||||
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q
|
||||
if ! "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q; then
|
||||
log_error "Requirements liessen sich nicht installieren."
|
||||
log_error "Wahrscheinlich passt eine in requirements.txt gepinnte Version nicht"
|
||||
log_error "zu Python $(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo '?')."
|
||||
exit 1
|
||||
fi
|
||||
log_info "venv ok ✓"
|
||||
|
||||
log_step "systemd Template-Unit installieren"
|
||||
@@ -86,7 +193,7 @@ install_base() {
|
||||
systemctl daemon-reload
|
||||
log_info "Template-Unit installiert ✓"
|
||||
|
||||
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR"
|
||||
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
@@ -116,6 +223,66 @@ show_existing_instances() {
|
||||
echo
|
||||
}
|
||||
|
||||
# Liest den Wert eines Keys (erste Zuweisung am Zeilenanfang) aus einer Config
|
||||
config_value() {
|
||||
local file="$1" key="$2"
|
||||
sed -n "s|^${key}[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*$|\1|p" "$file" | head -n1
|
||||
}
|
||||
|
||||
# Maskiert Sonderzeichen, damit ein Pfad gefahrlos in eine sed-Ersetzung darf
|
||||
# (Trennzeichen '|', Rueckverweis '&', Backslash).
|
||||
sed_escape_repl() {
|
||||
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
|
||||
}
|
||||
|
||||
# Prueft jeden Tesseract-Sprachcode gegen die installierten Sprachdateien und
|
||||
# bietet fehlende Pakete zur Installation an.
|
||||
# Rueckgabe: 0 = alle Sprachen verfuegbar (oder Pruefung nicht moeglich),
|
||||
# 1 = mindestens eine Sprache fehlt weiterhin.
|
||||
ensure_tesseract_langs() {
|
||||
local langs="$1"
|
||||
local raw installed code pkg answer rc=0
|
||||
local -a codes
|
||||
|
||||
if ! command -v tesseract >/dev/null 2>&1; then
|
||||
log_warn "tesseract ist nicht aufrufbar — Sprachpruefung wird uebersprungen."
|
||||
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
|
||||
return 0
|
||||
fi
|
||||
if ! raw="$(tesseract --list-langs 2>/dev/null)"; then
|
||||
log_warn "'tesseract --list-langs' schlug fehl — Sprachpruefung wird uebersprungen."
|
||||
log_warn "Eingabe '$langs' wird unveraendert uebernommen."
|
||||
return 0
|
||||
fi
|
||||
installed="$(printf '%s\n' "$raw" | grep -vi '^List of available' || true)"
|
||||
|
||||
IFS='+' read -r -a codes <<< "$langs"
|
||||
for code in "${codes[@]}"; do
|
||||
[ -n "$code" ] || continue
|
||||
if printf '%s\n' "$installed" | grep -qxF "$code"; then
|
||||
log_info "Sprache '$code' ist installiert ✓"
|
||||
continue
|
||||
fi
|
||||
pkg="tesseract-ocr-${code//_/-}"
|
||||
log_warn "Sprache '$code' ist nicht installiert (Paket: $pkg)."
|
||||
read -r -p "Paket '$pkg' jetzt installieren? [J/n]: " answer
|
||||
answer="${answer:-J}"
|
||||
if [[ "$answer" =~ ^[JjYy]$ ]]; then
|
||||
if ! apt-get install -y --no-install-recommends "$pkg"; then
|
||||
log_error "Paket '$pkg' liess sich nicht installieren."
|
||||
elif tesseract --list-langs 2>/dev/null | grep -qxF "$code"; then
|
||||
log_info "Paket '$pkg' installiert ✓"
|
||||
continue
|
||||
else
|
||||
log_error "Paket '$pkg' ist da, aber tesseract kennt '$code' weiterhin nicht."
|
||||
fi
|
||||
fi
|
||||
log_warn "Ohne die Sprachdatei '$code' scheitert das OCR bei JEDER Datei."
|
||||
rc=1
|
||||
done
|
||||
return $rc
|
||||
}
|
||||
|
||||
create_instance() {
|
||||
echo
|
||||
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
|
||||
@@ -153,20 +320,111 @@ create_instance() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- OCR-Sprachen ---
|
||||
echo
|
||||
log_info "Tesseract-Sprachen — gelten NUR fuer diese Instanz '$INST'."
|
||||
log_info "Jede zusaetzliche Sprache kostet Laufzeit und verschlechtert zugleich"
|
||||
log_info "die Erkennung — also so eng wie moeglich waehlen (z.B. nur 'deu')."
|
||||
local LANGS
|
||||
while true; do
|
||||
read -r -p "Tesseract-Sprachen [deu+eng]: " LANGS
|
||||
LANGS="${LANGS:-deu+eng}"
|
||||
if [[ ! "$LANGS" =~ ^[a-z]{3}(_[A-Za-z]+)?(\+[a-z]{3}(_[A-Za-z]+)?)*$ ]]; then
|
||||
log_error "Ungueltiges Format. Erwartet: Sprachcodes mit '+' verbunden,"
|
||||
log_error "z.B. 'deu', 'deu+eng' oder 'chi_sim+eng'."
|
||||
continue
|
||||
fi
|
||||
if ensure_tesseract_langs "$LANGS"; then
|
||||
break
|
||||
fi
|
||||
log_warn "Bitte Sprachen erneut angeben (fehlende Sprache einfach weglassen)."
|
||||
echo
|
||||
done
|
||||
|
||||
# --- Original archivieren? ---
|
||||
echo
|
||||
local ORIG_MODE="delete"
|
||||
local ARCHIVE_DIR=""
|
||||
local ARCHIVE_ANS
|
||||
read -r -p "Original nach erfolgreichem OCR archivieren? [j/N]: " ARCHIVE_ANS
|
||||
ARCHIVE_ANS="${ARCHIVE_ANS:-N}"
|
||||
if [[ "$ARCHIVE_ANS" =~ ^[JjYy]$ ]]; then
|
||||
ORIG_MODE="archive"
|
||||
local default_archive="$BASE/archive"
|
||||
while true; do
|
||||
read -r -p "Archiv-Verzeichnis [$default_archive]: " ARCHIVE_DIR
|
||||
ARCHIVE_DIR="${ARCHIVE_DIR:-$default_archive}"
|
||||
if [[ "$ARCHIVE_DIR" != /* ]]; then
|
||||
log_error "Bitte einen absoluten Pfad angeben (beginnt mit '/')."
|
||||
continue
|
||||
fi
|
||||
# Das Archiv darf keines der Arbeitsverzeichnisse sein: im Eingang
|
||||
# wuerde das Original endlos neu aufgegriffen, in den uebrigen
|
||||
# kollidiert es mit der Verarbeitung.
|
||||
case "${ARCHIVE_DIR%/}" in
|
||||
"$BASE/incoming"|"$BASE/outgoing"|"$BASE/working"|"$BASE/error")
|
||||
log_error "Das Archiv darf nicht incoming/outgoing/working/error sein."
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
break
|
||||
done
|
||||
else
|
||||
log_info "Original wird nach erfolgreichem OCR geloescht (original_on_success = \"delete\")."
|
||||
fi
|
||||
|
||||
log_info "Lege Datenverzeichnisse unter $BASE an..."
|
||||
mkdir -p "$BASE"/{incoming,outgoing,working,error}
|
||||
if [ -n "$ARCHIVE_DIR" ]; then
|
||||
mkdir -p "$ARCHIVE_DIR"
|
||||
fi
|
||||
chown -R "$SVC_USER":"$SVC_GROUP" "$BASE"
|
||||
# Innerhalb von $BASE erledigt das chown -R oben schon alles; nur ein Archiv
|
||||
# ausserhalb braucht eigenes mkdir/chown.
|
||||
if [ -n "$ARCHIVE_DIR" ] && [[ "$ARCHIVE_DIR" != "$BASE"/* ]] && [ "$ARCHIVE_DIR" != "$BASE" ]; then
|
||||
chown -R "$SVC_USER":"$SVC_GROUP" "$ARCHIVE_DIR"
|
||||
log_info "Archiv-Verzeichnis $ARCHIVE_DIR angelegt (liegt ausserhalb von $BASE)"
|
||||
fi
|
||||
|
||||
log_info "Erstelle Config $CONFIG_DIR/$INST.toml..."
|
||||
# Verankerte Ausdruecke (Zeilenanfang + Key + '='), damit die deutschen
|
||||
# Kommentarzeilen ueber den Keys unangetastet bleiben.
|
||||
local ESC_BASE ESC_ARCHIVE ESC_LANGS
|
||||
ESC_BASE="$(sed_escape_repl "$BASE")"
|
||||
ESC_ARCHIVE="$(sed_escape_repl "$ARCHIVE_DIR")"
|
||||
ESC_LANGS="$(sed_escape_repl "$LANGS")"
|
||||
sed \
|
||||
-e "s|/var/lib/pdf-ocr-hotfolder/incoming|$BASE/incoming|" \
|
||||
-e "s|/var/lib/pdf-ocr-hotfolder/outgoing|$BASE/outgoing|" \
|
||||
-e "s|/var/lib/pdf-ocr-hotfolder/working|$BASE/working|" \
|
||||
-e "s|/var/lib/pdf-ocr-hotfolder/error|$BASE/error|" \
|
||||
-e "s|^incoming[[:space:]]*=.*|incoming = \"$ESC_BASE/incoming\"|" \
|
||||
-e "s|^outgoing[[:space:]]*=.*|outgoing = \"$ESC_BASE/outgoing\"|" \
|
||||
-e "s|^working[[:space:]]*=.*|working = \"$ESC_BASE/working\"|" \
|
||||
-e "s|^error[[:space:]]*=.*|error = \"$ESC_BASE/error\"|" \
|
||||
-e "s|^languages[[:space:]]*=.*|languages = \"$ESC_LANGS\"|" \
|
||||
-e "s|^original_on_success[[:space:]]*=.*|original_on_success = \"$ORIG_MODE\"|" \
|
||||
-e "s|^archive_dir[[:space:]]*=.*|archive_dir = \"$ESC_ARCHIVE\"|" \
|
||||
"$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml"
|
||||
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
|
||||
chmod 640 "$CONFIG_DIR/$INST.toml"
|
||||
|
||||
# Erzeugte Config gegenpruefen: tragen die drei Keys wirklich die Auswahl?
|
||||
local CFG_OK=1 got key want
|
||||
for key in languages original_on_success archive_dir; do
|
||||
case "$key" in
|
||||
languages) want="$LANGS" ;;
|
||||
original_on_success) want="$ORIG_MODE" ;;
|
||||
archive_dir) want="$ARCHIVE_DIR" ;;
|
||||
esac
|
||||
got="$(config_value "$CONFIG_DIR/$INST.toml" "$key")"
|
||||
if [ "$got" != "$want" ]; then
|
||||
log_error "Config-Pruefung: $key ist \"$got\", erwartet \"$want\""
|
||||
CFG_OK=0
|
||||
fi
|
||||
done
|
||||
if [ "$CFG_OK" -eq 1 ]; then
|
||||
log_info "Config-Pruefung ok ✓ (languages / original_on_success / archive_dir)"
|
||||
else
|
||||
log_warn "Bitte $CONFIG_DIR/$INST.toml von Hand nachziehen."
|
||||
fi
|
||||
|
||||
# Drop-in für abweichenden Service-User
|
||||
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
|
||||
local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d"
|
||||
@@ -193,6 +451,12 @@ EOF
|
||||
echo " Eingang: $BASE/incoming"
|
||||
echo " Ausgang: $BASE/outgoing"
|
||||
echo " User: $SVC_USER ($SVC_GROUP)"
|
||||
if [ "$CFG_OK" -eq 1 ]; then
|
||||
echo " Sprachen: $LANGS"
|
||||
if [ "$ORIG_MODE" = "archive" ]; then
|
||||
echo " Archiv: $ARCHIVE_DIR"
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -205,9 +469,15 @@ echo "=========================================="
|
||||
echo " PDF OCR Hotfolder — Installer"
|
||||
echo "=========================================="
|
||||
|
||||
# Auch eine vorhandene, aber kaputte venv loest die Basis-Installation aus
|
||||
# (sonst wuerde install.sh nach einem Distributions-Upgrade nichts reparieren).
|
||||
if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
|
||||
log_step "Basis-Installation"
|
||||
install_base
|
||||
elif ! venv_is_healthy "$INSTALL_DIR/venv"; then
|
||||
log_warn "Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python."
|
||||
log_step "Basis-Installation wird zur Reparatur erneut ausgefuehrt"
|
||||
install_base
|
||||
else
|
||||
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
|
||||
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -4,11 +4,24 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .config import load_config
|
||||
from .service import HotfolderService
|
||||
from .config import Config, ConfigError, config_warnings, load_config
|
||||
from .service import (
|
||||
HotfolderService,
|
||||
PreflightError,
|
||||
check_output_config,
|
||||
check_preflight,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Exit-Codes von --check-config (werden vom Updater ausgewertet)
|
||||
CHECK_OK = 0
|
||||
CHECK_WARN = 1
|
||||
CHECK_ERROR = 2
|
||||
|
||||
|
||||
def _setup_logging(level: str) -> None:
|
||||
@@ -19,6 +32,81 @@ def _setup_logging(level: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _log_config_warnings(cfg: Config) -> None:
|
||||
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
|
||||
for warning in config_warnings(cfg):
|
||||
log.warning("Config-Warnung: %s", warning)
|
||||
|
||||
|
||||
def check_config(cfg_path: Path) -> int:
|
||||
"""Lädt und prüft die Config, ohne irgendetwas zu verarbeiten.
|
||||
|
||||
Returns:
|
||||
0 = alles sauber, 1 = nur Warnungen, 2 = Fehler (Config unbrauchbar
|
||||
oder Preflight scheitert).
|
||||
"""
|
||||
print(f"Prüfe Konfiguration: {cfg_path}")
|
||||
|
||||
try:
|
||||
cfg = load_config(cfg_path)
|
||||
except ConfigError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
except OSError as e:
|
||||
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
|
||||
print(" Config gelesen.")
|
||||
for label, path in (("incoming", cfg.paths.incoming),
|
||||
("outgoing", cfg.paths.outgoing),
|
||||
("working ", cfg.paths.working),
|
||||
("error ", cfg.paths.error)):
|
||||
hint = "" if path.is_dir() else " (existiert noch nicht, "\
|
||||
"wird beim Start angelegt)"
|
||||
print(f" {label} = {path}{hint}")
|
||||
print(f" OCR-Sprachen = {cfg.ocr.languages}")
|
||||
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
|
||||
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
check_preflight(cfg.ocr.pdfa_level)
|
||||
print(" Preflight ok (tesseract, gs vorhanden).")
|
||||
except PreflightError as e:
|
||||
errors.append(str(e))
|
||||
try:
|
||||
check_output_config(cfg.output.original_on_success,
|
||||
cfg.output.archive_dir,
|
||||
cfg.output.name_mode)
|
||||
print(" [output]-Sektion ok.")
|
||||
except PreflightError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
warnings = config_warnings(cfg)
|
||||
if warnings:
|
||||
print(f"\n{len(warnings)} Warnung(en):")
|
||||
for w in warnings:
|
||||
print(f" WARNUNG: {w}")
|
||||
|
||||
if errors:
|
||||
print(f"\n{len(errors)} Fehler:", file=sys.stderr)
|
||||
for e in errors:
|
||||
print(f" FEHLER: {e}", file=sys.stderr)
|
||||
print("\nErgebnis: Config unbrauchbar — der Dienst würde nicht starten.",
|
||||
file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
|
||||
if warnings:
|
||||
print("\nErgebnis: Config nutzbar, aber mit Warnungen.")
|
||||
return CHECK_WARN
|
||||
|
||||
print("\nErgebnis: Config sauber.")
|
||||
return CHECK_OK
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pdf-ocr-hotfolder",
|
||||
@@ -29,6 +117,9 @@ def main() -> int:
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="Nur bestehende Dateien verarbeiten und beenden")
|
||||
parser.add_argument("--check-config", action="store_true", dest="check_config",
|
||||
help="Config nur prüfen, nichts verarbeiten "
|
||||
"(Exit 0 = sauber, 1 = Warnungen, 2 = Fehler)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
@@ -36,18 +127,33 @@ def main() -> int:
|
||||
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.check_config:
|
||||
# Hat Vorrang vor --once: es wird nichts verarbeitet.
|
||||
return check_config(cfg_path)
|
||||
|
||||
try:
|
||||
cfg = load_config(cfg_path)
|
||||
except ConfigError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 2
|
||||
_setup_logging(cfg.log_level)
|
||||
_log_config_warnings(cfg)
|
||||
|
||||
service = HotfolderService(cfg)
|
||||
|
||||
if args.once:
|
||||
service._ensure_dirs() # noqa: SLF001
|
||||
service._scan_existing() # noqa: SLF001
|
||||
service._executor.shutdown(wait=True) # noqa: SLF001
|
||||
return 0
|
||||
try:
|
||||
errors = service.run_once()
|
||||
except PreflightError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 2
|
||||
return 1 if errors > 0 else 0
|
||||
|
||||
try:
|
||||
service.run()
|
||||
except PreflightError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
+150
-7
@@ -7,6 +7,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Konfigurationsdatei ist unvollständig oder fehlerhaft."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Paths:
|
||||
incoming: Path
|
||||
@@ -21,11 +25,26 @@ class OcrConfig:
|
||||
jobs: int = 4
|
||||
skip_text: bool = True
|
||||
oversample: int = 300
|
||||
pdfa_level: str = "2"
|
||||
# Default bewusst leer: pdfa_level + skip_text zerschießt OCR mit
|
||||
# Ghostscript 10.0.0-10.02.0 (Debian-12-Default), siehe Issue #3
|
||||
pdfa_level: str = ""
|
||||
deskew: bool = True
|
||||
clean: bool = False
|
||||
max_workers: int = 2
|
||||
timeout: int = 1800
|
||||
# Max. Sekunden, die Tesseract pro Seite laufen darf (0 = kein eigenes Limit)
|
||||
timeout: int = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputConfig:
|
||||
# "prefix" | "suffix" | "none"
|
||||
name_mode: str = "prefix"
|
||||
# Tag-String, verbatim eingefügt (Leerstring = kein Tag)
|
||||
name_tag: str = "OCR_"
|
||||
# "delete" | "archive"
|
||||
original_on_success: str = "delete"
|
||||
# Absoluter Pfad; Pflicht wenn original_on_success == "archive"
|
||||
archive_dir: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -79,12 +98,25 @@ class EmailNotify:
|
||||
class Config:
|
||||
paths: Paths
|
||||
ocr: OcrConfig
|
||||
output: OutputConfig
|
||||
verapdf: VeraPdfConfig
|
||||
folder: FolderUpload
|
||||
nextcloud: NextcloudUpload
|
||||
sftp: SftpUpload
|
||||
email: EmailNotify
|
||||
log_level: str = "INFO"
|
||||
# Einträge der TOML, die zu keiner Dataclass gehören (Tippfehler oder
|
||||
# Optionen aus älteren Versionen). load_config() sammelt sie hier ein,
|
||||
# statt sie stumm zu verwerfen — geloggt wird erst weiter oben, damit
|
||||
# load_config() ohne konfiguriertes Logging benutzbar bleibt.
|
||||
unknown_keys: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Bekannte Sektionen — alles andere landet in Config.unknown_keys
|
||||
_KNOWN_SECTIONS = ("paths", "ocr", "output", "verapdf", "upload", "notify", "logging")
|
||||
_KNOWN_UPLOAD_TARGETS = ("folder", "nextcloud", "sftp")
|
||||
_KNOWN_NOTIFY_TARGETS = ("email",)
|
||||
_KNOWN_LOGGING_KEYS = ("level",)
|
||||
|
||||
|
||||
def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||
@@ -94,21 +126,82 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||
return cur if isinstance(cur, dict) else {}
|
||||
|
||||
|
||||
def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
||||
"""Holt einen Pflicht-Pfad aus der [paths]-Sektion.
|
||||
|
||||
Wirft ConfigError mit klarer Meldung statt eines nackten KeyError.
|
||||
"""
|
||||
value = p.get(key)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise ConfigError(
|
||||
f"{cfg_path}: In der Sektion [paths] fehlt der Eintrag '{key}' "
|
||||
f"(oder er ist leer). Bitte ergänzen, z.B. "
|
||||
f'{key} = "/var/lib/pdf-ocr-hotfolder/{key}" '
|
||||
f"— siehe config.example.toml."
|
||||
)
|
||||
return Path(str(value))
|
||||
|
||||
|
||||
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
|
||||
known: tuple[str, ...]) -> list[str]:
|
||||
"""Listet alle Keys einer Sektion auf, die nicht in `known` stehen."""
|
||||
label = ".".join(keys)
|
||||
return [f"[{label}].{k}" for k in _section(data, *keys) if k not in known]
|
||||
|
||||
|
||||
def _collect_unknown_keys(data: dict[str, Any]) -> list[str]:
|
||||
"""Sammelt alle TOML-Einträge, die nirgends ausgewertet werden.
|
||||
|
||||
Dazu zählen Tippfehler (`[ocr].langauges`), Optionen aus älteren
|
||||
Versionen und komplett unbekannte Sektionen. Die Reihenfolge entspricht
|
||||
der Datei, damit die Meldung reproduzierbar bleibt.
|
||||
"""
|
||||
unknown: list[str] = []
|
||||
for name, value in data.items():
|
||||
if name not in _KNOWN_SECTIONS:
|
||||
unknown.append(f"[{name}]" if isinstance(value, dict) else name)
|
||||
unknown += _unknown_in(data, ("paths",), tuple(Paths.__annotations__))
|
||||
unknown += _unknown_in(data, ("ocr",), tuple(OcrConfig.__annotations__))
|
||||
unknown += _unknown_in(data, ("output",), tuple(OutputConfig.__annotations__))
|
||||
unknown += _unknown_in(data, ("verapdf",), tuple(VeraPdfConfig.__annotations__))
|
||||
for sub in _section(data, "upload"):
|
||||
if sub not in _KNOWN_UPLOAD_TARGETS:
|
||||
unknown.append(f"[upload.{sub}]")
|
||||
unknown += _unknown_in(data, ("upload", "folder"), tuple(FolderUpload.__annotations__))
|
||||
unknown += _unknown_in(data, ("upload", "nextcloud"), tuple(NextcloudUpload.__annotations__))
|
||||
unknown += _unknown_in(data, ("upload", "sftp"), tuple(SftpUpload.__annotations__))
|
||||
for sub in _section(data, "notify"):
|
||||
if sub not in _KNOWN_NOTIFY_TARGETS:
|
||||
unknown.append(f"[notify.{sub}]")
|
||||
unknown += _unknown_in(data, ("notify", "email"), tuple(EmailNotify.__annotations__))
|
||||
unknown += _unknown_in(data, ("logging",), _KNOWN_LOGGING_KEYS)
|
||||
return unknown
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> Config:
|
||||
path = Path(path)
|
||||
with path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
if not isinstance(data.get("paths"), dict):
|
||||
raise ConfigError(
|
||||
f"{path}: Die Sektion [paths] fehlt (oder ist keine Tabelle). "
|
||||
"Sie muss die Einträge incoming, outgoing, working und error "
|
||||
"enthalten — siehe config.example.toml."
|
||||
)
|
||||
|
||||
p = _section(data, "paths")
|
||||
paths = Paths(
|
||||
incoming=Path(p["incoming"]),
|
||||
outgoing=Path(p["outgoing"]),
|
||||
working=Path(p["working"]),
|
||||
error=Path(p["error"]),
|
||||
incoming=_require_path(p, "incoming", path),
|
||||
outgoing=_require_path(p, "outgoing", path),
|
||||
working=_require_path(p, "working", path),
|
||||
error=_require_path(p, "error", path),
|
||||
)
|
||||
|
||||
ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items()
|
||||
if k in OcrConfig.__annotations__})
|
||||
output = OutputConfig(**{k: v for k, v in _section(data, "output").items()
|
||||
if k in OutputConfig.__annotations__})
|
||||
verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items()
|
||||
if k in VeraPdfConfig.__annotations__})
|
||||
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
|
||||
@@ -123,7 +216,57 @@ def load_config(path: str | Path) -> Config:
|
||||
log_level = _section(data, "logging").get("level", "INFO")
|
||||
|
||||
return Config(
|
||||
paths=paths, ocr=ocr, verapdf=verapdf,
|
||||
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
|
||||
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
|
||||
log_level=log_level,
|
||||
unknown_keys=_collect_unknown_keys(data),
|
||||
)
|
||||
|
||||
|
||||
# ---- Legacy- und Plausibilitätswarnungen ----
|
||||
|
||||
# [ocr].timeout war vor 0.4.0 ein (wirkungsloses) Gesamt-Timeout mit Default
|
||||
# 1800. Ab diesem Wert gehen wir von einem Altwert aus.
|
||||
LEGACY_TIMEOUT_THRESHOLD = 900
|
||||
# Richtwert für das Seiten-Timeout seit 0.4.0
|
||||
RECOMMENDED_PAGE_TIMEOUT = 300
|
||||
|
||||
|
||||
def legacy_warnings(cfg: Config) -> list[str]:
|
||||
"""Warnt vor Einträgen, deren Bedeutung sich geändert hat.
|
||||
|
||||
Die Meldungen werden sowohl beim Dienststart ins Log geschrieben als auch
|
||||
von `--check-config` ausgegeben — deshalb steht der Text nur hier.
|
||||
"""
|
||||
out: list[str] = []
|
||||
if cfg.ocr.timeout >= LEGACY_TIMEOUT_THRESHOLD:
|
||||
out.append(
|
||||
f"[ocr].timeout = {cfg.ocr.timeout}: Seit Version 0.4.0 sind das "
|
||||
"Sekunden PRO SEITE (vorher ein wirkungsloses Gesamt-Timeout mit "
|
||||
f"Default 1800). Ein Wert >= {LEGACY_TIMEOUT_THRESHOLD} stammt fast "
|
||||
"sicher aus einer alten Config und lässt eine einzelne Seite "
|
||||
f"unnötig lange laufen. Richtwert: {RECOMMENDED_PAGE_TIMEOUT}."
|
||||
)
|
||||
if cfg.ocr.pdfa_level:
|
||||
out.append(
|
||||
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
|
||||
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
|
||||
"Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der "
|
||||
"Preflight bricht ab, falls die installierte Ghostscript-Version "
|
||||
"betroffen ist; ab 10.02.1 ist alles in Ordnung."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def unknown_key_warnings(cfg: Config) -> list[str]:
|
||||
"""Macht die beim Laden verworfenen Einträge sichtbar."""
|
||||
return [
|
||||
f"Unbekannter Config-Eintrag {key} — wird ignoriert (Tippfehler oder "
|
||||
"Option aus einer älteren Version?)"
|
||||
for key in cfg.unknown_keys
|
||||
]
|
||||
|
||||
|
||||
def config_warnings(cfg: Config) -> list[str]:
|
||||
"""Alle Warnungen zu einer geladenen Config (Legacy + unbekannte Keys)."""
|
||||
return legacy_warnings(cfg) + unknown_key_warnings(cfg)
|
||||
|
||||
@@ -7,12 +7,43 @@ import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import ocrmypdf
|
||||
|
||||
from .config import OcrConfig, VeraPdfConfig
|
||||
from .config import OcrConfig, OutputConfig, VeraPdfConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
|
||||
VALID_NAME_MODES = ("prefix", "suffix", "none")
|
||||
|
||||
# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem
|
||||
# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment.
|
||||
OCR_TEMP_PREFIX = "__ocr_"
|
||||
|
||||
|
||||
def build_output_name(src_name: str, mode: str, tag: str) -> str:
|
||||
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
|
||||
|
||||
Args:
|
||||
src_name: Original-Dateiname (z.B. "scan.pdf")
|
||||
mode: "prefix" | "suffix" | "none"
|
||||
tag: Einzufügender String (verbatim, leer = kein Tag)
|
||||
|
||||
Beispiele:
|
||||
prefix "OCR_": "scan.pdf" -> "OCR_scan.pdf"
|
||||
suffix "_OCR": "scan.pdf" -> "scan_OCR.pdf"
|
||||
suffix "_OCR": "scan.tar.gz.pdf" -> "scan.tar.gz_OCR.pdf"
|
||||
none: "scan.pdf" -> "scan.pdf"
|
||||
"""
|
||||
if mode == "none" or not tag:
|
||||
return src_name
|
||||
if mode == "prefix":
|
||||
return f"{tag}{src_name}"
|
||||
if mode == "suffix":
|
||||
# Nur die letzte Extension abspalten, sonst "foo.bar.pdf" kaputt gemacht
|
||||
p = Path(src_name)
|
||||
stem, ext = p.stem, p.suffix
|
||||
return f"{stem}{tag}{ext}"
|
||||
raise ValueError(f"Unbekannter name_mode: {mode!r}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResult:
|
||||
@@ -25,6 +56,8 @@ class ProcessResult:
|
||||
|
||||
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||
"""Führt ocrmypdf als Library-Call aus (kein Subprozess-Overhead)."""
|
||||
import ocrmypdf # lazy, damit Tests ohne ocrmypdf laufen
|
||||
|
||||
kwargs: dict = {
|
||||
"language": cfg.languages,
|
||||
"jobs": cfg.jobs,
|
||||
@@ -39,6 +72,15 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||
else:
|
||||
kwargs["output_type"] = "pdf"
|
||||
|
||||
# [ocr].timeout = max. Sekunden, die Tesseract pro Seite laufen darf.
|
||||
# ocrmypdf kennt kein Gesamt-Timeout für ein Dokument, nur `tesseract_timeout`
|
||||
# (pro Seite). ACHTUNG: ocrmypdf interpretiert tesseract_timeout=0 als
|
||||
# "OCR komplett überspringen" — deshalb wird 0 bei uns als "kein eigenes
|
||||
# Limit" behandelt und gar nicht erst durchgereicht (dann gilt der
|
||||
# ocrmypdf-Default).
|
||||
if cfg.timeout and cfg.timeout > 0:
|
||||
kwargs["tesseract_timeout"] = float(cfg.timeout)
|
||||
|
||||
log.info("OCR start: %s", src.name)
|
||||
ocrmypdf.ocr(str(src), str(dst), **kwargs)
|
||||
log.info("OCR done: %s", dst.name)
|
||||
@@ -71,12 +113,30 @@ def process_pdf(
|
||||
error_dir: Path,
|
||||
ocr_cfg: OcrConfig,
|
||||
vera_cfg: VeraPdfConfig,
|
||||
output_cfg: OutputConfig,
|
||||
) -> ProcessResult:
|
||||
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
|
||||
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
|
||||
work_src = working_dir / src.name
|
||||
work_out = working_dir / f"OCR_{src.name}"
|
||||
final_out = outgoing_dir / f"OCR_{src.name}"
|
||||
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
|
||||
final_out = outgoing_dir / out_name
|
||||
|
||||
if _is_same_file(src, work_src):
|
||||
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
|
||||
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
|
||||
# die Datei bestenfalls auf sich selbst schieben.
|
||||
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
|
||||
working_dir, src.name)
|
||||
elif work_src.exists():
|
||||
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
|
||||
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
|
||||
return ProcessResult(
|
||||
src, final_out, False,
|
||||
f"in {working_dir} liegt bereits eine andere Datei namens "
|
||||
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
|
||||
"beim nächsten Lauf erneut versucht",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
shutil.move(str(src), str(work_src))
|
||||
except OSError as e:
|
||||
@@ -93,17 +153,66 @@ def process_pdf(
|
||||
if vera_cfg.enabled:
|
||||
vera_ok = run_verapdf(work_out, vera_cfg)
|
||||
if not vera_ok:
|
||||
# Das OCR-Ergebnis ist unbrauchbar und wandert nach error/. Das
|
||||
# Original wird aber NICHT bedingungslos gelöscht: es folgt derselben
|
||||
# [output].original_on_success-Regel wie im Erfolgsfall, sonst
|
||||
# verliert man es ausgerechnet im Fehlerfall (archive!).
|
||||
_move_to_error(work_out, error_dir)
|
||||
work_src.unlink(missing_ok=True)
|
||||
_dispose_original(work_src, src.name, output_cfg)
|
||||
log.error(
|
||||
"veraPDF FAIL: %s — OCR-Ergebnis nach %s verschoben, Original %s",
|
||||
src.name, error_dir,
|
||||
"archiviert" if output_cfg.original_on_success == "archive" else "gelöscht",
|
||||
)
|
||||
return ProcessResult(src, final_out, False,
|
||||
"verapdf validation failed", verapdf_passed=False)
|
||||
|
||||
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(work_out), str(final_out))
|
||||
work_src.unlink(missing_ok=True)
|
||||
_dispose_original(work_src, src.name, output_cfg)
|
||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
|
||||
|
||||
|
||||
def _is_same_file(a: Path, b: Path) -> bool:
|
||||
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
|
||||
try:
|
||||
return a.resolve() == b.resolve()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
|
||||
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
|
||||
|
||||
Wird nach erfolgreichem OCR aufgerufen und ebenso, wenn veraPDF die
|
||||
Validierung ablehnt: auch dann soll `archive` das Original erhalten.
|
||||
"""
|
||||
if not work_src.exists():
|
||||
return
|
||||
mode = cfg.original_on_success
|
||||
if mode == "delete":
|
||||
work_src.unlink(missing_ok=True)
|
||||
return
|
||||
if mode == "archive":
|
||||
if not cfg.archive_dir:
|
||||
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
|
||||
work_src.unlink(missing_ok=True)
|
||||
return
|
||||
archive = Path(cfg.archive_dir)
|
||||
archive.mkdir(parents=True, exist_ok=True)
|
||||
dest = archive / original_name
|
||||
# Bei Namens-Kollision mit Timestamp umbenennen
|
||||
if dest.exists():
|
||||
from datetime import datetime
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
|
||||
shutil.move(str(work_src), str(dest))
|
||||
log.info("Original archiviert: %s", dest)
|
||||
return
|
||||
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
||||
work_src.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _move_to_error(p: Path, error_dir: Path) -> None:
|
||||
error_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
|
||||
+317
-14
@@ -2,22 +2,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from .config import Config
|
||||
from .processor import ProcessResult, process_pdf
|
||||
from .processor import (
|
||||
OCR_TEMP_PREFIX,
|
||||
VALID_NAME_MODES,
|
||||
ProcessResult,
|
||||
_move_to_error,
|
||||
process_pdf,
|
||||
)
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PreflightError(RuntimeError):
|
||||
"""Erforderliche externe Binaries fehlen."""
|
||||
|
||||
|
||||
# Pflicht-Binaries für ocrmypdf
|
||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
||||
|
||||
# Ghostscript-Versionen mit bekanntem PDF/A+skip_text Bug (Issue #3):
|
||||
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
|
||||
_GS_BROKEN_MIN = (10, 0, 0)
|
||||
_GS_BROKEN_MAX = (10, 2, 0)
|
||||
|
||||
|
||||
def _parse_version(text: str) -> tuple[int, ...] | None:
|
||||
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
|
||||
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
|
||||
if not m:
|
||||
return None
|
||||
return tuple(int(x) if x is not None else 0 for x in m.groups())
|
||||
|
||||
|
||||
def is_ghostscript_broken(version: str | None) -> bool:
|
||||
"""Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist.
|
||||
|
||||
Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher.
|
||||
"""
|
||||
if not version:
|
||||
return False
|
||||
parsed = _parse_version(version)
|
||||
if parsed is None:
|
||||
return False
|
||||
# Auf 3-Tupel normalisieren
|
||||
while len(parsed) < 3:
|
||||
parsed = parsed + (0,)
|
||||
parsed = parsed[:3]
|
||||
return _GS_BROKEN_MIN <= parsed <= _GS_BROKEN_MAX
|
||||
|
||||
|
||||
def detect_ghostscript_version() -> str | None:
|
||||
"""Ruft `gs --version` auf und gibt den Versionsstring zurück (oder None)."""
|
||||
gs = shutil.which("gs")
|
||||
if gs is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run([gs, "--version"], capture_output=True,
|
||||
text=True, timeout=5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def check_output_config(mode: str, archive_dir: str,
|
||||
name_mode: str = "prefix") -> None:
|
||||
"""Validiert die [output]-Section. Wirft PreflightError bei Problemen."""
|
||||
valid_modes = {"delete", "archive"}
|
||||
if mode not in valid_modes:
|
||||
raise PreflightError(
|
||||
f"[output].original_on_success={mode!r} ungültig. "
|
||||
f"Erlaubt: {sorted(valid_modes)}"
|
||||
)
|
||||
if mode == "archive" and not archive_dir:
|
||||
raise PreflightError(
|
||||
"[output].original_on_success='archive' erfordert [output].archive_dir"
|
||||
)
|
||||
# Früh prüfen: sonst schlägt ein Tippfehler erst pro Datei zu — und zwar
|
||||
# NACH dem Move nach working/, wo die Datei dann liegen bleibt.
|
||||
if name_mode not in VALID_NAME_MODES:
|
||||
raise PreflightError(
|
||||
f"[output].name_mode={name_mode!r} ungültig. "
|
||||
f"Erlaubt: {sorted(VALID_NAME_MODES)}"
|
||||
)
|
||||
|
||||
|
||||
def check_preflight(pdfa_level: str = "") -> None:
|
||||
"""Prüft externe Abhängigkeiten.
|
||||
|
||||
- Tesseract und Ghostscript müssen im PATH sein
|
||||
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
|
||||
bekannten 10.0.0–10.02.0 Bug geprüft
|
||||
|
||||
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
|
||||
"""
|
||||
missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None]
|
||||
if missing:
|
||||
raise PreflightError(
|
||||
"Fehlende Abhängigkeiten: " + ", ".join(missing)
|
||||
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
|
||||
)
|
||||
|
||||
if pdfa_level:
|
||||
gs_version = detect_ghostscript_version()
|
||||
if is_ghostscript_broken(gs_version):
|
||||
raise PreflightError(
|
||||
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
|
||||
"kompatibel (bekannter Bug in 10.0.0–10.02.0). "
|
||||
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
|
||||
"oder in der Config [ocr].pdfa_level = \"\" setzen."
|
||||
)
|
||||
|
||||
|
||||
def _is_pdf(path: Path) -> bool:
|
||||
return path.suffix.lower() == ".pdf" and path.is_file()
|
||||
|
||||
@@ -70,10 +180,20 @@ class HotfolderService:
|
||||
self._stop = threading.Event()
|
||||
self._inflight: set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
self._success_count = 0
|
||||
self._error_count = 0
|
||||
|
||||
@property
|
||||
def success_count(self) -> int:
|
||||
return self._success_count
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
return self._error_count
|
||||
|
||||
# ---- Setup ----
|
||||
|
||||
def _ensure_dirs(self) -> None:
|
||||
def ensure_dirs(self) -> None:
|
||||
for p in (self.cfg.paths.incoming, self.cfg.paths.outgoing,
|
||||
self.cfg.paths.working, self.cfg.paths.error):
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
@@ -81,7 +201,11 @@ class HotfolderService:
|
||||
# ---- Lifecycle ----
|
||||
|
||||
def run(self) -> None:
|
||||
self._ensure_dirs()
|
||||
check_preflight(self.cfg.ocr.pdfa_level)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
|
||||
self._observer = Observer()
|
||||
@@ -98,6 +222,23 @@ class HotfolderService:
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
def run_once(self) -> int:
|
||||
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||
|
||||
Returns:
|
||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||
"""
|
||||
check_preflight(self.cfg.ocr.pdfa_level)
|
||||
check_output_config(self.cfg.output.original_on_success,
|
||||
self.cfg.output.archive_dir,
|
||||
self.cfg.output.name_mode)
|
||||
self.ensure_dirs()
|
||||
self._scan_existing()
|
||||
self._executor.shutdown(wait=True)
|
||||
log.info("One-shot fertig: %d ok, %d Fehler",
|
||||
self._success_count, self._error_count)
|
||||
return self._error_count
|
||||
|
||||
def shutdown(self) -> None:
|
||||
log.info("Shutdown läuft...")
|
||||
if self._observer:
|
||||
@@ -109,11 +250,84 @@ class HotfolderService:
|
||||
# ---- Queue ----
|
||||
|
||||
def _scan_existing(self) -> None:
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen."""
|
||||
for p in self.cfg.paths.incoming.iterdir():
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen.
|
||||
|
||||
Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach
|
||||
incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision
|
||||
zwischen beiden Verzeichnissen aufgelöst ist, bevor die
|
||||
incoming-Datei nach working/ will.
|
||||
"""
|
||||
self._scan_working()
|
||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||
if _is_pdf(p):
|
||||
self.enqueue(p)
|
||||
|
||||
def _scan_working(self) -> None:
|
||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||
|
||||
`process_pdf()` verschiebt das Original vor dem OCR nach working/.
|
||||
Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec),
|
||||
bleibt es liegen und wurde bisher nie wieder angefasst — stiller
|
||||
Datenverlust. Die Datei wird deshalb an Ort und Stelle
|
||||
wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie
|
||||
nicht erneut.
|
||||
|
||||
Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`)
|
||||
sind unvollständige Fragmente: als Eingabe unbrauchbar und als
|
||||
Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein
|
||||
fertiges PDF hält und damit der neue Lauf sauber startet.
|
||||
"""
|
||||
working = self.cfg.paths.working
|
||||
if not working.is_dir():
|
||||
return
|
||||
for p in sorted(working.iterdir()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name.startswith(OCR_TEMP_PREFIX):
|
||||
log.warning(
|
||||
"Unvollständiges OCR-Fragment aus abgebrochenem Lauf "
|
||||
"gefunden und gelöscht: %s", p,
|
||||
)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
log.exception("Konnte OCR-Fragment %s nicht löschen", p)
|
||||
continue
|
||||
if not _is_pdf(p):
|
||||
continue
|
||||
target = self._free_resume_name(p)
|
||||
log.warning(
|
||||
"Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s "
|
||||
"(Dienst wurde vermutlich hart gestoppt) — OCR startet neu",
|
||||
target.name, working,
|
||||
)
|
||||
self.enqueue(target)
|
||||
|
||||
def _free_resume_name(self, p: Path) -> Path:
|
||||
"""Entschärft eine Namenskollision zwischen working/ und incoming/.
|
||||
|
||||
Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide
|
||||
dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die
|
||||
wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt —
|
||||
dann laufen beide durch, statt dass eine überschrieben wird.
|
||||
"""
|
||||
if not (self.cfg.paths.incoming / p.name).exists():
|
||||
return p
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}")
|
||||
try:
|
||||
p.rename(renamed)
|
||||
except OSError:
|
||||
log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter "
|
||||
"Originalnamen", p)
|
||||
return p
|
||||
log.warning(
|
||||
"In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene "
|
||||
"Datei wurde nach %s umbenannt, damit sich beide nicht "
|
||||
"überschreiben", self.cfg.paths.incoming, p.name, renamed.name,
|
||||
)
|
||||
return renamed
|
||||
|
||||
def enqueue(self, path: Path) -> None:
|
||||
if not _is_pdf(path):
|
||||
return
|
||||
@@ -134,13 +348,33 @@ class HotfolderService:
|
||||
|
||||
# ---- Processing ----
|
||||
|
||||
def _count_success(self) -> None:
|
||||
with self._lock:
|
||||
self._success_count += 1
|
||||
|
||||
def _count_error(self) -> None:
|
||||
with self._lock:
|
||||
self._error_count += 1
|
||||
|
||||
def _process(self, path: Path) -> None:
|
||||
if not _wait_until_stable(path):
|
||||
log.warning("Datei nicht stabilisiert, überspringe: %s", path)
|
||||
if not path.exists():
|
||||
# Datei wurde währenddessen entfernt — kein Fehlerfall
|
||||
log.info("Datei vor der Verarbeitung verschwunden: %s", path)
|
||||
return
|
||||
# Bewusst als Fehler zählen: sonst liefert --once trotz liegen
|
||||
# gebliebener Datei Exit 0.
|
||||
log.error(
|
||||
"Datei hat sich nicht stabilisiert (Timeout): %s — bleibt in %s "
|
||||
"liegen und wird beim nächsten Lauf erneut versucht",
|
||||
path, self.cfg.paths.incoming,
|
||||
)
|
||||
self._count_error()
|
||||
return
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
result: ProcessResult = process_pdf(
|
||||
src=path,
|
||||
working_dir=self.cfg.paths.working,
|
||||
@@ -148,18 +382,87 @@ class HotfolderService:
|
||||
error_dir=self.cfg.paths.error,
|
||||
ocr_cfg=self.cfg.ocr,
|
||||
vera_cfg=self.cfg.verapdf,
|
||||
output_cfg=self.cfg.output,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - kein Fehler darf die Zählung umgehen
|
||||
log.exception("Unerwarteter Fehler bei der Verarbeitung von %s", path.name)
|
||||
self._count_error()
|
||||
self._rescue_to_error(path)
|
||||
self._notify(ProcessResult(
|
||||
path, self.cfg.paths.outgoing / path.name, False,
|
||||
f"unerwarteter Fehler: {e}",
|
||||
))
|
||||
return
|
||||
|
||||
if result.success:
|
||||
self._dispatch_uploads(result.output)
|
||||
if not result.success:
|
||||
self._count_error()
|
||||
self._notify(result)
|
||||
return
|
||||
|
||||
failed = self._dispatch_uploads(result.output)
|
||||
if failed:
|
||||
log.error(
|
||||
"Upload fehlgeschlagen (%s) für %s — das OCR selbst war "
|
||||
"erfolgreich, die Datei bleibt daher in %s liegen und wird "
|
||||
"NICHT nach error/ verschoben",
|
||||
", ".join(failed), result.output.name, result.output.parent,
|
||||
)
|
||||
self._count_error()
|
||||
self._notify_upload_failure(result, failed)
|
||||
return
|
||||
|
||||
self._count_success()
|
||||
self._notify(result)
|
||||
|
||||
def _dispatch_uploads(self, pdf: Path) -> None:
|
||||
upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing)
|
||||
if self.cfg.nextcloud.enabled:
|
||||
upload_nextcloud(pdf, self.cfg.nextcloud)
|
||||
if self.cfg.sftp.enabled:
|
||||
upload_sftp(pdf, self.cfg.sftp)
|
||||
def _rescue_to_error(self, src: Path) -> None:
|
||||
"""Bringt eine Datei nach einer unerwarteten Exception ins error-Verzeichnis.
|
||||
|
||||
Die Datei kann je nach Abbruchzeitpunkt noch in incoming/ oder schon in
|
||||
working/ liegen. Der erste Treffer wird verschoben (keine Doppel-Moves),
|
||||
Fehler beim Verschieben werden nur geloggt.
|
||||
"""
|
||||
error_dir = self.cfg.paths.error
|
||||
for candidate in (src, self.cfg.paths.working / src.name):
|
||||
try:
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
if candidate.parent.resolve() == error_dir.resolve():
|
||||
return # liegt bereits im error-Verzeichnis
|
||||
except OSError:
|
||||
continue
|
||||
_move_to_error(candidate, error_dir)
|
||||
return
|
||||
log.warning("Datei %s nach Fehler nicht mehr auffindbar — "
|
||||
"kein Verschieben nach error/ möglich", src.name)
|
||||
|
||||
def _dispatch_uploads(self, pdf: Path) -> list[str]:
|
||||
"""Schiebt das fertige PDF an alle Upload-Ziele.
|
||||
|
||||
Die uploader prüfen `cfg.enabled` jeweils selbst und liefern für
|
||||
deaktivierte Ziele True.
|
||||
|
||||
Returns:
|
||||
Namen der fehlgeschlagenen Ziele — leere Liste = alle erfolgreich.
|
||||
"""
|
||||
failed: list[str] = []
|
||||
if not upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing):
|
||||
failed.append("folder")
|
||||
if not upload_nextcloud(pdf, self.cfg.nextcloud):
|
||||
failed.append("nextcloud")
|
||||
if not upload_sftp(pdf, self.cfg.sftp):
|
||||
failed.append("sftp")
|
||||
return failed
|
||||
|
||||
def _notify_upload_failure(self, result: ProcessResult, failed: list[str]) -> None:
|
||||
"""Fehler-Mail, wenn das OCR lief, aber mindestens ein Upload scheiterte."""
|
||||
subject = f"[pdf-ocr] FEHLER Upload: {result.source.name}"
|
||||
body = (
|
||||
f"OCR erfolgreich: {result.output}\n\n"
|
||||
f"Fehlgeschlagene Upload-Ziele: {', '.join(failed)}\n\n"
|
||||
f"Das OCR-PDF bleibt in {result.output.parent} liegen und wurde "
|
||||
"NICHT nach error/ verschoben. Details siehe Log.\n"
|
||||
)
|
||||
notify_email(self.cfg.email, subject, body, False)
|
||||
|
||||
def _notify(self, result: ProcessResult) -> None:
|
||||
if result.success:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
@@ -25,7 +26,9 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
|
||||
try:
|
||||
if pdf.resolve() == dest.resolve():
|
||||
return True
|
||||
dest.write_bytes(pdf.read_bytes())
|
||||
# copyfile statt read_bytes/write_bytes: große PDFs nicht komplett
|
||||
# in den Speicher laden
|
||||
shutil.copyfile(pdf, dest)
|
||||
log.info("Folder upload OK: %s", dest)
|
||||
return True
|
||||
except OSError as e:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
+9
-4
@@ -1,4 +1,9 @@
|
||||
ocrmypdf>=16.0
|
||||
watchdog>=4.0
|
||||
requests>=2.31
|
||||
paramiko>=3.4
|
||||
# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
|
||||
# (ein ocrmypdf 16 -> 17 reisst sonst alle Instanzen auf einmal).
|
||||
# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
|
||||
# gibt es fertige Wheels, es wird nichts kompiliert.
|
||||
# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren.
|
||||
ocrmypdf==16.13.0
|
||||
watchdog==6.0.0
|
||||
requests==2.33.1
|
||||
paramiko==4.0.0
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Drop-in für LXC/Container-Betrieb
|
||||
# Kopieren nach: /etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf
|
||||
# Danach: systemctl daemon-reload && systemctl restart 'pdf-ocr-hotfolder@*'
|
||||
|
||||
[Service]
|
||||
PrivateTmp=false
|
||||
ProtectSystem=false
|
||||
ProtectKernelTunables=false
|
||||
ProtectKernelModules=false
|
||||
ProtectControlGroups=false
|
||||
@@ -7,11 +7,15 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=pdfocr
|
||||
Group=pdfocr
|
||||
WorkingDirectory=/opt/pdf-ocr-hotfolder
|
||||
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=30
|
||||
# Ein laufendes OCR soll beim Stoppen zu Ende laufen duerfen. Bei SIGKILL
|
||||
# bliebe das Original in working/ liegen (wird beim naechsten Start zwar
|
||||
# wiederaufgenommen, kostet aber den kompletten Durchlauf).
|
||||
TimeoutStopSec=300
|
||||
|
||||
# Hardening (lockerer wegen AD-User & Datei-ACLs)
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Gemeinsame pytest-Fixtures."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.config import (
|
||||
Config,
|
||||
EmailNotify,
|
||||
FolderUpload,
|
||||
NextcloudUpload,
|
||||
OcrConfig,
|
||||
OutputConfig,
|
||||
Paths,
|
||||
SftpUpload,
|
||||
VeraPdfConfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config(tmp_path: Path) -> Config:
|
||||
"""Minimal-Config mit tmp_path-Verzeichnissen, alle Uploads deaktiviert."""
|
||||
paths = Paths(
|
||||
incoming=tmp_path / "incoming",
|
||||
outgoing=tmp_path / "outgoing",
|
||||
working=tmp_path / "working",
|
||||
error=tmp_path / "error",
|
||||
)
|
||||
for p in (paths.incoming, paths.outgoing, paths.working, paths.error):
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return Config(
|
||||
paths=paths,
|
||||
ocr=OcrConfig(max_workers=1),
|
||||
output=OutputConfig(),
|
||||
verapdf=VeraPdfConfig(enabled=False),
|
||||
folder=FolderUpload(enabled=False),
|
||||
nextcloud=NextcloudUpload(enabled=False),
|
||||
sftp=SftpUpload(enabled=False),
|
||||
email=EmailNotify(enabled=False),
|
||||
log_level="DEBUG",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_pdf(tmp_config: Config) -> Path:
|
||||
"""Legt eine Datei mit .pdf-Extension im incoming-Ordner ab.
|
||||
|
||||
Achtung: kein echtes PDF. Für Tests wird `process_pdf` gemockt.
|
||||
"""
|
||||
pdf = tmp_config.paths.incoming / "test.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4 fake\n")
|
||||
return pdf
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests für `--check-config`.
|
||||
|
||||
Die Exit-Codes werden vom Updater ausgewertet und müssen verlässlich sein:
|
||||
0 = sauber, 1 = nur Warnungen, 2 = Fehler.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, CHECK_OK, CHECK_WARN, main
|
||||
|
||||
|
||||
def _cfg_file(tmp_path: Path, tmp_config, extra: str = "") -> Path:
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""" + extra)
|
||||
return cfg_file
|
||||
|
||||
|
||||
def _check(monkeypatch, cfg_file: Path, binaries_present: bool = True) -> int:
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||
"--check-config"])
|
||||
which = "/usr/bin/fake" if binaries_present else None
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=which):
|
||||
return main()
|
||||
|
||||
|
||||
# ---------------- Exit 0 ----------------
|
||||
|
||||
def test_clean_config_returns_0(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config))
|
||||
assert rc == CHECK_OK
|
||||
out = capsys.readouterr().out
|
||||
assert "Config sauber" in out
|
||||
|
||||
|
||||
def test_check_does_not_process_files(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""Der Check darf nichts verarbeiten und nichts verschieben."""
|
||||
pdf = tmp_config.paths.incoming / "scan.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
assert _check(monkeypatch, _cfg_file(tmp_path, tmp_config)) == CHECK_OK
|
||||
assert pdf.exists()
|
||||
assert list(tmp_config.paths.outgoing.iterdir()) == []
|
||||
|
||||
|
||||
# ---------------- Exit 1 (nur Warnungen) ----------------
|
||||
|
||||
def test_legacy_timeout_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
"\n[ocr]\ntimeout = 1800\n"))
|
||||
assert rc == CHECK_WARN
|
||||
out = capsys.readouterr().out
|
||||
assert "WARNUNG" in out
|
||||
assert "PRO SEITE" in out
|
||||
|
||||
|
||||
def test_unknown_key_returns_1(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\nlangauges = "deu"\n'))
|
||||
assert rc == CHECK_WARN
|
||||
assert "[ocr].langauges" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pdfa_level_with_healthy_ghostscript_returns_1(
|
||||
tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
"""Gesundes Ghostscript: nur Hinweis (Exit 1), kein Abbruch."""
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(_cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\npdfa_level = "2"\n')),
|
||||
"--check-config"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||
return_value="10.02.1"):
|
||||
rc = main()
|
||||
assert rc == CHECK_WARN
|
||||
assert "Ghostscript" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------- Exit 2 (Fehler) ----------------
|
||||
|
||||
def test_missing_config_file_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(tmp_path / "gibtsnicht.toml"), "--check-config"])
|
||||
assert main() == CHECK_ERROR
|
||||
assert "nicht gefunden" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_broken_paths_section_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text('[ocr]\nlanguages = "deu"\n')
|
||||
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
|
||||
assert "[paths]" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_invalid_toml_returns_2(tmp_path, monkeypatch, capsys) -> None:
|
||||
"""Kaputtes TOML: saubere Meldung statt Traceback."""
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text("[paths\nincoming = ")
|
||||
assert _check(monkeypatch, cfg_file) == CHECK_ERROR
|
||||
assert "TOML" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_binaries_return_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config),
|
||||
binaries_present=False)
|
||||
assert rc == CHECK_ERROR
|
||||
err = capsys.readouterr().err
|
||||
assert "tesseract" in err
|
||||
|
||||
|
||||
def test_invalid_name_mode_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[output]\nname_mode = "praefix"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
assert "name_mode" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_archive_without_dir_returns_2(tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||
rc = _check(monkeypatch, _cfg_file(tmp_path, tmp_config,
|
||||
'\n[output]\noriginal_on_success = "archive"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
assert "archive_dir" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_error_beats_warning(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""Fehler + Warnung → Exit 2, nicht 1."""
|
||||
rc = _check(monkeypatch,
|
||||
_cfg_file(tmp_path, tmp_config,
|
||||
'\n[ocr]\ntimeout = 1800\n\n[output]\nname_mode = "x"\n'))
|
||||
assert rc == CHECK_ERROR
|
||||
|
||||
|
||||
# ---------------- Zusammenspiel mit anderen Optionen ----------------
|
||||
|
||||
def test_check_config_wins_over_once(tmp_path, tmp_config, monkeypatch) -> None:
|
||||
"""--check-config hat Vorrang: es wird nichts verarbeitet."""
|
||||
pdf = tmp_config.paths.incoming / "scan.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config",
|
||||
str(_cfg_file(tmp_path, tmp_config)),
|
||||
"--once", "--check-config"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||
assert main() == CHECK_OK
|
||||
assert pdf.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,expected", [(CHECK_OK, 0), (CHECK_WARN, 1),
|
||||
(CHECK_ERROR, 2)])
|
||||
def test_exit_code_constants(code: int, expected: int) -> None:
|
||||
"""Die Konstanten sind Teil der Schnittstelle zum Updater."""
|
||||
assert code == expected
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests für verständliche Fehlermeldungen beim Laden der Config."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.config import ConfigError, load_config
|
||||
|
||||
_FULL_PATHS = """
|
||||
[paths]
|
||||
incoming = "/tmp/in"
|
||||
outgoing = "/tmp/out"
|
||||
working = "/tmp/work"
|
||||
error = "/tmp/err"
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path: Path, content: str) -> Path:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(content)
|
||||
return cfg
|
||||
|
||||
|
||||
def test_missing_paths_section(tmp_path: Path) -> None:
|
||||
"""Fehlt [paths] komplett → ConfigError statt KeyError."""
|
||||
cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n')
|
||||
with pytest.raises(ConfigError) as exc:
|
||||
load_config(cfg)
|
||||
msg = str(exc.value)
|
||||
assert "[paths]" in msg
|
||||
assert str(cfg) in msg
|
||||
|
||||
|
||||
def test_empty_config_file(tmp_path: Path) -> None:
|
||||
cfg = _write(tmp_path, "")
|
||||
with pytest.raises(ConfigError, match=r"\[paths\]"):
|
||||
load_config(cfg)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["incoming", "outgoing", "working", "error"])
|
||||
def test_missing_single_path_key(tmp_path: Path, missing: str) -> None:
|
||||
"""Fehlt ein einzelner Key, wird genau dieser genannt."""
|
||||
lines = [line for line in _FULL_PATHS.strip().splitlines()
|
||||
if not line.startswith(missing)]
|
||||
cfg = _write(tmp_path, "\n".join(lines) + "\n")
|
||||
with pytest.raises(ConfigError) as exc:
|
||||
load_config(cfg)
|
||||
msg = str(exc.value)
|
||||
assert missing in msg
|
||||
assert str(cfg) in msg
|
||||
|
||||
|
||||
def test_empty_path_value_is_rejected(tmp_path: Path) -> None:
|
||||
"""Ein leerer Pfad ist genauso falsch wie ein fehlender."""
|
||||
cfg = _write(tmp_path, _FULL_PATHS.replace('working = "/tmp/work"',
|
||||
'working = ""'))
|
||||
with pytest.raises(ConfigError, match="working"):
|
||||
load_config(cfg)
|
||||
|
||||
|
||||
def test_complete_paths_section_loads(tmp_path: Path) -> None:
|
||||
cfg = _write(tmp_path, _FULL_PATHS)
|
||||
loaded = load_config(cfg)
|
||||
assert loaded.paths.incoming == Path("/tmp/in")
|
||||
assert loaded.paths.error == Path("/tmp/err")
|
||||
|
||||
|
||||
def test_main_returns_2_on_broken_config(tmp_path: Path, monkeypatch, capsys) -> None:
|
||||
"""CLI bricht sauber mit Exit-Code 2 ab — ohne Traceback."""
|
||||
cfg = _write(tmp_path, '[ocr]\nlanguages = "deu"\n')
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg), "--once"])
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
assert main() == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "FEHLER" in err
|
||||
assert "[paths]" in err
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests für Legacy-Warnungen und unbekannte Config-Keys.
|
||||
|
||||
Zwei stille Fallen:
|
||||
- [ocr].timeout bedeutet seit 0.4.0 Sekunden pro SEITE (vorher Gesamtlauf)
|
||||
- unbekannte Keys (Tippfehler!) wurden beim Laden stumm verworfen
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.config import (
|
||||
config_warnings,
|
||||
legacy_warnings,
|
||||
load_config,
|
||||
unknown_key_warnings,
|
||||
)
|
||||
|
||||
_PATHS = """
|
||||
[paths]
|
||||
incoming = "/tmp/in"
|
||||
outgoing = "/tmp/out"
|
||||
working = "/tmp/work"
|
||||
error = "/tmp/err"
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path: Path, extra: str = "") -> Path:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_PATHS + extra)
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------- Legacy: [ocr].timeout ----------------
|
||||
|
||||
def test_legacy_timeout_warns(tmp_path: Path) -> None:
|
||||
"""Ein Altwert (Gesamt-Timeout 1800) muss deutlich benannt werden."""
|
||||
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 1800\n"))
|
||||
warnings = legacy_warnings(cfg)
|
||||
assert len(warnings) == 1
|
||||
assert "timeout" in warnings[0]
|
||||
assert "PRO SEITE" in warnings[0]
|
||||
assert "300" in warnings[0]
|
||||
|
||||
|
||||
def test_timeout_at_threshold_warns(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 900\n"))
|
||||
assert legacy_warnings(cfg)
|
||||
|
||||
|
||||
def test_sane_timeout_does_not_warn(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, "\n[ocr]\ntimeout = 300\n"))
|
||||
assert legacy_warnings(cfg) == []
|
||||
|
||||
|
||||
def test_default_config_has_no_warnings(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path))
|
||||
assert config_warnings(cfg) == []
|
||||
|
||||
|
||||
# ---------------- Legacy: [ocr].pdfa_level ----------------
|
||||
|
||||
def test_pdfa_level_warns_about_ghostscript(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = "2"\n'))
|
||||
warnings = legacy_warnings(cfg)
|
||||
assert len(warnings) == 1
|
||||
assert "pdfa_level" in warnings[0]
|
||||
assert "Ghostscript" in warnings[0]
|
||||
assert "10.02.0" in warnings[0]
|
||||
|
||||
|
||||
def test_empty_pdfa_level_does_not_warn(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, '\n[ocr]\npdfa_level = ""\n'))
|
||||
assert legacy_warnings(cfg) == []
|
||||
|
||||
|
||||
def test_both_legacy_warnings_together(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, '\n[ocr]\ntimeout = 1800\npdfa_level = "1"\n'))
|
||||
assert len(legacy_warnings(cfg)) == 2
|
||||
|
||||
|
||||
# ---------------- Unbekannte Keys ----------------
|
||||
|
||||
def test_typo_key_is_collected(tmp_path: Path) -> None:
|
||||
"""`langauges` statt `languages` darf nicht mehr stumm verschwinden."""
|
||||
cfg = load_config(_write(tmp_path, '\n[ocr]\nlangauges = "deu"\n'))
|
||||
assert cfg.unknown_keys == ["[ocr].langauges"]
|
||||
assert "[ocr].langauges" in unknown_key_warnings(cfg)[0]
|
||||
# Der Rest wird weiterhin normal geladen
|
||||
assert cfg.ocr.languages == "deu+eng"
|
||||
|
||||
|
||||
def test_known_keys_are_not_reported(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, '\n[ocr]\nlanguages = "deu"\njobs = 2\n'))
|
||||
assert cfg.unknown_keys == []
|
||||
assert cfg.ocr.languages == "deu"
|
||||
|
||||
|
||||
def test_unknown_keys_in_all_sections(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, """
|
||||
[ocr]
|
||||
foo = 1
|
||||
|
||||
[output]
|
||||
bar = "x"
|
||||
|
||||
[verapdf]
|
||||
baz = true
|
||||
|
||||
[upload.folder]
|
||||
qux = ""
|
||||
|
||||
[upload.nextcloud]
|
||||
quux = ""
|
||||
|
||||
[upload.sftp]
|
||||
corge = 0
|
||||
|
||||
[notify.email]
|
||||
grault = ""
|
||||
|
||||
[logging]
|
||||
level = "INFO"
|
||||
garply = 1
|
||||
"""))
|
||||
assert cfg.unknown_keys == [
|
||||
"[ocr].foo", "[output].bar", "[verapdf].baz",
|
||||
"[upload.folder].qux", "[upload.nextcloud].quux", "[upload.sftp].corge",
|
||||
"[notify.email].grault", "[logging].garply",
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_top_level_key_is_reported(tmp_path: Path) -> None:
|
||||
"""Ein Key ausserhalb jeder Sektion (z.B. vergessene Sektionszeile)."""
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text('log_level = "DEBUG"\n' + _PATHS)
|
||||
cfg = load_config(cfg_file)
|
||||
assert cfg.unknown_keys == ["log_level"]
|
||||
|
||||
|
||||
def test_unknown_section_is_reported(tmp_path: Path) -> None:
|
||||
cfg = load_config(_write(tmp_path, '\n[ocrr]\nlanguages = "deu"\n\n[upload.ftp]\nhost = "x"\n'))
|
||||
assert "[ocrr]" in cfg.unknown_keys
|
||||
assert "[upload.ftp]" in cfg.unknown_keys
|
||||
|
||||
|
||||
def test_unknown_key_inside_paths(tmp_path: Path) -> None:
|
||||
"""Ein zusätzlicher Key direkt in [paths] wird ebenfalls gemeldet."""
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text(_PATHS + 'archive = "/tmp/a"\n')
|
||||
cfg = load_config(cfg_file)
|
||||
assert cfg.unknown_keys == ["[paths].archive"]
|
||||
|
||||
|
||||
def test_load_config_works_without_logging(tmp_path: Path) -> None:
|
||||
"""load_config() darf nichts loggen müssen — Tests rufen sie direkt auf."""
|
||||
cfg_file = _write(tmp_path, '\n[ocr]\nlangauges = "deu"\n')
|
||||
with patch("logging.Logger.warning") as warn:
|
||||
cfg = load_config(cfg_file)
|
||||
warn.assert_not_called()
|
||||
assert cfg.unknown_keys
|
||||
|
||||
|
||||
# ---------------- Warnungen beim Dienststart ----------------
|
||||
|
||||
def _argv(monkeypatch, cfg_file: Path, *extra: str) -> None:
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file), *extra])
|
||||
|
||||
|
||||
def test_warnings_are_logged_on_service_start(tmp_path: Path, tmp_config,
|
||||
monkeypatch, caplog) -> None:
|
||||
"""Beim normalen Start landen die Warnungen im Log (nicht nur im Check)."""
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
|
||||
[ocr]
|
||||
timeout = 1800
|
||||
langauges = "deu"
|
||||
""")
|
||||
_argv(monkeypatch, cfg_file, "--once")
|
||||
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.__main__"), \
|
||||
patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||
assert main() == 0
|
||||
|
||||
text = caplog.text
|
||||
assert "PRO SEITE" in text
|
||||
assert "[ocr].langauges" in text
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests für die Fehlerzählung im Service.
|
||||
|
||||
Deckt drei bisher stumme Fehlerpfade ab:
|
||||
- Exception aus `process_pdf()` (z.B. fehlgeschlagener Move nach outgoing/)
|
||||
- fehlgeschlagene Uploads
|
||||
- Timeout im Stabilitäts-Check
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.processor import ProcessResult
|
||||
from pdf_ocr_hotfolder.service import HotfolderService
|
||||
|
||||
|
||||
def _run_once(tmp_config, **patches):
|
||||
"""Führt run_once() mit gemocktem Preflight aus und gibt den Service zurück."""
|
||||
stack = [
|
||||
patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None),
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable",
|
||||
return_value=patches.pop("stable", True)),
|
||||
]
|
||||
for target, kwargs in patches.items():
|
||||
stack.append(patch(f"pdf_ocr_hotfolder.service.{target}", **kwargs))
|
||||
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
for p in stack:
|
||||
p.start()
|
||||
service.run_once()
|
||||
finally:
|
||||
for p in reversed(stack):
|
||||
p.stop()
|
||||
service._executor.shutdown(wait=False)
|
||||
return service
|
||||
|
||||
|
||||
# ---------------- Exception aus process_pdf ----------------
|
||||
|
||||
def test_exception_from_process_pdf_counts_as_error(tmp_config) -> None:
|
||||
"""Eine Exception aus process_pdf() darf die Zählung nicht umgehen."""
|
||||
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def explode(src, **kwargs):
|
||||
raise OSError("move to outgoing failed")
|
||||
|
||||
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
||||
|
||||
assert service.error_count == 1
|
||||
assert service.success_count == 0
|
||||
|
||||
|
||||
def test_exception_moves_file_to_error_dir(tmp_config) -> None:
|
||||
"""Die Datei landet nach einer Exception im error-Verzeichnis."""
|
||||
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def explode(src, **kwargs):
|
||||
raise RuntimeError("kaputt")
|
||||
|
||||
_run_once(tmp_config, process_pdf={"side_effect": explode})
|
||||
|
||||
assert (tmp_config.paths.error / "boom.pdf").exists()
|
||||
assert not (tmp_config.paths.incoming / "boom.pdf").exists()
|
||||
|
||||
|
||||
def test_exception_after_move_to_working_rescues_from_working(tmp_config) -> None:
|
||||
"""Realistischer Fall: process_pdf hat schon nach working/ verschoben."""
|
||||
src = tmp_config.paths.incoming / "boom.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def explode(src: Path, working_dir: Path, **kwargs):
|
||||
# process_pdf verschiebt zuerst nach working/, dann knallt der Move
|
||||
# nach outgoing/
|
||||
src.rename(working_dir / src.name)
|
||||
raise OSError("move to outgoing failed")
|
||||
|
||||
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
||||
|
||||
assert service.error_count == 1
|
||||
assert (tmp_config.paths.error / "boom.pdf").exists()
|
||||
assert not (tmp_config.paths.working / "boom.pdf").exists()
|
||||
|
||||
|
||||
def test_exception_with_vanished_file_does_not_raise(tmp_config) -> None:
|
||||
"""Ist die Datei nicht mehr auffindbar, wird nur geloggt — kein Crash."""
|
||||
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def explode(src: Path, **kwargs):
|
||||
src.unlink(missing_ok=True)
|
||||
raise RuntimeError("kaputt")
|
||||
|
||||
service = _run_once(tmp_config, process_pdf={"side_effect": explode})
|
||||
|
||||
assert service.error_count == 1
|
||||
assert not (tmp_config.paths.error / "boom.pdf").exists()
|
||||
|
||||
|
||||
def test_exception_triggers_error_notification(tmp_config) -> None:
|
||||
"""Auch bei einer Exception geht eine Fehler-Mail raus (success=False)."""
|
||||
(tmp_config.paths.incoming / "boom.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def explode(src, **kwargs):
|
||||
raise RuntimeError("kaputt")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
|
||||
_run_once(tmp_config, process_pdf={"side_effect": explode})
|
||||
|
||||
assert mail.call_count == 1
|
||||
args = mail.call_args[0]
|
||||
assert "FEHLER" in args[1]
|
||||
assert args[3] is False # success-Flag
|
||||
|
||||
|
||||
# ---------------- Upload-Fehler ----------------
|
||||
|
||||
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||
out = outgoing_dir / f"OCR_{src.name}"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(b"%PDF-1.4 ocr\n")
|
||||
src.unlink(missing_ok=True)
|
||||
return ProcessResult(src, out, True)
|
||||
|
||||
|
||||
def test_failed_upload_counts_as_error(tmp_config) -> None:
|
||||
"""Ein fehlgeschlagener Upload zählt als Fehler, nicht als Erfolg."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
service = _run_once(
|
||||
tmp_config,
|
||||
process_pdf={"side_effect": _fake_success},
|
||||
upload_nextcloud={"return_value": False},
|
||||
)
|
||||
|
||||
assert service.error_count == 1
|
||||
assert service.success_count == 0
|
||||
|
||||
|
||||
def test_failed_upload_sends_error_mail_naming_targets(tmp_config) -> None:
|
||||
"""Die Fehler-Mail nennt die fehlgeschlagenen Ziele."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
|
||||
_run_once(
|
||||
tmp_config,
|
||||
process_pdf={"side_effect": _fake_success},
|
||||
upload_nextcloud={"return_value": False},
|
||||
upload_sftp={"return_value": False},
|
||||
)
|
||||
|
||||
assert mail.call_count == 1
|
||||
_cfg, subject, body, success = mail.call_args[0]
|
||||
assert "FEHLER" in subject
|
||||
assert success is False
|
||||
assert "nextcloud" in body
|
||||
assert "sftp" in body
|
||||
|
||||
|
||||
def test_failed_upload_keeps_pdf_in_outgoing(tmp_config) -> None:
|
||||
"""Das OCR war erfolgreich — die Datei bleibt in outgoing/, nicht error/."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
_run_once(
|
||||
tmp_config,
|
||||
process_pdf={"side_effect": _fake_success},
|
||||
upload_folder={"return_value": False},
|
||||
)
|
||||
|
||||
assert (tmp_config.paths.outgoing / "OCR_a.pdf").exists()
|
||||
assert not (tmp_config.paths.error / "OCR_a.pdf").exists()
|
||||
|
||||
|
||||
def test_successful_uploads_count_as_success(tmp_config) -> None:
|
||||
"""Gegenprobe: wenn alle Uploads durchgehen, zählt es als Erfolg."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
service = _run_once(tmp_config, process_pdf={"side_effect": _fake_success})
|
||||
|
||||
assert service.success_count == 1
|
||||
assert service.error_count == 0
|
||||
|
||||
|
||||
def test_dispatch_uploads_reports_failed_targets(tmp_config) -> None:
|
||||
"""_dispatch_uploads() liefert die Namen der fehlgeschlagenen Ziele."""
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
pdf = tmp_config.paths.outgoing / "x.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
|
||||
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=False), \
|
||||
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
|
||||
assert service._dispatch_uploads(pdf) == ["nextcloud"]
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.upload_folder", return_value=True), \
|
||||
patch("pdf_ocr_hotfolder.service.upload_nextcloud", return_value=True), \
|
||||
patch("pdf_ocr_hotfolder.service.upload_sftp", return_value=True):
|
||||
assert service._dispatch_uploads(pdf) == []
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
# ---------------- Stabilitäts-Timeout ----------------
|
||||
|
||||
def test_unstable_file_counts_as_error(tmp_config) -> None:
|
||||
"""Stabilisiert sich eine Datei nicht, ist das ein Fehler (Exit 1)."""
|
||||
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf") as proc:
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
errors = service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
assert errors == 1
|
||||
assert service.error_count == 1
|
||||
proc.assert_not_called()
|
||||
|
||||
|
||||
def test_unstable_file_stays_in_incoming(tmp_config) -> None:
|
||||
"""Die instabile Datei bleibt bewusst in incoming/ liegen."""
|
||||
(tmp_config.paths.incoming / "slow.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=False):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
assert (tmp_config.paths.incoming / "slow.pdf").exists()
|
||||
assert not (tmp_config.paths.error / "slow.pdf").exists()
|
||||
|
||||
|
||||
def test_vanished_file_is_not_an_error(tmp_config) -> None:
|
||||
"""Verschwundene Datei ist kein Fehler — _wait_until_stable liefert dafür
|
||||
ebenfalls False."""
|
||||
pdf = tmp_config.paths.incoming / "weg.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def vanish(path: Path, **kwargs) -> bool:
|
||||
path.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", side_effect=vanish):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
errors = service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
assert errors == 0
|
||||
assert service.error_count == 0
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 PDF/A-Bug-Erkennung."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.service import (
|
||||
PreflightError,
|
||||
check_preflight,
|
||||
is_ghostscript_broken,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version,expected", [
|
||||
# Betroffene Versionen
|
||||
("10.0.0", True),
|
||||
("10.00.0", True),
|
||||
("10.01.0", True),
|
||||
("10.01.1", True),
|
||||
("10.01.2", True),
|
||||
("10.02.0", True),
|
||||
# Sichere Versionen
|
||||
("10.02.1", False),
|
||||
("10.03.0", False),
|
||||
("10.04.0", False),
|
||||
("11.0.0", False),
|
||||
("9.56.1", False), # Debian 11 / Ubuntu 22.04
|
||||
("9.55.0", False),
|
||||
# Edge cases
|
||||
("", False),
|
||||
(None, False),
|
||||
("garbage", False),
|
||||
])
|
||||
def test_is_ghostscript_broken(version, expected) -> None:
|
||||
assert is_ghostscript_broken(version) is expected
|
||||
|
||||
|
||||
def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None:
|
||||
"""Ohne pdfa_level darf der betroffene GS verwendet werden."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||
return_value="10.0.0"):
|
||||
check_preflight(pdfa_level="") # darf nicht werfen
|
||||
|
||||
|
||||
def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None:
|
||||
"""Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||
return_value="10.0.0"):
|
||||
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
|
||||
check_preflight(pdfa_level="2")
|
||||
|
||||
|
||||
def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None:
|
||||
"""Mit pdfa_level + gefixtem GS → ok."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||
return_value="10.02.1"):
|
||||
check_preflight(pdfa_level="2") # darf nicht werfen
|
||||
|
||||
|
||||
def test_default_config_pdfa_level_is_empty() -> None:
|
||||
"""Default-Config der Beispiel-Datei soll pdfa_level='' enthalten (Issue #3)."""
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
cfg_path = Path(__file__).parent.parent / "config.example.toml"
|
||||
with cfg_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["ocr"]["pdfa_level"] == "", \
|
||||
"config.example.toml muss pdfa_level='' als sicheren Default haben"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests für [ocr].timeout → ocrmypdf `tesseract_timeout`.
|
||||
|
||||
ocrmypdf wird hier komplett gemockt (per sys.modules), es läuft also nie
|
||||
wirklich — die Tests laufen auch ohne installiertes ocrmypdf.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.config import OcrConfig
|
||||
from pdf_ocr_hotfolder.processor import run_ocr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ocrmypdf(monkeypatch) -> ModuleType:
|
||||
"""Schiebt ein Dummy-ocrmypdf in sys.modules und merkt sich die kwargs."""
|
||||
mod = ModuleType("ocrmypdf")
|
||||
mod.calls = [] # type: ignore[attr-defined]
|
||||
|
||||
def ocr(src, dst, **kwargs):
|
||||
mod.calls.append({"src": src, "dst": dst, "kwargs": kwargs}) # type: ignore[attr-defined]
|
||||
Path(dst).write_bytes(b"%PDF-1.4 ocr\n")
|
||||
|
||||
mod.ocr = ocr # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "ocrmypdf", mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _run(fake, tmp_path: Path, cfg: OcrConfig) -> dict:
|
||||
src = tmp_path / "in.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
run_ocr(src, tmp_path / "out.pdf", cfg)
|
||||
assert len(fake.calls) == 1
|
||||
return fake.calls[0]["kwargs"]
|
||||
|
||||
|
||||
def test_timeout_is_passed_as_tesseract_timeout(fake_ocrmypdf, tmp_path: Path) -> None:
|
||||
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=120))
|
||||
assert kwargs["tesseract_timeout"] == 120.0
|
||||
|
||||
|
||||
def test_timeout_zero_means_no_limit(fake_ocrmypdf, tmp_path: Path) -> None:
|
||||
"""0 = kein Limit → der Key darf NICHT durchgereicht werden.
|
||||
|
||||
ocrmypdf würde tesseract_timeout=0 als 'OCR überspringen' auslegen.
|
||||
"""
|
||||
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=0))
|
||||
assert "tesseract_timeout" not in kwargs
|
||||
|
||||
|
||||
def test_negative_timeout_is_ignored(fake_ocrmypdf, tmp_path: Path) -> None:
|
||||
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig(timeout=-5))
|
||||
assert "tesseract_timeout" not in kwargs
|
||||
|
||||
|
||||
def test_default_timeout_is_passed(fake_ocrmypdf, tmp_path: Path) -> None:
|
||||
kwargs = _run(fake_ocrmypdf, tmp_path, OcrConfig())
|
||||
assert kwargs["tesseract_timeout"] == 300.0
|
||||
|
||||
|
||||
def test_other_kwargs_still_present(fake_ocrmypdf, tmp_path: Path) -> None:
|
||||
"""Der neue Key ersetzt nichts Bestehendes."""
|
||||
kwargs = _run(fake_ocrmypdf, tmp_path,
|
||||
OcrConfig(languages="deu", jobs=2, pdfa_level=""))
|
||||
assert kwargs["language"] == "deu"
|
||||
assert kwargs["jobs"] == 2
|
||||
assert kwargs["output_type"] == "pdf"
|
||||
assert kwargs["skip_text"] is True
|
||||
|
||||
|
||||
def test_config_default_matches_example(tmp_path: Path) -> None:
|
||||
"""Dataclass-Default und config.example.toml dürfen nicht auseinanderlaufen."""
|
||||
cfg_path = Path(__file__).parent.parent / "config.example.toml"
|
||||
with cfg_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
assert data["ocr"]["timeout"] == OcrConfig().timeout == 300
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests für Issue #2: --once Modus muss Exit-Code != 0 bei Fehlern liefern."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.processor import ProcessResult
|
||||
from pdf_ocr_hotfolder.service import HotfolderService
|
||||
|
||||
|
||||
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||
out = outgoing_dir / f"OCR_{src.name}"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(b"%PDF-1.4 ocr\n")
|
||||
src.unlink(missing_ok=True)
|
||||
return ProcessResult(src, out, True)
|
||||
|
||||
|
||||
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||
error_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = error_dir / src.name
|
||||
src.rename(dest)
|
||||
return ProcessResult(src, outgoing_dir / f"OCR_{src.name}", False,
|
||||
error="fake ocr failure")
|
||||
|
||||
|
||||
def _run(tmp_config, fake_process):
|
||||
"""Helper: führt run_once() mit gemocktem process_pdf und preflight aus."""
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=fake_process), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
return service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_once_exit_0_when_no_files(tmp_config) -> None:
|
||||
"""Szenario: Keine PDFs vorhanden → Exit 0."""
|
||||
errors = _run(tmp_config, _fake_success)
|
||||
assert errors == 0
|
||||
|
||||
|
||||
def test_once_exit_0_when_all_success(tmp_config) -> None:
|
||||
"""Szenario: Alle PDFs erfolgreich → Exit 0."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
errors = _run(tmp_config, _fake_success)
|
||||
assert errors == 0
|
||||
|
||||
|
||||
def test_once_exit_nonzero_when_all_fail(tmp_config) -> None:
|
||||
"""Szenario: Alle PDFs fehlgeschlagen → Exit != 0 (Issue #2)."""
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
errors = _run(tmp_config, _fake_failure)
|
||||
assert errors == 2
|
||||
|
||||
|
||||
def test_once_exit_nonzero_when_some_fail(tmp_config) -> None:
|
||||
"""Szenario: Teilweise fehlgeschlagen → Exit != 0."""
|
||||
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def mixed(src, *args, **kwargs):
|
||||
if "bad" in src.name:
|
||||
return _fake_failure(src, *args, **kwargs)
|
||||
return _fake_success(src, *args, **kwargs)
|
||||
|
||||
errors = _run(tmp_config, mixed)
|
||||
assert errors == 1
|
||||
|
||||
|
||||
def test_counters_track_success_and_failure(tmp_config) -> None:
|
||||
"""success_count und error_count sollen korrekt mitzählen."""
|
||||
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
def mixed(src, *args, **kwargs):
|
||||
if "bad" in src.name:
|
||||
return _fake_failure(src, *args, **kwargs)
|
||||
return _fake_success(src, *args, **kwargs)
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=mixed), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
service.run_once()
|
||||
assert service.success_count == 1
|
||||
assert service.error_count == 1
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests für Feature: konfigurierbare Dateinamen und Original-Behandlung."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
|
||||
from pdf_ocr_hotfolder.processor import build_output_name, process_pdf
|
||||
from pdf_ocr_hotfolder.service import PreflightError, check_output_config
|
||||
|
||||
|
||||
# ---------------- build_output_name ----------------
|
||||
|
||||
@pytest.mark.parametrize("src,mode,tag,expected", [
|
||||
# prefix
|
||||
("scan.pdf", "prefix", "OCR_", "OCR_scan.pdf"),
|
||||
("scan.pdf", "prefix", "[OCR] ", "[OCR] scan.pdf"),
|
||||
# suffix (Tag vor Extension)
|
||||
("scan.pdf", "suffix", "_OCR", "scan_OCR.pdf"),
|
||||
("scan.pdf", "suffix", "-ocr", "scan-ocr.pdf"),
|
||||
# none
|
||||
("scan.pdf", "none", "OCR_", "scan.pdf"),
|
||||
# leerer Tag = none
|
||||
("scan.pdf", "prefix", "", "scan.pdf"),
|
||||
("scan.pdf", "suffix", "", "scan.pdf"),
|
||||
# Mehrfach-Punkte im Namen: nur letzte Extension zählt
|
||||
("rechnung.2026.pdf", "suffix", "_OCR", "rechnung.2026_OCR.pdf"),
|
||||
("rechnung.2026.pdf", "prefix", "OCR_", "OCR_rechnung.2026.pdf"),
|
||||
# Name ohne Extension
|
||||
("NO_EXT", "suffix", "_OCR", "NO_EXT_OCR"),
|
||||
])
|
||||
def test_build_output_name(src, mode, tag, expected) -> None:
|
||||
assert build_output_name(src, mode, tag) == expected
|
||||
|
||||
|
||||
def test_build_output_name_invalid_mode() -> None:
|
||||
with pytest.raises(ValueError, match="name_mode"):
|
||||
build_output_name("x.pdf", "bogus", "OCR_")
|
||||
|
||||
|
||||
# ---------------- check_output_config ----------------
|
||||
|
||||
def test_check_output_config_delete_ok() -> None:
|
||||
check_output_config("delete", "") # ok
|
||||
|
||||
|
||||
def test_check_output_config_archive_requires_dir() -> None:
|
||||
with pytest.raises(PreflightError, match="archive_dir"):
|
||||
check_output_config("archive", "")
|
||||
|
||||
|
||||
def test_check_output_config_archive_with_dir_ok() -> None:
|
||||
check_output_config("archive", "/var/archive") # ok
|
||||
|
||||
|
||||
def test_check_output_config_invalid_mode() -> None:
|
||||
with pytest.raises(PreflightError, match="ungültig"):
|
||||
check_output_config("trash", "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name_mode", ["prefix", "suffix", "none"])
|
||||
def test_check_output_config_accepts_valid_name_modes(name_mode) -> None:
|
||||
check_output_config("delete", "", name_mode) # ok
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name_mode", ["prefixx", "Prefix", "", "postfix"])
|
||||
def test_check_output_config_invalid_name_mode(name_mode) -> None:
|
||||
"""Tippfehler in name_mode muss schon im Preflight auffallen."""
|
||||
with pytest.raises(PreflightError, match="name_mode"):
|
||||
check_output_config("delete", "", name_mode)
|
||||
|
||||
|
||||
def test_run_once_aborts_on_invalid_name_mode(tmp_config) -> None:
|
||||
"""Der Dienst bricht beim Start ab, bevor eine Datei angefasst wird."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.service import HotfolderService
|
||||
|
||||
tmp_config.output.name_mode = "bogus"
|
||||
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None):
|
||||
with pytest.raises(PreflightError, match="name_mode"):
|
||||
service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
# Datei wurde nicht angefasst
|
||||
assert (tmp_config.paths.incoming / "a.pdf").exists()
|
||||
|
||||
|
||||
def test_main_returns_2_on_invalid_name_mode(tmp_path: Path, monkeypatch) -> None:
|
||||
"""CLI liefert Exit-Code 2 — gleicher Mechanismus wie die übrigen Preflights."""
|
||||
import sys
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_path / 'in'}"
|
||||
outgoing = "{tmp_path / 'out'}"
|
||||
working = "{tmp_path / 'work'}"
|
||||
error = "{tmp_path / 'err'}"
|
||||
|
||||
[output]
|
||||
name_mode = "bogus"
|
||||
""")
|
||||
monkeypatch.setattr(sys, "argv",
|
||||
["pdf-ocr-hotfolder", "--config", str(cfg_file), "--once"])
|
||||
with _patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None):
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
assert main() == 2
|
||||
|
||||
|
||||
# ---------------- process_pdf mit Original-Behandlung ----------------
|
||||
|
||||
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||
"""Simuliert ocrmypdf: kopiert Inhalt, erzeugt Zieldatei."""
|
||||
dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes())
|
||||
|
||||
|
||||
def _prepare(tmp_path: Path) -> dict:
|
||||
dirs = {
|
||||
"working": tmp_path / "working",
|
||||
"outgoing": tmp_path / "outgoing",
|
||||
"error": tmp_path / "error",
|
||||
"archive": tmp_path / "archive",
|
||||
"incoming": tmp_path / "incoming",
|
||||
}
|
||||
for d in dirs.values():
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
src = dirs["incoming"] / "scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4 original\n")
|
||||
return {"src": src, **dirs}
|
||||
|
||||
|
||||
def test_process_pdf_prefix_delete(tmp_path: Path) -> None:
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="delete")
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||
result = process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
assert result.success
|
||||
assert (env["outgoing"] / "OCR_scan.pdf").exists()
|
||||
# Original ist weg, weder in incoming noch in working
|
||||
assert not env["src"].exists()
|
||||
assert not (env["working"] / "scan.pdf").exists()
|
||||
|
||||
|
||||
def test_process_pdf_suffix_delete(tmp_path: Path) -> None:
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="suffix", name_tag="_OCR",
|
||||
original_on_success="delete")
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||
result = process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
assert result.success
|
||||
assert (env["outgoing"] / "scan_OCR.pdf").exists()
|
||||
|
||||
|
||||
def test_process_pdf_none_mode(tmp_path: Path) -> None:
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="none", name_tag="OCR_",
|
||||
original_on_success="delete")
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||
result = process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
assert result.success
|
||||
# Ausgang hat GLEICHEN Namen wie Original
|
||||
assert (env["outgoing"] / "scan.pdf").exists()
|
||||
|
||||
|
||||
def test_process_pdf_archive_original(tmp_path: Path) -> None:
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="archive",
|
||||
archive_dir=str(env["archive"]))
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||
result = process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
assert result.success
|
||||
assert (env["outgoing"] / "OCR_scan.pdf").exists()
|
||||
# Original liegt jetzt im Archiv
|
||||
archived = env["archive"] / "scan.pdf"
|
||||
assert archived.exists()
|
||||
assert archived.read_bytes() == b"%PDF-1.4 original\n"
|
||||
|
||||
|
||||
def test_process_pdf_archive_name_collision(tmp_path: Path) -> None:
|
||||
"""Bei Namens-Kollision im Archiv wird Timestamp angehängt."""
|
||||
env = _prepare(tmp_path)
|
||||
# Vorhandene Kollisions-Datei
|
||||
(env["archive"] / "scan.pdf").write_bytes(b"old")
|
||||
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="archive",
|
||||
archive_dir=str(env["archive"]))
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||
process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
# Alte Datei unverändert
|
||||
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
|
||||
# Neue Datei mit Timestamp-Suffix
|
||||
archived = list(env["archive"].glob("scan_*.pdf"))
|
||||
assert len(archived) == 1
|
||||
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
|
||||
|
||||
|
||||
# ---------------- veraPDF FAIL: Original folgt original_on_success ----------------
|
||||
|
||||
def _run_with_vera_fail(env: dict, out_cfg: OutputConfig):
|
||||
"""process_pdf mit gemocktem OCR und einem veraPDF, das FAIL meldet."""
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
|
||||
patch("pdf_ocr_hotfolder.processor.run_verapdf", return_value=False):
|
||||
return process_pdf(
|
||||
src=env["src"],
|
||||
working_dir=env["working"],
|
||||
outgoing_dir=env["outgoing"],
|
||||
error_dir=env["error"],
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=True),
|
||||
output_cfg=out_cfg,
|
||||
)
|
||||
|
||||
|
||||
def test_process_pdf_verapdf_fail_delete_removes_original(tmp_path: Path) -> None:
|
||||
"""delete: Verhalten wie bisher — OCR-Ergebnis nach error/, Original weg."""
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="delete")
|
||||
result = _run_with_vera_fail(env, out_cfg)
|
||||
|
||||
assert not result.success
|
||||
assert result.verapdf_passed is False
|
||||
# OCR-Ergebnis liegt in error/
|
||||
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
|
||||
# Original ist weg
|
||||
assert not env["src"].exists()
|
||||
assert not (env["working"] / "scan.pdf").exists()
|
||||
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
|
||||
|
||||
|
||||
def test_process_pdf_verapdf_fail_archive_keeps_original(tmp_path: Path) -> None:
|
||||
"""archive: das Original darf im Fehlerfall NICHT verloren gehen."""
|
||||
env = _prepare(tmp_path)
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="archive",
|
||||
archive_dir=str(env["archive"]))
|
||||
result = _run_with_vera_fail(env, out_cfg)
|
||||
|
||||
assert not result.success
|
||||
assert result.verapdf_passed is False
|
||||
# OCR-Ergebnis liegt in error/
|
||||
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
|
||||
# Original liegt unversehrt im Archiv
|
||||
archived = env["archive"] / "scan.pdf"
|
||||
assert archived.exists()
|
||||
assert archived.read_bytes() == b"%PDF-1.4 original\n"
|
||||
assert not (env["working"] / "scan.pdf").exists()
|
||||
assert not (env["outgoing"] / "OCR_scan.pdf").exists()
|
||||
|
||||
|
||||
def test_process_pdf_verapdf_fail_archive_name_collision(tmp_path: Path) -> None:
|
||||
"""Auch im veraPDF-FAIL-Pfad greift der Timestamp-Kollisionsschutz."""
|
||||
env = _prepare(tmp_path)
|
||||
(env["archive"] / "scan.pdf").write_bytes(b"old")
|
||||
out_cfg = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||
original_on_success="archive",
|
||||
archive_dir=str(env["archive"]))
|
||||
_run_with_vera_fail(env, out_cfg)
|
||||
|
||||
assert (env["archive"] / "scan.pdf").read_bytes() == b"old"
|
||||
archived = list(env["archive"].glob("scan_*.pdf"))
|
||||
assert len(archived) == 1
|
||||
assert archived[0].read_bytes() == b"%PDF-1.4 original\n"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests für Issue #1: Preflight-Check bei fehlendem Tesseract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pdf_ocr_hotfolder.service import (
|
||||
HotfolderService,
|
||||
PreflightError,
|
||||
check_preflight,
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_passes_when_all_binaries_present() -> None:
|
||||
"""Wenn tesseract + gs im PATH sind, darf kein Fehler fliegen."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||
check_preflight() # darf nicht werfen
|
||||
|
||||
|
||||
def test_preflight_fails_when_tesseract_missing() -> None:
|
||||
"""Fehlendes tesseract → PreflightError mit passender Meldung."""
|
||||
def fake_which(name: str) -> str | None:
|
||||
return None if name == "tesseract" else "/usr/bin/fake"
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
|
||||
with pytest.raises(PreflightError, match="tesseract"):
|
||||
check_preflight()
|
||||
|
||||
|
||||
def test_preflight_fails_when_ghostscript_missing() -> None:
|
||||
def fake_which(name: str) -> str | None:
|
||||
return None if name == "gs" else "/usr/bin/fake"
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
|
||||
with pytest.raises(PreflightError, match="gs"):
|
||||
check_preflight()
|
||||
|
||||
|
||||
def test_preflight_lists_all_missing_binaries() -> None:
|
||||
"""Bei mehreren fehlenden Binaries werden alle genannt."""
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
with pytest.raises(PreflightError) as exc_info:
|
||||
check_preflight()
|
||||
msg = str(exc_info.value)
|
||||
assert "tesseract" in msg
|
||||
assert "gs" in msg
|
||||
|
||||
|
||||
def test_run_once_raises_preflight_error(tmp_config) -> None:
|
||||
"""HotfolderService.run_once() wirft PreflightError, wenn tesseract fehlt."""
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
with pytest.raises(PreflightError):
|
||||
service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
|
||||
|
||||
def test_main_returns_2_on_preflight_error(tmp_config, tmp_path, monkeypatch) -> None:
|
||||
"""CLI liefert Exit-Code 2 bei Preflight-Fehler (Issue #1 Szenario)."""
|
||||
cfg_file = tmp_path / "cfg.toml"
|
||||
cfg_file.write_text(f"""
|
||||
[paths]
|
||||
incoming = "{tmp_config.paths.incoming}"
|
||||
outgoing = "{tmp_config.paths.outgoing}"
|
||||
working = "{tmp_config.paths.working}"
|
||||
error = "{tmp_config.paths.error}"
|
||||
""")
|
||||
monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file), "--once"])
|
||||
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
|
||||
from pdf_ocr_hotfolder.__main__ import main
|
||||
assert main() == 2
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests für die Wiederaufnahme abgebrochener Läufe aus working/.
|
||||
|
||||
Hintergrund: `process_pdf()` verschiebt das Original vor dem OCR nach
|
||||
working/. Wird der Dienst dort hart gestoppt (SIGKILL nach TimeoutStopSec),
|
||||
blieb die Datei bisher für immer liegen — weder outgoing/, noch error/, noch
|
||||
eine Mail. ocrmypdf läuft in diesen Tests nie wirklich.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.config import OcrConfig, OutputConfig, VeraPdfConfig
|
||||
from pdf_ocr_hotfolder.processor import OCR_TEMP_PREFIX, ProcessResult, process_pdf
|
||||
from pdf_ocr_hotfolder.service import HotfolderService
|
||||
|
||||
|
||||
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||
"""Simuliert einen erfolgreichen Durchlauf inkl. Entsorgung des Originals."""
|
||||
out = outgoing_dir / f"OCR_{src.name}"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(b"%PDF-1.4 ocr\n")
|
||||
src.unlink(missing_ok=True)
|
||||
return ProcessResult(src, out, True)
|
||||
|
||||
|
||||
def _run_once(tmp_config, fake_process=_fake_success):
|
||||
"""run_once() mit gemocktem Preflight und gemocktem process_pdf."""
|
||||
seen: list[Path] = []
|
||||
|
||||
def spy(src, *args, **kwargs):
|
||||
seen.append(src)
|
||||
return fake_process(src, *args, **kwargs)
|
||||
|
||||
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
|
||||
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=spy), \
|
||||
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
|
||||
service = HotfolderService(tmp_config)
|
||||
try:
|
||||
service.run_once()
|
||||
finally:
|
||||
service._executor.shutdown(wait=False)
|
||||
return service, seen
|
||||
|
||||
|
||||
# ---------------- Aufgreifen aus working/ ----------------
|
||||
|
||||
def test_leftover_in_working_is_picked_up(tmp_config) -> None:
|
||||
"""Eine in working/ liegen gebliebene PDF wird wieder verarbeitet."""
|
||||
leftover = tmp_config.paths.working / "abgebrochen.pdf"
|
||||
leftover.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
service, seen = _run_once(tmp_config)
|
||||
|
||||
assert [p.name for p in seen] == ["abgebrochen.pdf"]
|
||||
assert seen[0].parent == tmp_config.paths.working
|
||||
assert service.success_count == 1
|
||||
assert not leftover.exists()
|
||||
assert (tmp_config.paths.outgoing / "OCR_abgebrochen.pdf").exists()
|
||||
|
||||
|
||||
def test_resume_logs_warning(tmp_config, caplog) -> None:
|
||||
"""Die Wiederaufnahme muss deutlich im Log stehen."""
|
||||
(tmp_config.paths.working / "abgebrochen.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
||||
_run_once(tmp_config)
|
||||
|
||||
text = caplog.text
|
||||
assert "Abgebrochener Lauf wird fortgesetzt" in text
|
||||
assert "abgebrochen.pdf" in text
|
||||
|
||||
|
||||
def test_ocr_fragment_is_removed_and_not_processed(tmp_config, caplog) -> None:
|
||||
"""__ocr_-Fragmente sind unbrauchbar: löschen, nicht als Eingabe nehmen."""
|
||||
leftover = tmp_config.paths.working / "scan.pdf"
|
||||
leftover.write_bytes(b"%PDF-1.4\n")
|
||||
fragment = tmp_config.paths.working / f"{OCR_TEMP_PREFIX}OCR_scan.pdf"
|
||||
fragment.write_bytes(b"%PDF-1.4 halbfertig\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
||||
service, seen = _run_once(tmp_config)
|
||||
|
||||
assert [p.name for p in seen] == ["scan.pdf"]
|
||||
assert not fragment.exists()
|
||||
assert service.error_count == 0
|
||||
assert "Fragment" in caplog.text
|
||||
|
||||
|
||||
def test_non_pdf_in_working_is_ignored(tmp_config) -> None:
|
||||
"""Fremddateien in working/ werden nicht angefasst."""
|
||||
junk = tmp_config.paths.working / "notizen.txt"
|
||||
junk.write_text("kein PDF")
|
||||
|
||||
_service, seen = _run_once(tmp_config)
|
||||
|
||||
assert seen == []
|
||||
assert junk.exists()
|
||||
|
||||
|
||||
def test_incoming_and_working_both_scanned(tmp_config) -> None:
|
||||
"""incoming/ wird weiterhin gescannt — zusätzlich zu working/."""
|
||||
(tmp_config.paths.incoming / "neu.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
(tmp_config.paths.working / "alt.pdf").write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
service, seen = _run_once(tmp_config)
|
||||
|
||||
assert sorted(p.name for p in seen) == ["alt.pdf", "neu.pdf"]
|
||||
assert service.success_count == 2
|
||||
|
||||
|
||||
def test_name_collision_between_working_and_incoming(tmp_config, caplog) -> None:
|
||||
"""Gleicher Name in beiden Ordnern: die working-Datei wird umbenannt.
|
||||
|
||||
Sonst würden sich beide dieselbe working- und dieselbe outgoing-Datei
|
||||
teilen und eine der beiden ginge verloren.
|
||||
"""
|
||||
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4 neu\n")
|
||||
(tmp_config.paths.working / "scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.service"):
|
||||
service, seen = _run_once(tmp_config)
|
||||
|
||||
names = sorted(p.name for p in seen)
|
||||
assert len(names) == 2
|
||||
assert "scan.pdf" in names
|
||||
# Die wiederaufgenommene Datei hat einen Zeitstempel bekommen
|
||||
renamed = [n for n in names if n != "scan.pdf"][0]
|
||||
assert renamed.startswith("scan_") and renamed.endswith(".pdf")
|
||||
assert service.success_count == 2
|
||||
assert "umbenannt" in caplog.text
|
||||
|
||||
|
||||
# ---------------- process_pdf: kein zweiter Move ----------------
|
||||
|
||||
def _ocr_ok(src: Path, dst: Path, cfg) -> None:
|
||||
dst.write_bytes(b"%PDF-1.4 ocr\n")
|
||||
|
||||
|
||||
def test_process_pdf_resumes_without_second_move(tmp_config) -> None:
|
||||
"""Eine Datei aus working/ darf nicht erneut nach working/ verschoben werden."""
|
||||
src = tmp_config.paths.working / "scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
||||
result = process_pdf(
|
||||
src=src,
|
||||
working_dir=tmp_config.paths.working,
|
||||
outgoing_dir=tmp_config.paths.outgoing,
|
||||
error_dir=tmp_config.paths.error,
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=OutputConfig(),
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert (tmp_config.paths.outgoing / "OCR_scan.pdf").exists()
|
||||
# Original entsorgt, keine Reste in working/
|
||||
assert list(tmp_config.paths.working.iterdir()) == []
|
||||
|
||||
|
||||
def test_process_pdf_resume_logs_warning(tmp_config, caplog) -> None:
|
||||
src = tmp_config.paths.working / "scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"), \
|
||||
patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
||||
process_pdf(
|
||||
src=src,
|
||||
working_dir=tmp_config.paths.working,
|
||||
outgoing_dir=tmp_config.paths.outgoing,
|
||||
error_dir=tmp_config.paths.error,
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=OutputConfig(),
|
||||
)
|
||||
|
||||
assert "Wiederaufnahme" in caplog.text
|
||||
|
||||
|
||||
def test_process_pdf_refuses_to_overwrite_working_file(tmp_config) -> None:
|
||||
"""Belegter Name in working/: lieber Fehler als stilles Überschreiben."""
|
||||
busy = tmp_config.paths.working / "scan.pdf"
|
||||
busy.write_bytes(b"%PDF-1.4 laeuft gerade\n")
|
||||
src = tmp_config.paths.incoming / "scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4 neu\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_ocr_ok):
|
||||
result = process_pdf(
|
||||
src=src,
|
||||
working_dir=tmp_config.paths.working,
|
||||
outgoing_dir=tmp_config.paths.outgoing,
|
||||
error_dir=tmp_config.paths.error,
|
||||
ocr_cfg=OcrConfig(),
|
||||
vera_cfg=VeraPdfConfig(enabled=False),
|
||||
output_cfg=OutputConfig(),
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert "scan.pdf" in result.error
|
||||
# Beide Dateien unangetastet
|
||||
assert busy.read_bytes() == b"%PDF-1.4 laeuft gerade\n"
|
||||
assert src.read_bytes() == b"%PDF-1.4 neu\n"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests für upload_folder() — Kopie per shutil.copyfile statt read_bytes()."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pdf_ocr_hotfolder.config import FolderUpload
|
||||
from pdf_ocr_hotfolder.uploaders import upload_folder
|
||||
|
||||
|
||||
def test_upload_folder_copies_file(tmp_path: Path) -> None:
|
||||
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(b"%PDF-1.4 inhalt\n")
|
||||
target = tmp_path / "ziel"
|
||||
|
||||
assert upload_folder(src, FolderUpload(enabled=True, target=str(target)),
|
||||
tmp_path / "out") is True
|
||||
assert (target / "OCR_scan.pdf").read_bytes() == b"%PDF-1.4 inhalt\n"
|
||||
# Quelle bleibt liegen (Kopie, kein Move)
|
||||
assert src.exists()
|
||||
|
||||
|
||||
def test_upload_folder_uses_copyfile_not_read_bytes(tmp_path: Path) -> None:
|
||||
"""Große PDFs dürfen nicht komplett in den Speicher gelesen werden."""
|
||||
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
target = tmp_path / "ziel"
|
||||
|
||||
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile:
|
||||
upload_folder(src, FolderUpload(enabled=True, target=str(target)),
|
||||
tmp_path / "out")
|
||||
copyfile.assert_called_once()
|
||||
|
||||
|
||||
def test_upload_folder_skips_self_target(tmp_path: Path) -> None:
|
||||
"""Ist das Ziel = outgoing, wird nicht auf sich selbst kopiert."""
|
||||
out = tmp_path / "out"
|
||||
out.mkdir()
|
||||
src = out / "OCR_scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile") as copyfile:
|
||||
assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True
|
||||
copyfile.assert_not_called()
|
||||
assert src.read_bytes() == b"%PDF-1.4\n"
|
||||
|
||||
|
||||
def test_upload_folder_disabled_returns_true(tmp_path: Path) -> None:
|
||||
src = tmp_path / "OCR_scan.pdf"
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
assert upload_folder(src, FolderUpload(enabled=False), tmp_path) is True
|
||||
|
||||
|
||||
def test_upload_folder_reports_failure(tmp_path: Path) -> None:
|
||||
"""OSError beim Kopieren → False (wird vom Service als Fehler gezählt)."""
|
||||
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(b"%PDF-1.4\n")
|
||||
|
||||
with patch("pdf_ocr_hotfolder.uploaders.shutil.copyfile",
|
||||
side_effect=OSError("disk full")):
|
||||
assert upload_folder(src, FolderUpload(enabled=True,
|
||||
target=str(tmp_path / "ziel")),
|
||||
tmp_path / "out") is False
|
||||
@@ -3,24 +3,685 @@
|
||||
# PDF OCR Hotfolder — Update-Script
|
||||
#
|
||||
# Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die
|
||||
# systemd Template-Unit. Danach werden alle laufenden Instanzen neu gestartet.
|
||||
# Config-Dateien unter /etc/pdf-ocr-hotfolder/ bleiben unverändert.
|
||||
# systemd Template-Unit. Danach werden alle Instanzen neu gestartet, die
|
||||
# vorher laufen sollten. Config-Dateien unter /etc/pdf-ocr-hotfolder/
|
||||
# bleiben unveraendert.
|
||||
#
|
||||
set -euo pipefail
|
||||
# Ueberlebt Debian-Major-Upgrades (12 -> 13 -> 14): stimmt die Python-Version
|
||||
# der venv nicht mehr mit dem System-Python ueberein oder ist der Interpreter
|
||||
# der venv gar nicht mehr da, wird die venv neu gebaut (ganz oder gar nicht).
|
||||
#
|
||||
# Aufruf:
|
||||
# sudo ./update.sh # normales Update
|
||||
# sudo ./update.sh --rebuild-venv # venv-Neubau erzwingen (nach dist-upgrade)
|
||||
#
|
||||
set -Eeuo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
|
||||
|
||||
if [ "${EUID}" -ne 0 ]; then
|
||||
log_error "Bitte als root ausführen: sudo ./update.sh"
|
||||
exit 1
|
||||
# Pfade sind ueberschreibbar, damit die Funktionen dieses Skripts isoliert
|
||||
# gegen eine Fake-Umgebung getestet werden koennen (siehe LIB_ONLY unten).
|
||||
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
|
||||
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
|
||||
: "${SYSTEMD_DIR:=/etc/systemd/system}"
|
||||
: "${BACKUP_DIR:=/var/backups/pdf-ocr-hotfolder}"
|
||||
: "${TAR_ROOT:=/}"
|
||||
: "${VERIFY_WAIT:=6}"
|
||||
: "${BACKUP_KEEP:=5}"
|
||||
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||
UNIT_GLOB='pdf-ocr-hotfolder@*.service'
|
||||
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
|
||||
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
|
||||
|
||||
REBUILD_VENV=0 # per --rebuild-venv erzwungen
|
||||
VENV_REBUILT=0 # wurde tatsaechlich neu gebaut?
|
||||
APT_WARN=0 # apt-Sync hat gemeckert
|
||||
LXC_SYNCED=0
|
||||
BACKUP_FILE=""
|
||||
PRIMARY_USER="pdfocr"
|
||||
TOUCHED=0 # 1, sobald auf der Platte etwas getauscht wurde
|
||||
|
||||
UNITS_ALL=() # alle bekannten Instanz-Units
|
||||
PREV_OK=() # liefen vorher sauber -> muessen nachher laufen
|
||||
PREV_BROKEN=() # waren vorher kaputt -> sollen laufen, gelten aber nicht als Erfolg
|
||||
PREV_STOPPED=() # waren bewusst gestoppt -> bleiben gestoppt
|
||||
CFG_WARN=() # Instanzen mit Config-Warnungen (Exit 1)
|
||||
CFG_ERR=() # Instanzen mit Config-Fehlern (Exit 2)
|
||||
STARTED_OK=()
|
||||
STARTED_FAIL=()
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
PDF OCR Hotfolder — Update
|
||||
|
||||
sudo ./update.sh [OPTIONEN]
|
||||
|
||||
Optionen:
|
||||
--rebuild-venv Python-venv zwingend neu bauen und Requirements frisch
|
||||
installieren. Das ist der Aufruf nach einem Debian-
|
||||
Major-Upgrade (apt full-upgrade, z.B. 12 -> 13), wenn das
|
||||
System-Python eine neue Version bekommen hat.
|
||||
-h, --help Diese Hilfe.
|
||||
|
||||
Ohne Option prueft das Skript selbst, ob die venv noch zum System-Python
|
||||
passt, und baut sie bei Bedarf neu.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Abbruch-Behandlung
|
||||
# ============================================================
|
||||
|
||||
CLEANUP_DONE=0
|
||||
|
||||
# Startet bei einem Abbruch die vorher laufenden Instanzen wieder und sagt
|
||||
# laut, was passiert ist.
|
||||
abort_handler() {
|
||||
local rc="${1:-1}" line="${2:-?}"
|
||||
if [ "$CLEANUP_DONE" -ne 0 ]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
CLEANUP_DONE=1
|
||||
trap - ERR INT TERM
|
||||
echo
|
||||
log_error "═══════════════════════════════════════════════════════════════"
|
||||
log_error "UPDATE ABGEBROCHEN (Exit-Code $rc, Zeile $line)."
|
||||
if [ "$TOUCHED" -eq 1 ]; then
|
||||
log_error "Der Stand auf der Platte kann halb aktualisiert sein."
|
||||
else
|
||||
log_error "Es wurde noch nichts getauscht — der alte Stand ist unveraendert."
|
||||
fi
|
||||
log_error "═══════════════════════════════════════════════════════════════"
|
||||
|
||||
local -a to_restart=()
|
||||
[ "${#PREV_OK[@]}" -gt 0 ] && to_restart+=("${PREV_OK[@]}")
|
||||
[ "${#PREV_BROKEN[@]}" -gt 0 ] && to_restart+=("${PREV_BROKEN[@]}")
|
||||
if [ "${#to_restart[@]}" -gt 0 ]; then
|
||||
log_warn "Starte die vorher laufenden Instanzen wieder..."
|
||||
local unit
|
||||
for unit in "${to_restart[@]}"; do
|
||||
if systemctl start "$unit" >/dev/null 2>&1; then
|
||||
log_info " wieder gestartet: $unit"
|
||||
else
|
||||
log_error " Start fehlgeschlagen: $unit (journalctl -u $unit -n 50)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_warn "Es liefen vorher keine Instanzen — nichts wieder zu starten."
|
||||
fi
|
||||
|
||||
if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
|
||||
log_warn "Backup vor dem Update: $BACKUP_FILE"
|
||||
log_warn "Rollback: tar -xzf '$BACKUP_FILE' -C / && systemctl daemon-reload"
|
||||
else
|
||||
log_warn "Es wurde noch KEIN Backup geschrieben."
|
||||
fi
|
||||
log_warn "Aeltere Backups: $BACKUP_DIR"
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
die() { log_error "$*"; abort_handler 1 "${BASH_LINENO[0]:-?}"; }
|
||||
|
||||
# ============================================================
|
||||
# Python / venv
|
||||
# ============================================================
|
||||
|
||||
# major.minor des uebergebenen Interpreters; leer, wenn er nicht laeuft.
|
||||
py_mm() {
|
||||
local py="$1"
|
||||
"$py" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# major.minor aus pyvenv.cfg (version = / version_info =); leer, wenn unlesbar.
|
||||
pyvenv_cfg_mm() {
|
||||
local cfg="$1/pyvenv.cfg"
|
||||
[ -f "$cfg" ] || return 0
|
||||
sed -n 's/^[[:space:]]*version\(_info\)\?[[:space:]]*=[[:space:]]*\([0-9]\+\.[0-9]\+\).*/\2/p' "$cfg" | head -n1
|
||||
}
|
||||
|
||||
# Prueft die venv gegen das aktuelle System-Python.
|
||||
# Setzt VENV_ISSUES (Array) und gibt 0 zurueck, wenn alles passt.
|
||||
VENV_ISSUES=()
|
||||
venv_is_healthy() {
|
||||
local venv="$1"
|
||||
local sys_mm venv_mm cfg_mm
|
||||
VENV_ISSUES=()
|
||||
|
||||
if [ ! -d "$venv" ]; then
|
||||
VENV_ISSUES+=("venv-Verzeichnis fehlt: $venv")
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$venv/bin/python" ]; then
|
||||
VENV_ISSUES+=("$venv/bin/python fehlt oder ist nicht ausfuehrbar")
|
||||
return 1
|
||||
fi
|
||||
|
||||
venv_mm="$(py_mm "$venv/bin/python")"
|
||||
if [ -z "$venv_mm" ]; then
|
||||
VENV_ISSUES+=("$venv/bin/python laeuft nicht (toter Symlink nach einem Distributions-Upgrade?)")
|
||||
return 1
|
||||
fi
|
||||
|
||||
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
|
||||
if [ -z "$sys_mm" ]; then
|
||||
VENV_ISSUES+=("System-python3 laeuft nicht — venv-Pruefung nicht moeglich")
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$venv_mm" != "$sys_mm" ]; then
|
||||
VENV_ISSUES+=("venv haengt an Python $venv_mm, das System liefert Python $sys_mm")
|
||||
return 1
|
||||
fi
|
||||
|
||||
cfg_mm="$(pyvenv_cfg_mm "$venv")"
|
||||
if [ -n "$cfg_mm" ] && [ "$cfg_mm" != "$venv_mm" ]; then
|
||||
VENV_ISSUES+=("pyvenv.cfg nennt Python $cfg_mm, der Interpreter meldet $venv_mm")
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Installiert die Requirements und uebersetzt pip-Fehler in eine Ansage, mit
|
||||
# der man etwas anfangen kann (typisch: Pin passt nicht mehr zum Python).
|
||||
pip_install_requirements() {
|
||||
local venv="$1" out rc py_mm_now
|
||||
py_mm_now="$(py_mm "$venv/bin/python")"
|
||||
|
||||
out="$("$venv/bin/pip" install --upgrade pip 2>&1)" && rc=0 || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log_warn "pip liess sich nicht aktualisieren (weiter mit der vorhandenen Version):"
|
||||
printf '%s\n' "$out" | tail -n 5
|
||||
fi
|
||||
|
||||
out="$("$venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" 2>&1)" && rc=0 || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
log_error "Installation der Requirements fehlgeschlagen (pip Exit $rc)."
|
||||
printf '%s\n' "$out" | tail -n 25
|
||||
echo
|
||||
local bad
|
||||
bad="$(printf '%s\n' "$out" | sed -n \
|
||||
-e 's/.*[Nn]o matching distribution found for \([^ ]*\).*/\1/p' \
|
||||
-e 's/.*Could not find a version that satisfies the requirement \([^ ]*\).*/\1/p' \
|
||||
-e 's/.*Failed building wheel for \([^ ]*\).*/\1/p' \
|
||||
-e 's/.*error: subprocess-exited-with-error.*building \([^ ]*\).*/\1/p' \
|
||||
| head -n1)"
|
||||
if [ -n "$bad" ]; then
|
||||
log_error "Gescheitertes Paket: $bad"
|
||||
fi
|
||||
log_error "Sehr wahrscheinliche Ursache: eine in requirements.txt fest gepinnte"
|
||||
log_error "Version gibt es fuer Python ${py_mm_now:-?} nicht (mehr)."
|
||||
log_error "Naechster Schritt: requirements.txt anheben (passende Version fuer"
|
||||
log_error "Python ${py_mm_now:-?} eintragen) und update.sh --rebuild-venv erneut laufen lassen."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Baut die venv neu: alte wegsichern, neue bauen, Requirements installieren,
|
||||
# und erst bei Erfolg die alte entfernen. Scheitert etwas, wird die alte
|
||||
# zurueckgerollt und hart abgebrochen.
|
||||
rebuild_venv() {
|
||||
local venv="$INSTALL_DIR/venv"
|
||||
local saved sys_mm
|
||||
saved="$INSTALL_DIR/venv.old-$(date +%Y%m%d-%H%M%S)"
|
||||
sys_mm="$(py_mm "$(command -v python3 || echo /usr/bin/python3)")"
|
||||
|
||||
log_step "venv wird neu gebaut (System-Python ${sys_mm:-?})"
|
||||
if [ -d "$venv" ]; then
|
||||
mv "$venv" "$saved" || die "Alte venv liess sich nicht nach $saved verschieben."
|
||||
log_info "Alte venv gesichert: $saved"
|
||||
else
|
||||
saved=""
|
||||
log_info "Keine alte venv vorhanden — es wird frisch gebaut."
|
||||
fi
|
||||
|
||||
if ! python3 -m venv "$venv"; then
|
||||
log_error "python3 -m venv ist fehlgeschlagen."
|
||||
log_error "Fehlt das Paket python3-venv? -> apt-get install -y python3-venv"
|
||||
rm -rf "$venv"
|
||||
if [ -n "$saved" ]; then
|
||||
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
|
||||
fi
|
||||
die "venv-Neubau abgebrochen."
|
||||
fi
|
||||
|
||||
if ! pip_install_requirements "$venv"; then
|
||||
rm -rf "$venv"
|
||||
if [ -n "$saved" ]; then
|
||||
mv "$saved" "$venv" && log_warn "Alte venv wurde zurueckgerollt: $venv"
|
||||
log_warn "Sie haengt weiterhin an einem Python, das es so nicht mehr gibt —"
|
||||
log_warn "die Instanzen laufen damit nicht. Erst requirements.txt korrigieren."
|
||||
fi
|
||||
die "venv-Neubau abgebrochen — keine halb gefuellte venv zurueckgelassen."
|
||||
fi
|
||||
|
||||
[ -n "$saved" ] && rm -rf "$saved"
|
||||
VENV_REBUILT=1
|
||||
log_info "venv neu gebaut ✓ (Python ${sys_mm:-?})"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# System-Pakete
|
||||
# ============================================================
|
||||
|
||||
# Holt die Paketliste aus install.sh (einzige Quelle) und installiert sie.
|
||||
# apt-get install ist idempotent; bereits vorhandene Pakete bleiben unberuehrt.
|
||||
# Nachinstallierte Tesseract-Sprachpakete werden NICHT angefasst (kein purge,
|
||||
# kein autoremove).
|
||||
sync_system_packages() {
|
||||
local block pkgs
|
||||
log_step "System-Pakete abgleichen"
|
||||
|
||||
block="$(sed -n '/^# --- BEGIN apt-packages/,/^# --- END apt-packages/p' "$REPO_DIR/install.sh" 2>/dev/null || true)"
|
||||
if [ -z "$block" ] || ! printf '%s' "$block" | grep -q 'pdf_ocr_apt_packages()'; then
|
||||
log_warn "Paketliste in install.sh nicht gefunden — System-Pakete werden nicht abgeglichen."
|
||||
APT_WARN=1
|
||||
return 0
|
||||
fi
|
||||
eval "$block"
|
||||
if ! declare -F pdf_ocr_apt_packages >/dev/null; then
|
||||
log_warn "pdf_ocr_apt_packages() liess sich nicht laden — uebersprungen."
|
||||
APT_WARN=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
mapfile -t pkgs < <(pdf_ocr_apt_packages)
|
||||
if [ "${#pkgs[@]}" -eq 0 ]; then
|
||||
log_warn "Paketliste ist leer — uebersprungen."
|
||||
APT_WARN=1
|
||||
return 0
|
||||
fi
|
||||
log_info "Pakete: ${pkgs[*]}"
|
||||
|
||||
if ! apt-get update -qq; then
|
||||
log_warn "'apt-get update' fehlgeschlagen (kein Netz / kein Mirror?)."
|
||||
APT_WARN=1
|
||||
fi
|
||||
if apt-get install -y --no-install-recommends "${pkgs[@]}"; then
|
||||
log_info "System-Pakete ok ✓ (vorhandene Sprachpakete bleiben unangetastet)"
|
||||
else
|
||||
log_warn "Mindestens ein System-Paket liess sich nicht installieren."
|
||||
log_warn "Falls eine neue Version ein neues Paket braucht, laufen die Instanzen evtl. nicht."
|
||||
APT_WARN=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Instanzen erfassen
|
||||
# ============================================================
|
||||
|
||||
unit_state() { systemctl is-active "$1" 2>/dev/null || true; }
|
||||
unit_failed() { systemctl is-failed "$1" 2>/dev/null || true; }
|
||||
unit_restarts() {
|
||||
local n
|
||||
n="$(systemctl show -p NRestarts --value "$1" 2>/dev/null || true)"
|
||||
case "$n" in (''|*[!0-9]*) echo 0 ;; (*) echo "$n" ;; esac
|
||||
}
|
||||
|
||||
# Sammelt alle Instanz-Units: geladene (aktiv, activating, failed), per
|
||||
# list-unit-files enabled, und alle, fuer die eine Config existiert.
|
||||
collect_instances() {
|
||||
local -A seen=()
|
||||
local unit name line state failed cfg
|
||||
|
||||
while read -r line; do
|
||||
unit="$(printf '%s' "$line" | awk '{print $1}')"
|
||||
unit="${unit#●}"
|
||||
[ -n "$unit" ] || continue
|
||||
case "$unit" in
|
||||
pdf-ocr-hotfolder@.service) continue ;;
|
||||
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
|
||||
esac
|
||||
done < <(systemctl list-units --all --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
|
||||
|
||||
while read -r line; do
|
||||
unit="$(printf '%s' "$line" | awk '{print $1}')"
|
||||
[ -n "$unit" ] || continue
|
||||
case "$unit" in
|
||||
pdf-ocr-hotfolder@.service) continue ;;
|
||||
pdf-ocr-hotfolder@?*.service) seen["$unit"]=1 ;;
|
||||
esac
|
||||
done < <(systemctl list-unit-files --no-legend --plain "$UNIT_GLOB" 2>/dev/null || true)
|
||||
|
||||
shopt -s nullglob
|
||||
for cfg in "$CONFIG_DIR"/*.toml; do
|
||||
name="$(basename "$cfg" .toml)"
|
||||
[ -n "$name" ] || continue
|
||||
seen["pdf-ocr-hotfolder@${name}.service"]=1
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
UNITS_ALL=(); PREV_OK=(); PREV_BROKEN=(); PREV_STOPPED=()
|
||||
[ "${#seen[@]}" -gt 0 ] || return 0
|
||||
mapfile -t UNITS_ALL < <(printf '%s\n' "${!seen[@]}" | sort)
|
||||
|
||||
for unit in "${UNITS_ALL[@]}"; do
|
||||
state="$(unit_state "$unit")"
|
||||
failed="$(unit_failed "$unit")"
|
||||
case "$state" in
|
||||
active)
|
||||
if [ "$failed" = "failed" ]; then
|
||||
PREV_BROKEN+=("$unit")
|
||||
else
|
||||
PREV_OK+=("$unit")
|
||||
fi
|
||||
;;
|
||||
activating|reloading|deactivating|failed)
|
||||
PREV_BROKEN+=("$unit")
|
||||
;;
|
||||
*)
|
||||
if [ "$failed" = "failed" ]; then
|
||||
PREV_BROKEN+=("$unit")
|
||||
else
|
||||
PREV_STOPPED+=("$unit")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
report_instances() {
|
||||
if [ "${#PREV_OK[@]}" -gt 0 ]; then
|
||||
log_info "Lief vorher sauber (${#PREV_OK[@]}): ${PREV_OK[*]}"
|
||||
else
|
||||
log_info "Keine sauber laufende Instanz gefunden."
|
||||
fi
|
||||
if [ "${#PREV_BROKEN[@]}" -gt 0 ]; then
|
||||
log_warn "War vorher KAPUTT (${#PREV_BROKEN[@]}): ${PREV_BROKEN[*]}"
|
||||
log_warn " (failed oder Crash-Loop/activating — wird mit gestartet, gilt aber"
|
||||
log_warn " erst als Erfolg, wenn sie nach dem Update wirklich laeuft.)"
|
||||
fi
|
||||
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
|
||||
log_info "Bewusst gestoppt, bleibt gestoppt (${#PREV_STOPPED[@]}): ${PREV_STOPPED[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_instances() {
|
||||
local -a units=()
|
||||
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
|
||||
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
|
||||
[ "${#units[@]}" -gt 0 ] || { log_info "Nichts zu stoppen."; return 0; }
|
||||
log_info "Stoppe Instanzen: ${units[*]}"
|
||||
local unit
|
||||
for unit in "${units[@]}"; do
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || log_warn " stop fehlgeschlagen: $unit"
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Verifikation nach dem Start
|
||||
# ============================================================
|
||||
|
||||
# Wartet mindestens VERIFY_WAIT Sekunden und prueft danach is-active,
|
||||
# is-failed und den Restart-Zaehler. Ein Crash-Loop (Type=simple meldet
|
||||
# sofort "active") faellt damit auf.
|
||||
verify_unit() {
|
||||
local unit="$1"
|
||||
local r0 r1 state failed waited=0
|
||||
r0="$(unit_restarts "$unit")"
|
||||
while [ "$waited" -lt "$VERIFY_WAIT" ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
[ "$(unit_state "$unit")" = "failed" ] && break
|
||||
done
|
||||
state="$(unit_state "$unit")"
|
||||
failed="$(unit_failed "$unit")"
|
||||
r1="$(unit_restarts "$unit")"
|
||||
|
||||
if [ "$state" != "active" ]; then
|
||||
log_error " ❌ $unit — Status '$state' nach ${waited}s"
|
||||
return 1
|
||||
fi
|
||||
if [ "$failed" = "failed" ]; then
|
||||
log_error " ❌ $unit — systemd meldet 'failed'"
|
||||
return 1
|
||||
fi
|
||||
if [ "$r1" -gt "$r0" ]; then
|
||||
log_error " ❌ $unit — Crash-Loop (NRestarts $r0 -> $r1 in ${waited}s)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
start_instances() {
|
||||
local -a units=()
|
||||
[ "${#PREV_OK[@]}" -gt 0 ] && units+=("${PREV_OK[@]}")
|
||||
[ "${#PREV_BROKEN[@]}" -gt 0 ] && units+=("${PREV_BROKEN[@]}")
|
||||
STARTED_OK=(); STARTED_FAIL=()
|
||||
[ "${#units[@]}" -gt 0 ] || { log_info "Keine Instanz zu starten."; return 0; }
|
||||
|
||||
local unit
|
||||
for unit in "${units[@]}"; do
|
||||
# Alten failed-Zustand raeumen, damit is-failed/NRestarts danach
|
||||
# wirklich etwas ueber diesen Start aussagen.
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
systemctl start "$unit" >/dev/null 2>&1 || log_warn " start meldete einen Fehler: $unit"
|
||||
if verify_unit "$unit"; then
|
||||
log_info " ✅ $unit laeuft (nach ${VERIFY_WAIT}s stabil)"
|
||||
STARTED_OK+=("$unit")
|
||||
else
|
||||
log_error " Logs: journalctl -u $unit -n 50 --no-pager"
|
||||
STARTED_FAIL+=("$unit")
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Config-Pruefung (CLI des Python-Teils)
|
||||
# ============================================================
|
||||
|
||||
# Nutzt: venv/bin/python -m pdf_ocr_hotfolder --check-config --config <datei>
|
||||
# Exit 0 = sauber, 1 = Warnungen, 2 = Fehler.
|
||||
# Kennt der installierte Code das Subkommando nicht, wird die Pruefung
|
||||
# uebersprungen (Warnung), das Update laeuft aber weiter.
|
||||
check_all_configs() {
|
||||
local py="$INSTALL_DIR/venv/bin/python"
|
||||
local cfg name out rc
|
||||
local -a cfgs=()
|
||||
|
||||
log_step "Configs pruefen (--check-config)"
|
||||
CFG_WARN=(); CFG_ERR=()
|
||||
|
||||
if [ ! -x "$py" ]; then
|
||||
log_warn "venv-Python nicht ausfuehrbar — Config-Pruefung uebersprungen."
|
||||
return 0
|
||||
fi
|
||||
shopt -s nullglob
|
||||
cfgs=("$CONFIG_DIR"/*.toml)
|
||||
shopt -u nullglob
|
||||
if [ "${#cfgs[@]}" -eq 0 ]; then
|
||||
log_info "Keine Instanz-Configs unter $CONFIG_DIR."
|
||||
return 0
|
||||
fi
|
||||
|
||||
for cfg in "${cfgs[@]}"; do
|
||||
name="$(basename "$cfg" .toml)"
|
||||
out="$(cd "$INSTALL_DIR" && "$py" -m pdf_ocr_hotfolder --check-config --config "$cfg" 2>&1)" && rc=0 || rc=$?
|
||||
case "$rc" in
|
||||
0)
|
||||
log_info " ✅ $name — Config ok"
|
||||
[ -n "$out" ] && printf '%s\n' "$out"
|
||||
;;
|
||||
1)
|
||||
log_warn " ⚠ $name — Warnungen:"
|
||||
printf '%s\n' "$out"
|
||||
CFG_WARN+=("$name")
|
||||
;;
|
||||
2)
|
||||
# argparse beendet sich bei unbekannten Optionen ebenfalls mit 2 —
|
||||
# das ist kein Config-Fehler, sondern aelterer Code.
|
||||
if printf '%s' "$out" | grep -qiE 'unrecognized arguments|invalid choice|no such option|unknown option|unrecognized option'; then
|
||||
log_warn "Der installierte Code kennt --check-config nicht (aeltere Version)."
|
||||
log_warn "Config-Pruefung wird uebersprungen — Update laeuft weiter."
|
||||
CFG_WARN=(); CFG_ERR=()
|
||||
return 0
|
||||
fi
|
||||
log_error " ❌ $name — Config-FEHLER:"
|
||||
printf '%s\n' "$out"
|
||||
CFG_ERR+=("$name")
|
||||
;;
|
||||
*)
|
||||
log_warn " ⚠ $name — --check-config lieferte unerwarteten Exit-Code $rc; ignoriert."
|
||||
[ -n "$out" ] && printf '%s\n' "$out" | tail -n 10
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Backup
|
||||
# ============================================================
|
||||
|
||||
# Pfad relativ zu TAR_ROOT (fuer tar -C "$TAR_ROOT").
|
||||
strip_root() {
|
||||
local p="$1" root="${TAR_ROOT%/}"
|
||||
[ -n "$root" ] && p="${p#"$root"}"
|
||||
printf '%s' "${p#/}"
|
||||
}
|
||||
|
||||
# Sichert Code, Instanz-Configs, Template-Unit, Drop-ins und ein pip freeze
|
||||
# der alten venv. Datenverzeichnisse (/var/lib/...) bleiben bewusst draussen.
|
||||
# Das Archiv enthaelt Klartext-Passwoerter aus den Instanz-Configs und wird
|
||||
# deshalb root-only (0600) abgelegt.
|
||||
create_backup() {
|
||||
local stage tmp d
|
||||
local -a items=()
|
||||
|
||||
log_step "Backup erstellen"
|
||||
mkdir -p "$BACKUP_DIR" || die "Backup-Verzeichnis $BACKUP_DIR nicht anlegbar."
|
||||
chmod 700 "$BACKUP_DIR" 2>/dev/null || true
|
||||
|
||||
stage="$(mktemp -d)" || die "mktemp -d fehlgeschlagen."
|
||||
{
|
||||
echo "# pip freeze der alten venv vor dem Update"
|
||||
echo "# Zeitpunkt: $(date -Is)"
|
||||
echo "# Version: ${OLD_VERSION:-unknown} -> ${NEW_VERSION:-unknown}"
|
||||
if [ -x "$INSTALL_DIR/venv/bin/pip" ]; then
|
||||
"$INSTALL_DIR/venv/bin/pip" freeze 2>/dev/null || echo "# pip freeze fehlgeschlagen (venv defekt?)"
|
||||
else
|
||||
echo "# keine funktionierende venv vorhanden"
|
||||
fi
|
||||
} > "$stage/pip-freeze.txt"
|
||||
|
||||
[ -d "$INSTALL_DIR" ] && items+=("$(strip_root "$INSTALL_DIR")")
|
||||
[ -d "$CONFIG_DIR" ] && items+=("$(strip_root "$CONFIG_DIR")")
|
||||
[ -f "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ] && items+=("$(strip_root "$SYSTEMD_DIR/$SERVICE_TEMPLATE")")
|
||||
shopt -s nullglob
|
||||
for d in "$SYSTEMD_DIR"/pdf-ocr-hotfolder@*.service.d; do
|
||||
items+=("$(strip_root "$d")")
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
rm -rf "$stage"
|
||||
die "Nichts zu sichern gefunden — das sieht nach einer kaputten Installation aus."
|
||||
fi
|
||||
|
||||
tmp="$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
local old_umask; old_umask="$(umask)"
|
||||
umask 077
|
||||
if ! tar -czf "$tmp" \
|
||||
--exclude="$(strip_root "$INSTALL_DIR")/venv" \
|
||||
--exclude="$(strip_root "$INSTALL_DIR")/venv.old-*" \
|
||||
--exclude='*/__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
-C "$TAR_ROOT" "${items[@]}" \
|
||||
-C "$stage" pip-freeze.txt; then
|
||||
umask "$old_umask"
|
||||
rm -f "$tmp"
|
||||
rm -rf "$stage"
|
||||
die "Backup fehlgeschlagen (volle Platte?). Es wurde NICHTS veraendert."
|
||||
fi
|
||||
umask "$old_umask"
|
||||
rm -rf "$stage"
|
||||
chmod 600 "$tmp" || true
|
||||
chown root:root "$tmp" 2>/dev/null || true
|
||||
|
||||
BACKUP_FILE="$tmp"
|
||||
log_info "Backup: $tmp ($(du -h "$tmp" 2>/dev/null | awk '{print $1}'))"
|
||||
log_info " Enthalten: Code, $CONFIG_DIR, Template-Unit, Drop-ins, pip-freeze.txt"
|
||||
log_info " NICHT enthalten: venv und die Datenverzeichnisse (/var/lib/pdf-ocr-hotfolder)"
|
||||
log_warn " Achtung: die Instanz-Configs enthalten Klartext-Passwoerter"
|
||||
log_warn " (SMTP/Nextcloud/SFTP) — Archiv ist deshalb root-only (0600)."
|
||||
rotate_backups
|
||||
}
|
||||
|
||||
# Behaelt die letzten $BACKUP_KEEP Archive, loescht aeltere.
|
||||
rotate_backups() {
|
||||
local -a all=() old=()
|
||||
shopt -s nullglob
|
||||
mapfile -t all < <(printf '%s\n' "$BACKUP_DIR"/backup-*.tar.gz | sort)
|
||||
shopt -u nullglob
|
||||
[ "${#all[@]}" -gt "$BACKUP_KEEP" ] || return 0
|
||||
old=("${all[@]:0:$(( ${#all[@]} - BACKUP_KEEP ))}")
|
||||
local f
|
||||
for f in "${old[@]}"; do
|
||||
rm -f "$f" && log_info " Altes Backup entfernt: $(basename "$f")"
|
||||
done
|
||||
log_info " Rotation: ${BACKUP_KEEP} Backups behalten, ${#old[@]} entfernt."
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# systemd
|
||||
# ============================================================
|
||||
|
||||
install_units() {
|
||||
log_step "systemd-Units aktualisieren"
|
||||
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE"
|
||||
log_info "Template-Unit aktualisiert ✓"
|
||||
|
||||
# Issue #4 redux: existiert das LXC-Drop-in, muss es mit der Template-Unit
|
||||
# mitwachsen — sonst reisst ein neu ergaenzter Hardening-Schalter alle
|
||||
# Container-Instanzen (Error 226/NAMESPACE).
|
||||
if [ -f "$LXC_DROPIN" ]; then
|
||||
if [ -f "$REPO_DIR/systemd/lxc-compat.conf" ]; then
|
||||
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN"
|
||||
LXC_SYNCED=1
|
||||
log_info "LXC-Drop-in nachgezogen: $LXC_DROPIN ✓"
|
||||
else
|
||||
log_warn "systemd/lxc-compat.conf fehlt im Repo — Drop-in bleibt alt."
|
||||
fi
|
||||
elif systemd-detect-virt --container -q 2>/dev/null; then
|
||||
log_warn "Container-Umgebung erkannt, aber kein LXC-Drop-in installiert."
|
||||
log_warn "Bei Error 226/NAMESPACE: install.sh erneut laufen lassen oder"
|
||||
log_warn " cp '$REPO_DIR/systemd/lxc-compat.conf' '$LXC_DROPIN'"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# LIB_ONLY: bis hierher nur Definitionen. Mit
|
||||
# PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh
|
||||
# laesst sich alles oben isoliert testen, ohne dass etwas passiert.
|
||||
# ============================================================
|
||||
if [ "${PDF_OCR_UPDATE_LIB_ONLY:-0}" = "1" ]; then
|
||||
# shellcheck disable=SC2317 # 'exit' greift nur, wenn das Skript nicht gesourct wurde
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
INSTALL_DIR="/opt/pdf-ocr-hotfolder"
|
||||
CONFIG_DIR="/etc/pdf-ocr-hotfolder"
|
||||
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rebuild-venv) REBUILD_VENV=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) log_error "Unbekannte Option: $1"; echo; usage; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ "${EUID}" -ne 0 ]; then
|
||||
log_error "Bitte als root ausfuehren: sudo ./update.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
||||
@@ -29,11 +690,11 @@ elif [ -f "$INSTALL_DIR/.repo_path" ]; then
|
||||
REPO_DIR="$(cat "$INSTALL_DIR/.repo_path")"
|
||||
[ -d "$REPO_DIR" ] || { log_error "Gespeicherter Repo-Pfad existiert nicht: $REPO_DIR"; exit 1; }
|
||||
else
|
||||
log_error "Repo nicht gefunden. update.sh aus dem Repo ausführen."
|
||||
log_error "Repo nicht gefunden. update.sh aus dem Repo ausfuehren."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -d "$INSTALL_DIR" ] || { log_error "Installation nicht gefunden. Erst install.sh ausführen."; exit 1; }
|
||||
[ -d "$INSTALL_DIR" ] || { log_error "Installation nicht gefunden. Erst install.sh ausfuehren."; exit 1; }
|
||||
|
||||
OLD_VERSION="$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
NEW_VERSION="$(cat "$REPO_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
@@ -47,64 +708,153 @@ log_info "Install: $INSTALL_DIR"
|
||||
log_info "Version: $OLD_VERSION → $NEW_VERSION"
|
||||
echo
|
||||
|
||||
# Laufende Instanzen ermitteln
|
||||
mapfile -t RUNNING < <(systemctl list-units --no-legend --state=active 'pdf-ocr-hotfolder@*.service' 2>/dev/null | awk '{print $1}')
|
||||
if [ "${#RUNNING[@]}" -gt 0 ]; then
|
||||
log_info "Laufende Instanzen: ${RUNNING[*]}"
|
||||
# Ab hier kann ein Abbruch Instanzen gestoppt zuruecklassen.
|
||||
trap 'abort_handler $? $LINENO' ERR
|
||||
trap 'abort_handler 130 $LINENO' INT
|
||||
trap 'abort_handler 143 $LINENO' TERM
|
||||
|
||||
# --- Instanzen erfassen ---
|
||||
log_step "Instanzen erfassen"
|
||||
collect_instances
|
||||
report_instances
|
||||
|
||||
# --- Eigentuemer des Codes merken (vor dem venv-Neubau) ---
|
||||
if [ -d "$INSTALL_DIR/venv" ]; then
|
||||
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)"
|
||||
else
|
||||
log_info "Keine laufenden Instanzen."
|
||||
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR" 2>/dev/null || echo pdfocr)"
|
||||
fi
|
||||
[ "$PRIMARY_USER" = "root" ] && PRIMARY_USER="pdfocr"
|
||||
|
||||
# --- System-Pakete (auch beim Update, nicht nur bei install.sh) ---
|
||||
sync_system_packages
|
||||
|
||||
# --- venv-Gesundheit pruefen (vor dem Stoppen, damit man es frueh sieht) ---
|
||||
log_step "venv pruefen"
|
||||
NEED_REBUILD=0
|
||||
if [ "$REBUILD_VENV" -eq 1 ]; then
|
||||
log_info "--rebuild-venv gesetzt: venv wird in jedem Fall neu gebaut."
|
||||
NEED_REBUILD=1
|
||||
elif venv_is_healthy "$INSTALL_DIR/venv"; then
|
||||
log_info "venv ok ✓ (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
|
||||
else
|
||||
log_warn "venv passt nicht mehr:"
|
||||
for issue in "${VENV_ISSUES[@]}"; do
|
||||
log_warn " - $issue"
|
||||
done
|
||||
log_warn "Typische Ursache: Debian-Major-Upgrade (z.B. 12 -> 13). Die venv haengt"
|
||||
log_warn "am alten Interpreter, systemd quittiert das mit 203/EXEC."
|
||||
log_warn "-> venv wird neu gebaut."
|
||||
NEED_REBUILD=1
|
||||
fi
|
||||
|
||||
log_info "Stoppe laufende Instanzen..."
|
||||
for unit in "${RUNNING[@]}"; do
|
||||
systemctl stop "$unit" || true
|
||||
done
|
||||
# --- Ab hier wird angefasst ---
|
||||
log_step "Instanzen stoppen"
|
||||
stop_instances
|
||||
|
||||
log_info "Backup erstellen..."
|
||||
BACKUP_DIR="/var/backups/pdf-ocr-hotfolder"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
tar -czf "$BACKUP_DIR/backup-$(date +%Y%m%d-%H%M%S).tar.gz" \
|
||||
-C "$INSTALL_DIR" --exclude=venv --exclude=__pycache__ . 2>/dev/null || true
|
||||
create_backup
|
||||
|
||||
log_info "Code aktualisieren..."
|
||||
log_step "Code aktualisieren"
|
||||
TOUCHED=1
|
||||
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
||||
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
|
||||
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
||||
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
||||
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
|
||||
log_info "Code auf $NEW_VERSION ✓"
|
||||
|
||||
log_info "Dependencies aktualisieren..."
|
||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
|
||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" -q
|
||||
log_step "Dependencies aktualisieren"
|
||||
if [ "$NEED_REBUILD" -eq 1 ]; then
|
||||
rebuild_venv
|
||||
else
|
||||
pip_install_requirements "$INSTALL_DIR/venv" || die "Dependencies liessen sich nicht aktualisieren."
|
||||
log_info "Dependencies ok ✓"
|
||||
fi
|
||||
|
||||
log_info "systemd Template-Unit aktualisieren..."
|
||||
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
|
||||
systemctl daemon-reload
|
||||
install_units
|
||||
|
||||
log_info "Berechtigungen setzen..."
|
||||
# Eigentümer des Codes bleibt der primäre User (pdfocr); Instanzen laufen
|
||||
log_step "Berechtigungen setzen"
|
||||
# Eigentuemer des Codes bleibt der primaere User (pdfocr); Instanzen laufen
|
||||
# ggf. als anderer User, lesen aber nur den Code.
|
||||
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)"
|
||||
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
|
||||
log_info "Eigentuemer: $PRIMARY_USER"
|
||||
|
||||
log_info "Starte Instanzen wieder..."
|
||||
FAIL=0
|
||||
for unit in "${RUNNING[@]}"; do
|
||||
systemctl start "$unit" || true
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
log_info " ✅ $unit"
|
||||
check_all_configs
|
||||
|
||||
log_step "Instanzen starten"
|
||||
start_instances
|
||||
|
||||
# ============================================================
|
||||
# Zusammenfassung: Ist gegen Soll
|
||||
# ============================================================
|
||||
trap - ERR INT TERM
|
||||
|
||||
RC=0
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo " Zusammenfassung"
|
||||
echo "=========================================="
|
||||
log_info "Version: $OLD_VERSION → $NEW_VERSION"
|
||||
log_info "Backup: ${BACKUP_FILE:-keines}"
|
||||
[ "$VENV_REBUILT" -eq 1 ] && log_info "venv: neu gebaut (Python $(py_mm "$INSTALL_DIR/venv/bin/python"))"
|
||||
[ "$LXC_SYNCED" -eq 1 ] && log_info "LXC: Drop-in nachgezogen"
|
||||
|
||||
SOLL=$(( ${#PREV_OK[@]} + ${#PREV_BROKEN[@]} ))
|
||||
log_info "Soll: $SOLL Instanz(en) sollen laufen | Ist: ${#STARTED_OK[@]} laufen"
|
||||
|
||||
if [ "${#PREV_STOPPED[@]}" -gt 0 ]; then
|
||||
log_info "Gestoppt gelassen: ${PREV_STOPPED[*]}"
|
||||
fi
|
||||
|
||||
# Vorher kaputte Instanzen, die auch jetzt nicht laufen: klar benennen.
|
||||
FIXED=(); STILL_BROKEN=()
|
||||
for unit in "${PREV_BROKEN[@]:-}"; do
|
||||
[ -n "$unit" ] || continue
|
||||
if printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit"; then
|
||||
FIXED+=("$unit")
|
||||
else
|
||||
log_error " ❌ $unit — journalctl -u $unit -n 30"
|
||||
FAIL=1
|
||||
STILL_BROKEN+=("$unit")
|
||||
fi
|
||||
done
|
||||
[ "${#FIXED[@]}" -gt 0 ] && log_info "Vorher kaputt, laeuft jetzt: ${FIXED[*]}"
|
||||
|
||||
if [ "${#STILL_BROKEN[@]}" -gt 0 ]; then
|
||||
log_error "Vorher kaputt und immer noch kaputt: ${STILL_BROKEN[*]}"
|
||||
RC=1
|
||||
fi
|
||||
|
||||
VORHER_OK_JETZT_NICHT=()
|
||||
for unit in "${PREV_OK[@]:-}"; do
|
||||
[ -n "$unit" ] || continue
|
||||
printf '%s\n' "${STARTED_OK[@]:-}" | grep -qxF "$unit" || VORHER_OK_JETZT_NICHT+=("$unit")
|
||||
done
|
||||
if [ "${#VORHER_OK_JETZT_NICHT[@]}" -gt 0 ]; then
|
||||
log_error "REGRESSION — lief vorher, laeuft jetzt nicht: ${VORHER_OK_JETZT_NICHT[*]}"
|
||||
log_error "Rollback: tar -xzf '${BACKUP_FILE:-<backup>}' -C / && systemctl daemon-reload"
|
||||
RC=1
|
||||
fi
|
||||
|
||||
if [ "${#CFG_ERR[@]}" -gt 0 ]; then
|
||||
log_error "Config-FEHLER (Exit 2) bei: ${CFG_ERR[*]}"
|
||||
log_error " Diese Instanzen gelten NICHT als erfolgreich aktualisiert."
|
||||
RC=1
|
||||
fi
|
||||
if [ "${#CFG_WARN[@]}" -gt 0 ]; then
|
||||
log_warn "Config-Warnungen (Exit 1) bei: ${CFG_WARN[*]}"
|
||||
log_warn " Kein Abbruchgrund, aber bitte nachsehen:"
|
||||
for name in "${CFG_WARN[@]}"; do
|
||||
log_warn " $INSTALL_DIR/venv/bin/python -m pdf_ocr_hotfolder --check-config --config $CONFIG_DIR/$name.toml"
|
||||
done
|
||||
fi
|
||||
if [ "$APT_WARN" -eq 1 ]; then
|
||||
log_warn "System-Pakete konnten nicht vollstaendig abgeglichen werden (siehe oben)."
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
log_info "Update auf $NEW_VERSION abgeschlossen ✓"
|
||||
else
|
||||
log_warn "Update abgeschlossen, aber mindestens eine Instanz läuft nicht."
|
||||
exit 1
|
||||
log_error "Update abgeschlossen, aber mit Problemen (siehe oben)."
|
||||
fi
|
||||
exit "$RC"
|
||||
|
||||
Reference in New Issue
Block a user