Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b67c846a2 | |||
| 465ff8873f | |||
| cd803a3dfe | |||
| 305454eeb5 | |||
| 04dc3c7b72 | |||
| aa9918adba | |||
| 3e24aa2ecd | |||
| 2062476252 | |||
| 8da0b7da1c | |||
| 578472872e | |||
| cbdc9d6664 | |||
| a23a3968ef | |||
| 9cdc9ae443 |
+368
-54
@@ -1,8 +1,15 @@
|
|||||||
# AI Agent Briefing — PDF OCR Hotfolder
|
# AI Agent Briefing — PDF OCR Hotfolder
|
||||||
|
|
||||||
**Zuletzt aktualisiert:** 2026-04-08
|
**Zuletzt aktualisiert:** 2026-09-23
|
||||||
**Version:** 0.2.0
|
**Version:** 0.7.2
|
||||||
**Status:** Multi-Instanz-Support, nicht produktiv getestet
|
**Status:** Multi-Instanz-Betrieb, Preflight-Checks (inkl. veraPDF-Binary), Fehlerzählung, Wiederaufnahme aus `working/`, Kollisionsschutz auf allen Schreibpfaden, Bewachung des Verzeichnis-Watches und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). `install.sh` und `update.sh` teilen sich `lib/common.sh`. Test-Suite grün (**254 pytest-Tests**). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, aus Vorbereitungen auf Debian 13 und aus einer Durchsicht auf stille Datenverlust-Pfade (0.7.0), 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
|
## 🎯 Projektziel
|
||||||
|
|
||||||
@@ -13,21 +20,54 @@ Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in
|
|||||||
```
|
```
|
||||||
pdf-ocr-hotfolder/
|
pdf-ocr-hotfolder/
|
||||||
├── pdf_ocr_hotfolder/
|
├── pdf_ocr_hotfolder/
|
||||||
│ ├── __init__.py # Versionsstring
|
│ ├── __init__.py # Versionsstring (__version__)
|
||||||
│ ├── __main__.py # CLI-Entrypoint (argparse, --once, --config)
|
│ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version)
|
||||||
│ ├── config.py # TOML-Loader, Dataclasses
|
│ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen
|
||||||
│ ├── service.py # Hauptservice (watchdog + ThreadPool)
|
│ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung
|
||||||
│ ├── processor.py # ocrmypdf + veraPDF
|
│ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz
|
||||||
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, email
|
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify
|
||||||
|
├── lib/
|
||||||
|
│ └── common.sh # gemeinsam fuer install.sh + update.sh: Logging, require_root,
|
||||||
|
│ # Layout-Konstanten, apt-Paketliste, venv_is_healthy()
|
||||||
|
├── tests/ # pytest-Suite (254 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_dispose_failure.py # Original nicht entsorgbar -> Erfolg + warning
|
||||||
|
│ ├── test_error_collision.py # error/ ueberschreibt nichts
|
||||||
|
│ ├── test_error_counting.py
|
||||||
|
│ ├── test_ghostscript_version.py
|
||||||
|
│ ├── test_incoming_non_pdf.py # Sammelmeldung fuer Fremddateien
|
||||||
|
│ ├── test_log_stream.py # Logging geht nach stdout
|
||||||
|
│ ├── test_observer_watchdog.py # toter Observer -> EXIT_OBSERVER_DEAD
|
||||||
|
│ ├── test_ocr_timeout.py
|
||||||
|
│ ├── test_once_exit_code.py
|
||||||
|
│ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts
|
||||||
|
│ ├── test_output_naming.py
|
||||||
|
│ ├── test_preflight.py
|
||||||
|
│ ├── test_relative_paths.py # relative Pfade -> ConfigError
|
||||||
|
│ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente
|
||||||
|
│ ├── test_startup_toml_error.py # kaputtes TOML beim Dienststart -> Exit 2
|
||||||
|
│ ├── test_upload_folder.py
|
||||||
|
│ └── test_verapdf_preflight.py # Binary-Pruefung + VeraPdfUnavailable
|
||||||
├── systemd/
|
├── systemd/
|
||||||
│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i)
|
│ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300,
|
||||||
|
│ │ # RestartPreventExitStatus=2
|
||||||
|
│ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten
|
||||||
|
├── docs/
|
||||||
|
│ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting
|
||||||
|
│ ├── 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
|
├── config.example.toml
|
||||||
├── install.sh # Interaktiver Installer
|
├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen
|
||||||
├── update.sh # Update aus Repo
|
├── update.sh # Updater (--help, --rebuild-venv, --no-smoke-test), ~1270 Zeilen
|
||||||
├── requirements.txt
|
├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1)
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── CHANGELOG.md
|
├── CHANGELOG.md
|
||||||
└── README.md
|
├── README.md
|
||||||
|
└── AI_AGENT_BRIEFING.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Stack
|
## 🔧 Stack
|
||||||
@@ -35,25 +75,45 @@ pdf-ocr-hotfolder/
|
|||||||
| Komponente | Technologie |
|
| Komponente | Technologie |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| Sprache | Python 3.11+ (für `tomllib` aus stdlib) |
|
| 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 |
|
| Engine | Tesseract |
|
||||||
| Watcher | `watchdog` |
|
| Watcher | `watchdog` |
|
||||||
| HTTP | `requests` (Nextcloud WebDAV) |
|
| HTTP | `requests` (Nextcloud WebDAV) |
|
||||||
| SFTP | `paramiko` |
|
| SFTP | `paramiko` |
|
||||||
| Email | `smtplib` (stdlib) |
|
| 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)
|
## 🖥️ Installations-Layout (Multi-Instanz)
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
||||||
|
| `/opt/pdf-ocr-hotfolder/lib/common.sh` | Kopie der gemeinsamen Shell-Bibliothek; `update.sh` sourct sie, wenn es nicht aus dem Repo läuft |
|
||||||
|
| `/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/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` | 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) |
|
| `/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/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 (0600, letzte 5) |
|
||||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
|
|
||||||
|
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. Der Stream wird seit 0.7.0
|
||||||
|
**explizit** auf `sys.stdout` gesetzt — der `basicConfig()`-Default ist
|
||||||
|
**stderr**, und README wie `docs/INSTALLATION.md` versprachen stdout.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u pdf-ocr-hotfolder@<instanz> -f # eine Instanz mitlesen
|
||||||
|
journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute
|
||||||
|
```
|
||||||
|
|
||||||
## 👤 Service-User
|
## 👤 Service-User
|
||||||
|
|
||||||
@@ -63,51 +123,265 @@ pdf-ocr-hotfolder/
|
|||||||
- Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via `id -gn` ermittelt
|
- 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
|
- 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)
|
- Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer**
|
||||||
- Folgender Lauf: Basis-Install wird übersprungen, bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden
|
die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut
|
||||||
- Eingaben pro Instanz: Name (`[a-z0-9-]+`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`), Service-User
|
(`venv_is_healthy()` aus `lib/common.sh`, Befunde über `report_venv_issues`).
|
||||||
- `config.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert
|
- In Containern (`systemd-detect-virt --container`) bietet der Installer das
|
||||||
- Instanz wird sofort `enable --now` gestartet
|
LXC-Drop-in an **und** prüft, ob `systemd-journald` läuft. Tut es das nicht,
|
||||||
|
warnt er (der Dienst loggt ausschließlich nach journald), nennt den
|
||||||
|
`ImportCredential=`-Drop-in für den Debian-13-Fall und fragt, ob fortgefahren
|
||||||
|
werden soll.
|
||||||
|
- 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 `lib/common.sh` zwischen den
|
||||||
|
Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion
|
||||||
|
`pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen;
|
||||||
|
**`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung
|
||||||
|
heraus und evaluiert ihn**, weil gesourct evtl. die ältere installierte Kopie
|
||||||
|
wurde. 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:
|
## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek
|
||||||
```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
|
Seit 0.7.0 sourcen `install.sh` und `update.sh` dieselbe Datei. Sie führt beim
|
||||||
|
Sourcen **nichts** aus, was das System anfasst, und enthält nur Definitionen:
|
||||||
|
|
||||||
`update.sh`:
|
| Inhalt | Details |
|
||||||
1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units
|
|--------|---------|
|
||||||
2. Stoppt diese
|
| Ausgabe | `log_info`/`log_warn`/`log_error`/`log_step`, Farbkonstanten |
|
||||||
3. Backup nach `/var/backups/pdf-ocr-hotfolder/`
|
| Rechte | `require_root "<gemeinter Aufruf>"` |
|
||||||
4. Kopiert Code + requirements + VERSION + config.example aus dem Repo
|
| Layout | `INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN_DIR`, `LXC_DROPIN`, `COMMON_LIB_REL` — alle per `: "${X:=…}"`, also aus der Umgebung überschreibbar (Tests) |
|
||||||
5. `pip install --upgrade` im venv
|
| Pakete | `pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken |
|
||||||
6. Aktualisiert Template-Unit + `daemon-reload`
|
| venv | `py_mm()`, `pyvenv_cfg_mm()`, `venv_is_healthy()`, `report_venv_issues()` |
|
||||||
7. Startet alle zuvor aktiven Instanzen wieder
|
|
||||||
8. Exit 1 wenn eine Instanz nicht mehr hochkommt
|
|
||||||
|
|
||||||
Config-Dateien werden **nie** überschrieben.
|
Drei Dinge, die man dabei wissen muss:
|
||||||
|
|
||||||
|
- **`venv_is_healthy()` gibt es nur noch einmal.** Vorher hatte jedes der beiden
|
||||||
|
Skripte eine eigene Fassung, und die in `install.sh` war die schlankere: sie
|
||||||
|
verglich nur `major.minor` des venv-Interpreters mit dem System-Python und
|
||||||
|
hätte den Distro-Upgrade-Fall über `pyvenv.cfg` nicht bemerkt. Erhalten
|
||||||
|
geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter,
|
||||||
|
Interpreter **läuft**, Version == System-Python, `pyvenv.cfg` == Interpreter).
|
||||||
|
Sie setzt `VENV_ISSUES` und gibt nichts selbst aus — dafür ist
|
||||||
|
`report_venv_issues()` da.
|
||||||
|
- **`lib/` wird mitinstalliert.** `install.sh` **und** `update.sh` kopieren es
|
||||||
|
nach `/opt/pdf-ocr-hotfolder/lib/`, jeweils mit vorherigem
|
||||||
|
`rm -rf "${INSTALL_DIR:?}/lib"`. Es liegt damit auch im Update-Backup (das
|
||||||
|
sichert `$INSTALL_DIR` ohne venv).
|
||||||
|
- **Fundreihenfolge in `update.sh`:** erst `$SCRIPT_DIR/lib/common.sh` (Repo),
|
||||||
|
dann `${INSTALL_DIR}/lib/common.sh`. Fehlt sie überall, bricht das Skript
|
||||||
|
**sofort** ab — ein `command not found` mitten im Lauf wäre die schlechtere
|
||||||
|
Nachricht. `install.sh` sucht nur neben sich und verlangt das vollständige
|
||||||
|
Repo.
|
||||||
|
|
||||||
|
## 🔄 Update-Verhalten (Kurzfassung)
|
||||||
|
|
||||||
|
Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig:
|
||||||
|
|
||||||
|
- `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()` aus `lib/common.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 (im Archiv unter
|
||||||
|
`opt/pdf-ocr-hotfolder/`, damit es beim Entpacken nach `/` nicht im
|
||||||
|
Wurzelverzeichnis landet). **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. Die
|
||||||
|
Vorgaben stehen in `lib/common.sh` und sind dort ebenfalls überschreibbar
|
||||||
|
gehalten (`: "${X:=…}"`).
|
||||||
|
|
||||||
|
## ⚙️ 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** und **absolut**, fehlt einer oder ist relativ → `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` (absolut) |
|
||||||
|
| `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI |
|
||||||
|
| `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op; sonst absolut) |
|
||||||
|
| `[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 |
|
||||||
|
|
||||||
|
**Absolute Pfade sind Pflicht** (`_require_absolute()` in `config.py`, seit
|
||||||
|
0.7.0): `[paths]`-Einträge, `[output].archive_dir` und
|
||||||
|
`[upload.folder].target`. Ein relativer Pfad wurde gegen das
|
||||||
|
`WorkingDirectory` der Unit aufgelöst, landete also still unter
|
||||||
|
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
|
||||||
|
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte bleiben
|
||||||
|
erlaubt (beide Keys sind optional).
|
||||||
|
|
||||||
|
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 sowie veraPDF-Binary und -Flavour (bzw.
|
||||||
|
`(aus)`), 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.
|
||||||
|
|
||||||
|
### Exit-Codes des Prozesses
|
||||||
|
|
||||||
|
| Code | Woher | Bedeutung |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| `0` | `main()` | regulärer Stopp, `--once` ohne Fehler, Config sauber |
|
||||||
|
| `1` | `main()` | `--once` mit `error_count > 0`; `--check-config` mit Warnungen |
|
||||||
|
| `2` | `main()` | `ConfigError`, `TOMLDecodeError`, `OSError` beim Laden, `PreflightError` |
|
||||||
|
| `3` | `service.EXIT_OBSERVER_DEAD` | watchdog-Observer gestorben — Neustart **erwünscht** |
|
||||||
|
|
||||||
|
`run()` gibt seit 0.7.0 einen `int` zurück (vorher `None`), `main()` reicht ihn
|
||||||
|
durch. Die Unit setzt **`RestartPreventExitStatus=2`**: Config-/Preflight-Fehler
|
||||||
|
heilt kein Neustart, die Instanz bleibt sichtbar `failed` stehen statt im
|
||||||
|
5-Sekunden-Takt zu kreisen (das Start-Rate-Limit greift bei `RestartSec=5` nie).
|
||||||
|
Exit 3 ist **bewusst nicht** 2, damit `Restart=on-failure` dort greift.
|
||||||
|
Anwender-Sicht: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes).
|
||||||
|
|
||||||
## 🔄 Verarbeitungs-Flow
|
## 🔄 Verarbeitungs-Flow
|
||||||
|
|
||||||
1. `watchdog` triggert auf Datei-Event in `incoming/`
|
**Beim Start (`run()` wie `run_once()`), vor allem anderen** — beide rufen
|
||||||
2. `_wait_until_stable()` wartet, bis Datei nicht mehr wächst (Scanner schreibt mehrmals)
|
dasselbe `_preflight()`:
|
||||||
3. Move nach `working/`
|
1. `check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17))
|
||||||
4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF — schneller)
|
2. `check_verapdf_binary()` — nur bei `[verapdf].enabled`: `binary` darf nicht leer sein und muss über `resolve_verapdf_binary()` auffindbar **und ausführbar** sein (Pfad mit `/` direkt geprüft, nackter Name über `shutil.which`)
|
||||||
5. Optional: veraPDF-Validierung (CLI-Subprozess)
|
3. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode`
|
||||||
6. Move nach `outgoing/` als `OCR_<originalname>.pdf`
|
4. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/unlesbarer/fehlender Config)
|
||||||
7. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
|
5. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`; Dateien ohne `.pdf`-Endung meldet `_report_non_pdf()` als **eine** Sammelzeile (Anzahl + bis zu 3 Beispiele), nur beim Start-Scan
|
||||||
8. Optional E-Mail-Notify
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
**Im Betrieb (`_wait_loop()`):** die Schleife wartet nicht nur auf den Stopp,
|
||||||
|
sie prüft **sekündlich `self._observer.is_alive()`**. Stirbt der Observer
|
||||||
|
(erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes
|
||||||
|
Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm
|
||||||
|
nichts mehr. Jetzt: `log.error` mit den möglichen Ursachen und `return
|
||||||
|
EXIT_OBSERVER_DEAD` (3), damit `Restart=on-failure` den Watch neu aufsetzt. Bei
|
||||||
|
regulärem Stopp wird die Prüfung übersprungen, sonst gäbe es dort einen
|
||||||
|
Fehlalarm.
|
||||||
|
|
||||||
|
**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). **FAIL** → OCR-Ergebnis nach `error/`, Original folgt `original_on_success` (bei `archive` also erhalten). **`VeraPdfUnavailable`** → Original **und** Ergebnis nach `error/`, das Original wird weder gelöscht noch archiviert
|
||||||
|
6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`), vorher durch `_collision_free_path()` — `ProcessResult.output` trägt den **tatsächlich** geschriebenen Pfad
|
||||||
|
7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix). `_dispose_original()` wirft nicht, sondern liefert bei Misserfolg einen Meldungstext → `ProcessResult.warning`
|
||||||
|
8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
|
||||||
|
9. E-Mail-Notify je nach `[notify.email].on` — bei gesetztem `warning` als **„OK mit Warnung"** und mit `success=False` an `notify_email()`, damit sie auch bei `on = "errors"` zugestellt wird
|
||||||
|
|
||||||
|
**Kollisionsschutz (`_collision_free_path()` in `processor.py`):** existiert das
|
||||||
|
Ziel, wird `scan.pdf` zu `scan_<YYYYmmdd-HHMMSS>.pdf`; ist auch das belegt (zwei
|
||||||
|
Dateien in derselben Sekunde, mehrere Worker), wird zusätzlich hochgezählt.
|
||||||
|
Benutzt von `outgoing/`, `_dispose_original()` (Archiv), `_move_to_error()` —
|
||||||
|
und damit auch `_rescue_to_error()` — sowie `upload_folder()` in
|
||||||
|
`uploaders.py`. Jeder dieser Pfade überschrieb vorher still. **`uploaders.py`
|
||||||
|
importiert dafür `_collision_free_path` aus `processor.py`** — die einzige
|
||||||
|
Abhängigkeit in diese Richtung.
|
||||||
|
|
||||||
|
**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) |
|
||||||
|
| veraPDF nicht befragbar (`VeraPdfUnavailable`) | ja | **Original UND Ergebnis** nach `error/`; das Original wird weder gelöscht noch archiviert (seit 0.7.0) |
|
||||||
|
| Original lässt sich nicht entsorgen (`_dispose_original`) | **nein** — gilt als Erfolg | Ergebnis in `outgoing/`, Upload läuft; Original bleibt in `working/` und wird beim nächsten Start erneut verarbeitet. `log.error` + Mail „OK mit Warnung" |
|
||||||
|
| 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
|
## 🧠 Performance-Entscheidungen
|
||||||
|
|
||||||
@@ -116,8 +390,32 @@ Fehler → Move nach `error/`, Service läuft weiter (kein `exit 1` wie im alten
|
|||||||
- **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs
|
- **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs
|
||||||
- **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet
|
- **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet
|
||||||
- **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke)
|
- **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)
|
- 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. ocrmypdf verweigert damit die Arbeit — aber die Bedingung dafür hängt an der **ocrmypdf-Version**, und genau daran ist 0.6.0 gescheitert:
|
||||||
|
- **ocrmypdf ≤ 16.x**: die Prüfung in `builtin_plugins/ghostscript.py::check_options()` läuft **bedingungslos**. `skip_text = true` allein reicht — `output_type` wird nicht geprüft. Auf Debian 12 scheitert damit **jede** Datei.
|
||||||
|
- **ocrmypdf ≥ 17.0**: derselbe Block steckt in einem `if options.output_type.startswith('pdfa'):`. Ohne PDF/A wird Ghostscript nicht angefasst.
|
||||||
|
|
||||||
|
`pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key.
|
||||||
|
|
||||||
|
**Abhilfe — und die Falle dabei:** `pdfa_level = ""` lassen (Default), `skip_text = false` setzen, oder eine Distribution mit neuerem Ghostscript (**Debian 13: 10.05.1**). **Ein Ghostscript-Upgrade auf Debian 12 gibt es nicht** — `bookworm-backports` enthält kein Ghostscript-Paket (am echten Paketindex geprüft: 2606 Pakete, `ghostscript` nicht darunter). Bis einschließlich v0.7.0 haben Preflight-Meldung, Config-Warnung, `config.example.toml`, README und alle drei docs-Dateien genau dieses Upgrade empfohlen — ein Rat, der nie funktionieren konnte. Falls er irgendwo wieder auftaucht: **ersatzlos streichen**. Planungsaussage für den Admin: **wer auf Debian 12 PDF/A mit `skip_text = true` braucht, hat dort keinen Weg** — das muss *vor* der Installation entschieden werden, nicht beim Preflight-Abbruch.
|
||||||
|
- **`[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 beide Skripte das mit **demselben** `venv_is_healthy()` aus `lib/common.sh` (seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die in `install.sh` war die schwächere).
|
||||||
|
- **`incoming/` darf nicht auf CIFS/NFS liegen.** Der Hotfolder hängt vollständig an inotify, und inotify sieht nur Änderungen des lokalen Kernels. Schreibt ein anderer Rechner über SMB/NFS in ein gemountetes Verzeichnis, entsteht **gar kein Event** — der Dienst meldet `active (running)`, arbeitet beim Start-Scan den Bestand ab und bemerkt danach nichts mehr. Betriebsvorgabe: ext4, xfs oder zfs, `incoming/` lokal ([docs/INSTALLATION.md](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
|
||||||
|
- **Debian 13 in LXC auf Proxmox: journald scheitert mit `243/CREDENTIALS`.** systemd ≥ 255 (Debian 13 hat 257) setzt `ImportCredential=journal.*`; der Hilfsprozess `(sd-mkdcreds)` mountet dafür, und das AppArmor-Profil des Proxmox-Hosts blockiert das. Da der Dienst ausschließlich nach journald loggt, gibt es dann **keine** Logs. Betrifft jede Debian-13-LXC auf Proxmox 8.4 (Debian 12 mit systemd 252 nicht) und legt auch logind, networkd, console-getty und tmpfiles-setup lahm. Abhilfe und Hintergrund: [docs/INSTALLATION.md](docs/INSTALLATION.md#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials). In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch. **Nach der Reparatur die Instanzen einmal neu starten** (`systemctl restart 'pdf-ocr-hotfolder@*'`): wer gestartet wurde, während journald tot war, hat danach ein leeres Journal (`-- No entries --`) — das sieht aus wie eine gescheiterte Reparatur, ist aber nur der fehlende Neustart.
|
||||||
|
- **Ein nicht aufrufbares veraPDF war bis 0.6.3 der gefährlichste Fehler des Dienstes.** `run_verapdf()` lieferte für ein fehlendes Binary, einen Timeout oder eine leere Ausgabe schlicht `False` — also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nach `error/` und das Original wurde laut `original_on_success` entsorgt, beim Default `delete` also gelöscht. Scan für Scan, bei grünem `systemctl status`. Seit 0.7.0: Preflight-Prüfung (Exit 2) **und** `VeraPdfUnavailable` als eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist.
|
||||||
|
- **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.
|
||||||
|
- **Relative Pfade in der Config waren still falsch.** Sie wurden gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` aufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0 `ConfigError` + Exit 2 für `[paths]`, `[output].archive_dir`, `[upload.folder].target`. **Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" Instanz stoppt** — gewollt, siehe [docs/UPDATE.md](docs/UPDATE.md#relative-pfade--fehler-seit-070).
|
||||||
|
- **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).
|
||||||
|
- **Auf einem frischen Proxmox-Debian-Template (12 und 13) fehlen `sudo` und `git`.** `sudo ./install.sh` scheitert dort mit `sudo: command not found` — als `root` direkt (`./install.sh`) laeuft alles; die Skripte brauchen root-Rechte, nicht `sudo`. Und ohne `git` gibt es keinen Clone. Beides steht jetzt in den Voraussetzungen ([docs/INSTALLATION.md](docs/INSTALLATION.md#pakete-die-auf-einem-frischen-system-fehlen-können)). Wer Doku aendert: `sudo` **nicht** ueberall streichen — die meisten Admins haben es; beide Wege nennen.
|
||||||
|
- **`systemctl start` kann kein Glob.** `stop 'pdf-ocr-hotfolder@*'` trifft alle laufenden Instanzen (die Units sind geladen), `start 'pdf-ocr-hotfolder@*'` versucht eine Instanz namens `*` zu starten und scheitert. Nach einem Rollback muss deshalb **jede Instanz einzeln** gestartet werden ([docs/UPDATE.md](docs/UPDATE.md#rollback)). `restart` geht wieder mit Glob.
|
||||||
|
|
||||||
## 🛠️ Entwicklung
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
Lokaler Test ohne Installation:
|
Lokaler Test ohne Installation:
|
||||||
@@ -130,11 +428,25 @@ cp config.example.toml /tmp/config.toml
|
|||||||
python -m pdf_ocr_hotfolder --config /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 254 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. veraPDF wird über `subprocess.run` gemockt, der watchdog-Observer über ein Fake-Objekt mit `is_alive()`.
|
||||||
|
|
||||||
## 📋 Roadmap / TODO
|
## 📋 Roadmap / TODO
|
||||||
|
|
||||||
- [ ] Tests (`pytest`) für `processor` und `uploaders`
|
- [x] Tests (`pytest`) für `processor` und `uploaders` — 254 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)
|
||||||
|
- [x] Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in `outgoing/`, Archiv, `error/` und Ordner-Upload; veraPDF-Preflight + `VeraPdfUnavailable`; relative Pfade als Config-Fehler
|
||||||
|
- [x] Toter watchdog-Observer wird erkannt (Exit 3) und getestet
|
||||||
|
- [ ] Test-Lücken schließen: `run_ocr()` läuft nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Der `_Handler`-Eventpfad ist weiterhin ungetestet (getestet ist nur die Observer-**Bewachung** in `_wait_loop()`). `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh`/`lib/common.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt; `lib/common.sh` wäre jetzt die einfachste Stelle zum Anfangen, weil sie beim Sourcen nichts tut.
|
||||||
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
|
- [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit)
|
||||||
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
|
- [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess <error-file>`
|
||||||
|
- [ ] Instanz-Löschung in `install.sh` statt als Handarbeit
|
||||||
- [ ] Optional: S3/MinIO Upload-Target
|
- [ ] Optional: S3/MinIO Upload-Target
|
||||||
- [ ] Docker-Image für Setups ohne systemd
|
- [ ] Docker-Image für Setups ohne systemd
|
||||||
|
|
||||||
@@ -142,6 +454,8 @@ python -m pdf_ocr_hotfolder --config /tmp/config.toml
|
|||||||
|
|
||||||
- **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
- **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||||
- **Owner:** sonith_ug
|
- **Owner:** sonith_ug
|
||||||
|
- **Clone per HTTPS (Normalfall, ohne Credentials):** `https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git` — das ist der Weg, den die Installations-Doku nennt und der im Erstinstallations-Test benutzt wurde.
|
||||||
|
- **SSH nur mit Deploy-Key — und der 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)
|
- **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell)
|
||||||
- **Tags:** `v{VERSION}`, automatischer Push nach Commit
|
- **Tags:** `v{VERSION}`, automatischer Push nach Commit
|
||||||
|
|
||||||
|
|||||||
+697
@@ -1,5 +1,702 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.7.2] - 2026-09-23
|
||||||
|
|
||||||
|
Nachlese aus der Verifikation von 0.7.1 auf einem frischen Debian-12-Container.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `update.sh` meldete "LXC-Drop-in nachgezogen ✓" auch dann, wenn die Datei
|
||||||
|
bereits identisch war — dieselbe Sorte Unwahrheit wie das
|
||||||
|
Ghostscript-Haekchen in 0.7.0. Es wird jetzt verglichen und nur gemeldet,
|
||||||
|
was tatsaechlich passiert ist ("bereits aktuell" vs. "nachgezogen").
|
||||||
|
- Die Zeile "Quelle wieder entfernt" gehoert zur schlechten Nachricht und
|
||||||
|
kommt jetzt als WARN statt als INFO.
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-09-23
|
||||||
|
|
||||||
|
Gefunden beim ersten echten Erstinstallations-Test auf frischen Debian-12-
|
||||||
|
und Debian-13-Containern. Der Installationsweg selbst hat getragen; diese
|
||||||
|
drei Stellen haben gelogen oder gefehlt.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Das Ghostscript-Angebot auf Debian 12 war ein No-Op, der sich als Erfolg
|
||||||
|
meldete** (`Ghostscript aktualisiert: 10.00.0 -> 10.00.0 ✓`). Grund:
|
||||||
|
`bookworm-backports` enthaelt ueberhaupt kein `ghostscript` — am echten
|
||||||
|
Paketindex verifiziert (2606 Pakete, ghostscript nicht dabei, Kontroll-
|
||||||
|
pakete wie systemd/golang-go sehr wohl). Zurueck blieb eine nutzlose
|
||||||
|
`sources.list.d`-Quelle. Die Routine (jetzt `check_ghostscript` in
|
||||||
|
`lib/common.sh`) sucht nun erst den tatsaechlichen Kandidaten, vergleicht
|
||||||
|
die Version vorher/nachher, meldet "unveraendert" als Warnung statt als
|
||||||
|
Erfolg und entfernt eine nur zur Probe angelegte Quelle wieder. Eine
|
||||||
|
bereits vorhandene Backports-Quelle bleibt unangetastet.
|
||||||
|
- **Der Rat "Ghostscript aus bookworm-backports" ist ueberall raus** — er
|
||||||
|
stand auch in der Preflight-Fehlermeldung, der pdfa_level-Warnung,
|
||||||
|
config.example.toml und vier Doku-Dateien. Ersetzt durch die echten
|
||||||
|
Optionen: pdfa_level leer lassen (Default), skip_text = false, oder
|
||||||
|
Debian 13 (gs 10.05.1). Ein Test prueft negativ, dass der Rat nicht
|
||||||
|
zurueckkommt.
|
||||||
|
- **Mehrzeilige Log-Hinweise waren zerrissen und nicht kopierbar**: die
|
||||||
|
log-Funktionen nutzten `echo -e`, wodurch `\n` in Hinweistexten zu echten
|
||||||
|
Zeilenumbruechen ohne `[WARN]`-Praefix wurden. Jetzt `printf` mit `%s`
|
||||||
|
fuer den Text — das Problem kann strukturell nicht wiederkommen.
|
||||||
|
- **`pip-freeze.txt` landete beim Rollback im Wurzelverzeichnis.** Sie liegt
|
||||||
|
im Backup jetzt unter `opt/pdf-ocr-hotfolder/` und damit nach dem
|
||||||
|
Entpacken neben der Installation.
|
||||||
|
|
||||||
|
### Added (Doku)
|
||||||
|
- Voraussetzungen, die auf einem frischen Proxmox-Debian-Template fehlen:
|
||||||
|
**`git` und `sudo`** sind dort nicht installiert — der dokumentierte
|
||||||
|
Aufruf `sudo ./install.sh` scheitert mit `sudo: command not found`.
|
||||||
|
Beide Wege (root direkt / sudo) sind jetzt beschrieben.
|
||||||
|
- HTTPS- statt SSH-Clone als Normalfall (funktioniert ohne Credentials);
|
||||||
|
SSH-Variante mit dem Hinweis, dass der User `gitea` heisst, nicht `git`.
|
||||||
|
- Entscheidungstabelle zu PDF/A auf Debian 12 vs. 13. Praezisierung: die
|
||||||
|
Sackgasse ist PDF/A **zusammen mit** `skip_text = true`; mit
|
||||||
|
`skip_text = false` geht PDF/A auch auf Debian 12, nur langsamer.
|
||||||
|
- Rollback: `systemctl start` kann kein Glob (anders als `stop`), bei
|
||||||
|
mehreren Instanzen jede einzeln starten. Dazu, was ein Rollback
|
||||||
|
nachweislich zurueckholt und was nicht.
|
||||||
|
- journald-Reparatur: die Instanzen danach einmal neu starten, sonst bleibt
|
||||||
|
das Journal leer und die Reparatur sieht gescheitert aus.
|
||||||
|
- "So sieht ein Erstlauf aus" — die Reihenfolge der Abfragen.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-23
|
||||||
|
|
||||||
|
Schliesst die stillen Datenverlust-Pfade: gleichnamige Dateien werden nirgends
|
||||||
|
mehr ueberschrieben, und ein nicht aufrufbares veraPDF wird nicht mehr als
|
||||||
|
"PDF ist ungueltig" missverstanden. Dazu Robustheit (Exit 2 statt Traceback,
|
||||||
|
toter Verzeichnis-Watch faellt auf, relative Pfade sind ein Config-Fehler) und
|
||||||
|
eine gemeinsame Shell-Bibliothek fuer install.sh und update.sh.
|
||||||
|
Test-Suite: 254 pytest-Tests gruen.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`lib/common.sh` — gemeinsame Shell-Bibliothek.** `install.sh` und
|
||||||
|
`update.sh` sourcen sie und teilen sich darueber Log-Funktionen
|
||||||
|
(`log_info`/`log_warn`/`log_error`/`log_step`), `require_root`, die
|
||||||
|
Layout-Konstanten (`INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`,
|
||||||
|
`DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN*`), die apt-Paketliste
|
||||||
|
(`pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken) und die
|
||||||
|
venv-Pruefung (`py_mm`, `pyvenv_cfg_mm`, `venv_is_healthy`,
|
||||||
|
`report_venv_issues`).
|
||||||
|
- Die **Paketliste** steht damit nicht mehr in `install.sh`, sondern in
|
||||||
|
`lib/common.sh`; `update.sh` schneidet sie weiterhin per `sed` aus der
|
||||||
|
**Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht
|
||||||
|
die vielleicht aeltere, bereits gesourcte aus der Installation.
|
||||||
|
- `venv_is_healthy()` gab es bisher **zweimal** — die schlanke Variante in
|
||||||
|
`install.sh` haette den Distro-Upgrade-Fall (`pyvenv.cfg` gegen
|
||||||
|
System-Python) nicht erkannt. Jetzt existiert nur noch die gruendliche
|
||||||
|
Fassung, und `install.sh` nennt die Befunde ueber `report_venv_issues`.
|
||||||
|
- `lib/` wird von `install.sh` **und** `update.sh` nach
|
||||||
|
`/opt/pdf-ocr-hotfolder/lib/` mitkopiert und liegt damit im
|
||||||
|
Update-Backup. `update.sh` sourct bevorzugt die Repo-Fassung neben sich
|
||||||
|
und faellt auf die installierte zurueck; fehlt sie ueberall, bricht es
|
||||||
|
sofort ab statt mitten im Lauf mit "command not found".
|
||||||
|
- **veraPDF wird im Preflight geprueft** (`check_verapdf_binary()`,
|
||||||
|
`resolve_verapdf_binary()`). Mit `[verapdf].enabled = true` muss
|
||||||
|
`[verapdf].binary` auf ein vorhandenes, ausfuehrbares Programm zeigen —
|
||||||
|
sonst startet der Dienst gar nicht erst (Exit 2), und `--check-config`
|
||||||
|
meldet den Fehler. Ein Pfad mit `/` wird direkt geprueft, ein nackter Name
|
||||||
|
im `PATH` gesucht. `--check-config` zeigt jetzt ausserdem Binary und Flavour
|
||||||
|
bzw. `(aus)` an.
|
||||||
|
Hintergrund: ein Tippfehler im Pfad war der **gefaehrlichste Fehler des
|
||||||
|
ganzen Dienstes**. `run_verapdf()` fand das Programm fuer JEDE Datei nicht,
|
||||||
|
wertete das als FAIL, schob das OCR-Ergebnis nach `error/` — und
|
||||||
|
`_dispose_original()` entsorgte das Original laut
|
||||||
|
`[output].original_on_success`, bei dessen Default `delete` also Scan fuer
|
||||||
|
Scan die Vorlage. Die Unit stand dabei als `active (running)` da.
|
||||||
|
- **Exit 3: toter Verzeichnis-Watch** (`EXIT_OBSERVER_DEAD` in `service.py`).
|
||||||
|
Stirbt der watchdog-Observer im Betrieb (erschoepftes
|
||||||
|
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
|
||||||
|
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr — kein
|
||||||
|
Log, keine Mail, niemand merkt es. Die Hauptschleife prueft den Observer
|
||||||
|
jetzt sekuendlich mit, loggt im Ernstfall die moeglichen Ursachen und
|
||||||
|
beendet sich mit Exit 3; `Restart=on-failure` startet den Dienst neu und der
|
||||||
|
Watch wird neu aufgesetzt. Bewusst **nicht** Exit 2 — den unterdrueckt die
|
||||||
|
Unit jetzt beim Neustart (s.u.).
|
||||||
|
- **`ProcessResult.warning`** — erfolgreicher Durchlauf mit Nebenbefund. Bisher
|
||||||
|
gab es nur Erfolg oder Fehler; ein liegengebliebenes Original passte in
|
||||||
|
keine der beiden Schubladen.
|
||||||
|
- **Sammelmeldung fuer Nicht-PDF-Dateien in `incoming/`**
|
||||||
|
(`_report_non_pdf()`). Alles ohne `.pdf`-Endung wurde ignoriert und
|
||||||
|
sammelte sich stumm an (Scanner-Fehlablagen, abgebrochene Uploads,
|
||||||
|
Thumbnails). Beim Start-Scan gibt es jetzt **eine** Warnung mit Anzahl und
|
||||||
|
bis zu drei Beispielnamen — keine Zeile pro Datei und nichts im laufenden
|
||||||
|
Betrieb.
|
||||||
|
- **`install.sh` warnt beim Erstinstall in Containern, wenn
|
||||||
|
`systemd-journald` nicht laeuft.** Der Dienst loggt ausschliesslich nach
|
||||||
|
journald; ist journald kaputt, gibt es gar keine Logs. Die Warnung nennt den
|
||||||
|
Drop-in-Befehl fuer den Debian-13-Fall und fragt, ob fortgefahren werden
|
||||||
|
soll.
|
||||||
|
- **Dokumentation** (siehe unten unter *Docs*): Dateisystem-Festlegung,
|
||||||
|
Debian-13-journald-Befund, konkrete Speicher-Messwerte, Exit-Code-Tabelle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`outgoing/`: gleichnamige Datei wird nicht mehr ueberschrieben.** Liefert
|
||||||
|
der Scanner denselben Dateinamen ein zweites Mal (oder wurde das
|
||||||
|
Vorgaengerergebnis noch nicht abgeholt), legte der `shutil.move` die
|
||||||
|
aeltere Datei kommentarlos um. Neu: `_collision_free_path()` haengt einen
|
||||||
|
Zeitstempel an (`scan.pdf` -> `scan_20260923-081500.pdf`), bei Kollision
|
||||||
|
innerhalb derselben Sekunde zusaetzlich einen Zaehler. Es gibt eine
|
||||||
|
`log.warning`, und `ProcessResult.output` traegt den **tatsaechlich**
|
||||||
|
geschriebenen Pfad — die Upload-Ziele und die Mail nennen damit die richtige
|
||||||
|
Datei.
|
||||||
|
- **Dieselbe Klasse in `error/` und beim Ordner-Upload.** `_move_to_error()`
|
||||||
|
(und damit auch `_rescue_to_error()`) sowie `upload_folder()` mit
|
||||||
|
abweichendem `[upload.folder].target` ersetzten bisher still eine
|
||||||
|
gleichnamige Datei im Ziel. Beide nutzen jetzt denselben
|
||||||
|
Zeitstempel-Ausweg. Scheitert dieselbe `scan.pdf` zweimal, liegen jetzt
|
||||||
|
beide Fassungen in `error/`.
|
||||||
|
- **veraPDF: Stoerung wird nicht mehr als FAIL gewertet.** `run_verapdf()`
|
||||||
|
unterscheidet jetzt zwischen einem echten Urteil (PASS/FAIL) und
|
||||||
|
"Programm nicht aufrufbar, nicht startbar, Timeout oder kein PASS/FAIL in
|
||||||
|
der Ausgabe" — Letzteres wirft `VeraPdfUnavailable`. Im Stoerungsfall
|
||||||
|
wandern **Original UND OCR-Ergebnis** nach `error/`, und das Original wird
|
||||||
|
weder geloescht noch archiviert, unabhaengig von
|
||||||
|
`[output].original_on_success`. Vorher lieferte jeder dieser Faelle
|
||||||
|
schlicht `False` und damit ein Fehlurteil ueber die Datei.
|
||||||
|
- **Kaputtes TOML und nicht lesbare Config beenden den Dienststart mit
|
||||||
|
Exit 2** statt mit einem nackten Traceback. `main()` faengt jetzt
|
||||||
|
`tomllib.TOMLDecodeError` und `OSError` genauso ab wie `--check-config`; die
|
||||||
|
Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert
|
||||||
|
(`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14 — auf
|
||||||
|
Debian 12 steht die Position nur im Meldungstext, deshalb `getattr`).
|
||||||
|
- **Relative Pfade in der Config sind jetzt ein Fehler.** `[paths].incoming`,
|
||||||
|
`outgoing`, `working`, `error` sowie `[output].archive_dir` und
|
||||||
|
`[upload.folder].target` muessen absolut sein (`_require_absolute()`,
|
||||||
|
`ConfigError` + Exit 2). Ein relativer Pfad wurde gegen das
|
||||||
|
`WorkingDirectory` der Unit aufgeloest und landete still unter
|
||||||
|
`/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der
|
||||||
|
Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte
|
||||||
|
bleiben erlaubt (`archive_dir`/`target` sind optional).
|
||||||
|
- **Ein Fehler beim Entsorgen des Originals entwertet den Durchlauf nicht
|
||||||
|
mehr.** `_dispose_original()` wirft nicht mehr, sondern liefert eine
|
||||||
|
Meldung zurueck: zum Aufrufzeitpunkt liegt das fertige PDF schon in
|
||||||
|
`outgoing/`, eine Exception von dort haette den gelungenen Durchlauf im
|
||||||
|
Catch-all des Service in einen Fehler verwandelt — mitsamt ausgefallenem
|
||||||
|
Upload. Jetzt gilt der Lauf als Erfolg, Upload und Benachrichtigung laufen,
|
||||||
|
es gibt aber eine `log.error` (Original liegt noch in `working/` und wird
|
||||||
|
beim naechsten Start erneut durch das OCR geschickt), und die Mail geht als
|
||||||
|
**"OK mit Warnung"** auch bei `[notify.email].on = "errors"` raus — sonst
|
||||||
|
waere genau das wieder ein stiller Fehlerpfad.
|
||||||
|
- **Log geht explizit nach stdout.** `logging.basicConfig()` schreibt per
|
||||||
|
Default nach **stderr**; README und `docs/INSTALLATION.md` versprachen aber
|
||||||
|
stdout. Fuer journald egal, fuer den dort beschriebenen
|
||||||
|
Vordergrund-Notbehelf und fuer jede Weiterleitung nicht.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`systemd/pdf-ocr-hotfolder@.service`: `RestartPreventExitStatus=2`.**
|
||||||
|
Exit 2 = Config- oder Preflight-Fehler, den behebt kein Neustart. Bisher
|
||||||
|
startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu
|
||||||
|
(das Start-Rate-Limit greift bei `RestartSec=5` nie). Jetzt bleibt die
|
||||||
|
Instanz sichtbar `failed` stehen.
|
||||||
|
- `HotfolderService.run()` gibt einen **Exit-Code** zurueck (0 oder
|
||||||
|
`EXIT_OBSERVER_DEAD`) statt `None`; `main()` reicht ihn durch. Preflight und
|
||||||
|
`check_output_config()` stehen jetzt gebuendelt in `_preflight()`, das
|
||||||
|
`run()` und `run_once()` gemeinsam nutzen.
|
||||||
|
- `check_preflight()` nimmt zwei weitere Parameter (`verapdf_enabled`,
|
||||||
|
`verapdf_binary`) — beide mit Default, bestehende Aufrufe bleiben gueltig.
|
||||||
|
- `config.example.toml`: Warnhinweise zu absoluten Pfaden (`[paths]`,
|
||||||
|
`[output].archive_dir`, `[upload.folder].target`), zur veraPDF-Preflight-
|
||||||
|
Pruefung samt Begruendung und zum Kollisionsverhalten im Upload-Ziel.
|
||||||
|
- `install.sh` ist von ~73 Zeilen Kopf auf das Sourcen von `lib/common.sh`
|
||||||
|
geschrumpft und nutzt durchgehend `SYSTEMD_DIR`/`LXC_DROPIN` statt
|
||||||
|
hartkodierter Pfade.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **`docs/INSTALLATION.md`, Systemanforderungen: Dateisystem festgelegt.** Der
|
||||||
|
Dienst laeuft ausschliesslich auf **ext4, xfs oder zfs**. `incoming/` gehoert
|
||||||
|
auf ein lokales, inotify-faehiges Dateisystem — **kein CIFS/NFS-Mount**: dort
|
||||||
|
liefert inotify grundsaetzlich keine Events, weil Schreibzugriffe anderer
|
||||||
|
Rechner am lokalen Kernel vorbeigehen. Der Dienst wuerde dann nur noch beim
|
||||||
|
Start etwas verarbeiten und im laufenden Betrieb nichts mehr bemerken.
|
||||||
|
- **Speicher-Messwerte konkretisiert.** Zur bestehenden 512-MB-Messung kommen
|
||||||
|
Zahlen von einem **2-GB-Container** (Debian 13, 300-dpi-A4-Seite mit
|
||||||
|
`deskew`, `oversample = 300`, `jobs = 4`): Laufzeit **19 s**, `MemoryPeak`
|
||||||
|
des Dienstes **380 MB**, `memory.peak` des ganzen Containers **503 MB**,
|
||||||
|
`oom_kill 0`. Auf derselben Maschine mit 512 MB war genau das der OOM-Kill.
|
||||||
|
Die Empfehlung "mindestens 2 GB" ist damit belegt statt geschaetzt.
|
||||||
|
- **Debian 13 in LXC auf Proxmox: journald scheitert — verifizierter Befund,
|
||||||
|
betrifft jede Debian-13-LXC auf Proxmox 8.4.** In 0.6.3 stand noch, das sei
|
||||||
|
ein Schaden auf genau einer Maschine; das war falsch. Ursache: systemd >= 255
|
||||||
|
(Debian 13 hat 257) setzt `ImportCredential=journal.*` in der
|
||||||
|
journald-Unit, der Hilfsprozess `(sd-mkdcreds)` mountet dafuer, und das
|
||||||
|
AppArmor-Profil des Proxmox-Hosts blockiert das
|
||||||
|
(`apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>"
|
||||||
|
name="/dev/" comm="(sd-mkdcreds)"` im Host-Log). Debian 12 (systemd 252)
|
||||||
|
kennt `ImportCredential` nicht und ist nicht betroffen. Dokumentiert sind
|
||||||
|
die reboot-feste Abhilfe im Container (Drop-in
|
||||||
|
`systemd-journald.service.d/no-credentials.conf` mit leerem
|
||||||
|
`ImportCredential=`), der Hinweis auf die weiteren betroffenen Units
|
||||||
|
(logind, networkd, console-getty, tmpfiles-setup) und der saubere Weg
|
||||||
|
host-seitig.
|
||||||
|
- **Exit-Code-Tabelle 0/1/2/3** in `docs/INSTALLATION.md`, verlinkt aus
|
||||||
|
README, `docs/UPDATE.md` und dem Troubleshooting — mit dem Hinweis, dass die
|
||||||
|
Unit bei 2 **nicht** neu startet und bei 3 gerade doch.
|
||||||
|
- Die fuenf Ueberschriften der Instanz-Abfragen in `docs/INSTALLATION.md` sind
|
||||||
|
**entnummeriert** (`### 4. OCR-Sprachen` -> `### OCR-Sprachen`). Die Anker
|
||||||
|
hiessen vorher `#4-ocr-sprachen` und waeren bei jeder Umsortierung
|
||||||
|
gebrochen; die Reihenfolge steht weiterhin im Text.
|
||||||
|
- `AI_AGENT_BRIEFING.md` auf den heutigen Stand gezogen: Dateibaum mit `lib/`
|
||||||
|
und allen 20 Test-Dateien, nur noch **ein** `venv_is_healthy()`,
|
||||||
|
Paketliste in `lib/common.sh`, veraPDF-Preflight, Kollisionsschutz,
|
||||||
|
Exit-Codes, Observer-Bewachung, Testzahl 254.
|
||||||
|
- Testzahl ueberall von 152 auf **254** korrigiert (README,
|
||||||
|
`AI_AGENT_BRIEFING.md`, `docs/OS-UPGRADE.md`).
|
||||||
|
|
||||||
|
## [0.6.3] - 2026-09-23
|
||||||
|
|
||||||
|
Reine Doku-Version — kein Code, kein Installer, kein Updater, keine Unit, keine
|
||||||
|
Tests angefasst (ausser dem Versionsstring). Beide Erkenntnisse stammen aus den
|
||||||
|
Testlaeufen auf Debian 12 und Debian 13.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Abschnitt "Systemanforderungen" in `docs/INSTALLATION.md`.** Die
|
||||||
|
Dimensionierung fehlte bisher komplett. Empfohlen werden **mindestens 2 GB
|
||||||
|
RAM**; 512 MB reichen fuer 300-dpi-Scans nachweislich nicht. Gemessen auf
|
||||||
|
einem LXC-Container mit 512 MB RAM + 512 MB Swap, Debian 13,
|
||||||
|
Ghostscript 10.05.1: eine **einzelne A4-Seite in 300 dpi** mit
|
||||||
|
`deskew = true` riss das cgroup-Limit und der Dienst wurde vom OOM-Killer
|
||||||
|
beendet (`oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201,
|
||||||
|
task=python`, `total-vm:1168764kB, anon-rss:489676kB`). Dieselbe
|
||||||
|
Verarbeitung mit einer kleineren Seite (850x1100 px) lief in ca. 19 s sauber
|
||||||
|
durch.
|
||||||
|
- Dokumentiert ist der Zusammenhang **RAM <-> `max_workers` x `jobs` x
|
||||||
|
Aufloesung**: `max_workers` (Default 2) laesst zwei solcher Seiten
|
||||||
|
gleichzeitig laufen, der Spitzenbedarf multipliziert sich entsprechend.
|
||||||
|
Wer knapp dimensioniert, zieht zuerst `max_workers` herunter.
|
||||||
|
- Dazu, **wie sich ein OOM-Kill aeussert** (Dienst weg bzw. von systemd neu
|
||||||
|
gestartet, `NRestarts` steigt, Abbruch mitten in der Datei ohne Traceback,
|
||||||
|
PDF bleibt in `working/` liegen) und **wie man ihn nachweist**
|
||||||
|
(`/sys/fs/cgroup/memory.events` im Container, `dmesg` auf dem LXC-Host).
|
||||||
|
Ohne diese Pruefung sieht der Fall wie ein Anwendungsfehler aus und man
|
||||||
|
sucht in ocrmypdf, Tesseract oder der Config.
|
||||||
|
- Mit dem Hinweis, dass die Wiederaufnahme aus `working/` seit v0.6.0 den
|
||||||
|
Datenverlust abfaengt — der OOM selbst bleibt aber ein Problem: bei
|
||||||
|
unveraenderter Dimensionierung laeuft dieselbe Datei nach dem Neustart
|
||||||
|
erneut hinein.
|
||||||
|
- `README.md` bekommt im Schnellstart nur einen Einzeiler mit Verweis, keine
|
||||||
|
Dublette.
|
||||||
|
- **Troubleshooting-Eintrag "Keine Logs: No journal files were found" in
|
||||||
|
`docs/INSTALLATION.md`.** Auf einem der Testcontainer war
|
||||||
|
`systemd-journald.service` kaputt (`failed`, `status=243/CREDENTIALS`),
|
||||||
|
`journalctl` lieferte `No journal files were found.` Da der Dienst seit
|
||||||
|
v0.4.1 **ausschliesslich** nach journald loggt (kein FileHandler, kein
|
||||||
|
Logverzeichnis — bewusste Entscheidung), gibt es dann gar keine Dienstlogs:
|
||||||
|
ein blinder Fleck, der vor jeder Fehlersuche per
|
||||||
|
`systemctl status systemd-journald` auszuschliessen ist. Als Notbehelf ist
|
||||||
|
der Vordergrund-Aufruf dokumentiert
|
||||||
|
(`cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m
|
||||||
|
pdf_ocr_hotfolder --config ...`, das `cd` ist zwingend, s. 0.6.2).
|
||||||
|
Ausdruecklich festgehalten: das ist **kein** generelles LXC-Muster — der
|
||||||
|
zweite Testcontainer war in Ordnung, es war Schaden auf genau dieser
|
||||||
|
Maschine.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `AI_AGENT_BRIEFING.md`: der Kommentar zu `requirements.txt` in der
|
||||||
|
Projektstruktur nannte noch "ocrmypdf 16.x!" — genau die Version, die seit
|
||||||
|
0.6.1 als unbrauchbar gilt und gegen die der Pin schuetzt. Korrigiert auf
|
||||||
|
17.x.
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-09-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Der `--check-config`-Befehl, den `update.sh` in der Zusammenfassung ausgibt,
|
||||||
|
lief so wie gedruckt nicht (`No module named pdf_ocr_hotfolder`). Das Paket
|
||||||
|
wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert und
|
||||||
|
nur ueber das Arbeitsverzeichnis gefunden — dem Hinweis fehlte das
|
||||||
|
vorangestellte `cd`. Betraf auch die Beispiele in README.md,
|
||||||
|
docs/INSTALLATION.md, docs/UPDATE.md und docs/OS-UPGRADE.md.
|
||||||
|
Gefunden beim Update-Test v0.3.1 -> v0.6.1 auf Debian 12.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-22
|
||||||
|
|
||||||
|
> **Fuer Bestandsinstallationen wichtig.** Wer 0.6.0 bereits eingespielt hat,
|
||||||
|
> laeuft auf Debian 12 mit hoher Wahrscheinlichkeit im Totalausfall: der Dienst
|
||||||
|
> meldet `active`, `--check-config` meldet "Preflight ok" — und **jede** PDF
|
||||||
|
> landet in `error/`. Nach dem Update auf 0.6.1 nachsehen, ob in `error/`
|
||||||
|
> unverarbeitete Dateien liegen, und diese zurueck nach `incoming/` schieben.
|
||||||
|
> Der Updater faehrt jetzt selbst einen Rauchtest, der so einen Zustand sofort
|
||||||
|
> aufdeckt.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ocrmypdf-Pin von 16.13.0 auf 17.4.1 korrigiert — das war ein stiller
|
||||||
|
Totalausfall.** 0.6.0 pinnte `ocrmypdf==16.13.0`. Auf Bestandssystemen mit
|
||||||
|
vorher `ocrmypdf>=16.0` war das ein **Downgrade** von 17.4.1, und auf
|
||||||
|
Debian 12 (Ghostscript 10.0.0) bricht ocrmypdf 16.13.0 bei jeder PDF ab:
|
||||||
|
|
||||||
|
```
|
||||||
|
MissingDependencyError: Ghostscript 10.0.0 through 10.02.0 (your version:
|
||||||
|
10.0.0) contain serious regressions that corrupt PDFs with existing text
|
||||||
|
```
|
||||||
|
|
||||||
|
Ursache, verifiziert im Quelltext von
|
||||||
|
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`: bis
|
||||||
|
einschliesslich 16.x laeuft die Ghostscript-Pruefung **bedingungslos** —
|
||||||
|
`skip_text=true` allein genuegt, `output_type` wird gar nicht geprueft,
|
||||||
|
obwohl die Fehlermeldung selbst `--output-type pdf` empfiehlt. Ab **17.0.0**
|
||||||
|
umschliesst denselben Block ein
|
||||||
|
`if options.output_type.startswith('pdfa'):`; ohne PDF/A wird Ghostscript
|
||||||
|
nicht angefasst. `run_ocr()` setzt bei leerem `pdfa_level` genau
|
||||||
|
`output_type="pdf"` und hielt sich damit faelschlich fuer sicher.
|
||||||
|
|
||||||
|
Betroffen war nicht nur das Update, sondern ebenso jede **Neuinstallation**:
|
||||||
|
`skip_text = true` ist der Default. — 17.4.1 ist die auf Debian 12 + gs 10.0.0
|
||||||
|
real verifizierte Version; `skip_text` bleibt in 17.x als Alias fuer
|
||||||
|
`mode='skip'` unterstuetzt, `run_ocr()` musste nicht angepasst werden.
|
||||||
|
|
||||||
|
- **Preflight prueft jetzt die reale Bedingung.** `check_preflight()` sah die
|
||||||
|
Ghostscript-Version bisher nur bei gesetztem `pdfa_level` an (`if pdfa_level:`)
|
||||||
|
— genau deshalb ging der kaputte Zustand als "Preflight ok" durch. Die neue
|
||||||
|
Bedingung (`_gs_block_reason()`) bildet ocrmypdf nach:
|
||||||
|
|
||||||
|
```
|
||||||
|
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Signatur ist jetzt `check_preflight(pdfa_level, skip_text)`; alle
|
||||||
|
Aufrufstellen (`run()`, `run_once()`, `--check-config`) reichen beides durch.
|
||||||
|
`redo_ocr` steht bewusst **nicht** in der Bedingung: die Config kennt keinen
|
||||||
|
solchen Key, und ein erfundener waere schlimmer als ein fehlender.
|
||||||
|
|
||||||
|
Ergebnis: der Dienst bricht beim **Start** mit Exit 2 ab statt bei der ersten
|
||||||
|
Datei, und `--check-config` meldet den Zustand als **Fehler** (Exit 2) — also
|
||||||
|
auch mitten im Update. Die Meldung nennt beide Auswege: Ghostscript >= 10.02.1
|
||||||
|
aus bookworm-backports (der Installer bietet das an) oder
|
||||||
|
`[ocr].skip_text = false`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`update.sh` macht Versionsspruenge der Kernabhaengigkeiten sichtbar.** Die
|
||||||
|
Versionen der in `requirements.txt` gepinnten Pakete werden vor und nach
|
||||||
|
`pip install` gemessen; Downgrades erscheinen als `[WARN]`, Upgrades und neue
|
||||||
|
Pakete als `[INFO]`, beides zusaetzlich in der Abschluss-Zusammenfassung. Das
|
||||||
|
Downgrade 17.4.1 -> 16.13.0 verschwand bisher wortlos hinter
|
||||||
|
`[INFO] Dependencies ok ✓`.
|
||||||
|
- **Rauchtest in `update.sh`.** Nach dem Start jeder Instanz geht eine winzige
|
||||||
|
Test-PDF durch die **echte** Pipeline; der Test gilt als bestanden, wenn sie
|
||||||
|
in `outgoing/` ankommt. Erst das deckt einen Totalausfall auf, den systemd
|
||||||
|
nicht sieht.
|
||||||
|
- Die Test-PDF (694 Bytes, eine Seite) steckt als base64 im Skript — kein
|
||||||
|
Pillow, kein `gs`, kein `convert` noetig.
|
||||||
|
- Eindeutiger Dateiname (`__smoketest_update_<zeitstempel>_<pid>.pdf`), der
|
||||||
|
mit keiner Kundendatei kollidieren kann.
|
||||||
|
- **Raeumt restlos auf** — Testdatei und Ergebnis, in `incoming/`, `working/`
|
||||||
|
(inkl. `__ocr_`-Zwischendatei), `outgoing/`, `error/` und Archiv, auch bei
|
||||||
|
Fehlschlag und Timeout.
|
||||||
|
- **Uebersprungen** bei Instanzen mit aktivem `[upload.nextcloud]`,
|
||||||
|
`[upload.sftp]`, `[notify.email]` oder `[upload.folder]` mit gesetztem
|
||||||
|
`target`: dort wuerde die Testdatei nach aussen gehen, im Zweifel zum
|
||||||
|
Kunden. `[upload.folder]` ohne `target` schreibt nach `outgoing/` und ist
|
||||||
|
harmlos.
|
||||||
|
- Wartezeit `SMOKE_TIMEOUT` (Standard 90 s), danach durchgefallen — das
|
||||||
|
Skript haengt nicht.
|
||||||
|
- Ein Fehlschlag setzt den Exit-Code auf 1 und nennt den `journalctl`-Befehl,
|
||||||
|
rollt aber **nichts** zurueck.
|
||||||
|
- Abschaltbar mit `--no-smoke-test`, dokumentiert in `--help`.
|
||||||
|
- `--check-config` zeigt zusaetzlich `skip_text` sowie die installierte
|
||||||
|
ocrmypdf- und Ghostscript-Version an.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Doku praezisiert.** `config.example.toml`, `config.py`,
|
||||||
|
`docs/INSTALLATION.md` und `docs/UPDATE.md` behaupteten sinngemaess,
|
||||||
|
`pdfa_level = ""` sei der sichere Default gegen den Ghostscript-Bug. Das
|
||||||
|
stimmt so nicht: die Entwarnung haengt an der ocrmypdf-Version und gilt erst
|
||||||
|
ab 17. Der Ghostscript-Abschnitt in `INSTALLATION.md` stellt die Bedingung
|
||||||
|
jetzt je ocrmypdf-Major gegenueber und nennt `skip_text = false` als zweiten
|
||||||
|
Weg; `UPDATE.md` beschreibt Rauchtest und Versionssprung-Meldung.
|
||||||
|
- Kommentarblock in `requirements.txt` korrigiert: der Schutz gilt gegen den
|
||||||
|
naechsten ungewollten Major-Sprung (18), **nicht** gegen 17 — samt
|
||||||
|
Begruendung, warum 16.x fuer uns unbrauchbar ist.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 152 statt 135 Tests. Neu: die Preflight-Matrix aus GS-Version x `skip_text` x
|
||||||
|
`pdfa_level` x ocrmypdf-Major — darunter der Fall, der durchrutschte
|
||||||
|
(betroffene GS-Version + `skip_text=true` + leeres `pdfa_level` +
|
||||||
|
ocrmypdf 16.x muss `PreflightError` ausloesen) und die Gegenprobe, dass
|
||||||
|
dieselbe Config mit ocrmypdf 17.x **nicht** ausloest (sonst startet keine
|
||||||
|
Debian-12-Bestandsinstanz mehr). Dazu `ocrmypdf_checks_gs_always()`, der
|
||||||
|
Abbruch in `run_once()` und Exit 2 bei `--check-config`.
|
||||||
|
|
||||||
|
## [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
|
## [0.2.1] - 2026-04-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,33 +2,68 @@
|
|||||||
|
|
||||||
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.
|
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
|
## Features
|
||||||
|
|
||||||
- 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead)
|
- 🔍 **OCR via ocrmypdf + Tesseract** (Library-Call, kein Subprozess-Overhead)
|
||||||
- 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events
|
- 📂 **Hotfolder via watchdog** — reagiert auf `created`, `moved`, `closed` Events
|
||||||
- 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat
|
- 🧠 **Stabilitäts-Erkennung**: wartet bis Scanner fertig geschrieben hat
|
||||||
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
|
- 🔁 **Parallelverarbeitung** mehrerer PDFs (ThreadPool, konfigurierbar)
|
||||||
- ✅ **PDF/A-Output** (1, 2 oder 3) optional
|
- ♻️ **Wiederaufnahme aus `working/`** nach einem harten Stopp — keine Datei bleibt liegen
|
||||||
- 🛡️ **veraPDF-Validierung** optional
|
- ✅ **PDF/A-Output** (1, 2 oder 3) optional — **aber nicht auf Debian 12**: dessen Ghostscript (10.0.0–10.02.0) hat einen Bug, ocrmypdf lehnt PDF/A zusammen mit `skip_text = true` ab, und ein neueres Ghostscript gibt es dort nicht (`bookworm-backports` enthält kein Ghostscript-Paket). Wer PDF/A braucht, installiert auf **Debian 13** (Ghostscript 10.05.1) oder setzt `skip_text = false` ([Details](docs/INSTALLATION.md#ghostscript-bug-auf-debian-12))
|
||||||
|
- 🛡️ **veraPDF-Validierung** optional — Binary wird im Preflight geprüft, eine Störung gilt nicht als FAIL
|
||||||
|
- 🚫 **Überschreibt nie eine gleichnamige Datei** — in `outgoing/`, `error/`, Archiv und Ordner-Upload weicht sie mit Zeitstempel aus
|
||||||
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
|
- ☁️ **Upload-Ziele**: lokaler Ordner, Nextcloud (WebDAV via Python), SFTP
|
||||||
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
|
- 📧 **E-Mail-Notify** (immer / nur Fehler / nie)
|
||||||
- 🔐 **Service-User-Support** für lokale **und AD-User mit lokaler UID** (SSSD/Winbind)
|
- 🔐 **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
|
||||||
|
- 👁️ **Toter Verzeichnis-Watch wird erkannt** — der Dienst beendet sich (Exit 3), systemd setzt den Watch neu auf
|
||||||
|
- 🩺 **`--check-config`** prüft eine Instanz-Config ohne etwas zu verarbeiten
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
|
**Voraussetzungen:** Debian 12 oder 13, Python 3.11+, root — und **mindestens
|
||||||
|
2 GB RAM** (512 MB reichen für 300-dpi-Scans nachweislich nicht, siehe
|
||||||
|
[Systemanforderungen](docs/INSTALLATION.md#systemanforderungen)).
|
||||||
|
Dateisystem **ext4, xfs oder zfs**; `incoming/` muss **lokal** liegen — auf
|
||||||
|
einem CIFS/NFS-Mount liefert inotify keine Events und der Hotfolder bemerkt
|
||||||
|
neue Dateien nur noch beim Start
|
||||||
|
([warum](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)).
|
||||||
|
Außerdem **`git`** (für den Clone) und, falls nicht als `root` gearbeitet wird,
|
||||||
|
**`sudo`** — beides fehlt im Proxmox-Debian-Standard-Template
|
||||||
|
([Details](docs/INSTALLATION.md#voraussetzungen)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
|
apt update && apt install -y git # als root; ggf. zusätzlich: sudo
|
||||||
cd pdf-ocr-hotfolder
|
|
||||||
sudo ./install.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Installer:
|
```bash
|
||||||
1. Installiert einmalig Code + venv + systemd-Template-Unit
|
# HTTPS — funktioniert ohne Credentials, das ist der Normalfall
|
||||||
2. Fragt nach Instanz-Name, Basis-Pfad, Service-User
|
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
|
||||||
3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`)
|
cd pdf-ocr-hotfolder
|
||||||
|
./install.sh # als root; mit sudo: sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen.
|
Wer einen Deploy-Key hinterlegt hat, klont per SSH — **der SSH-User heißt
|
||||||
|
`gitea`, nicht `git`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
Test:
|
||||||
|
|
||||||
@@ -39,85 +74,69 @@ journalctl -u pdf-ocr-hotfolder@<instanz> -f
|
|||||||
|
|
||||||
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz.
|
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:
|
Update: `git pull && ./update.sh` (als root; mit `sudo`:
|
||||||
|
`sudo ./update.sh`) — siehe **[docs/UPDATE.md](docs/UPDATE.md)**.
|
||||||
- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/<name>.toml`
|
Nach einem Debian-Major-Upgrade: **[docs/OS-UPGRADE.md](docs/OS-UPGRADE.md)**.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Verzeichnisse
|
## Verzeichnisse
|
||||||
|
|
||||||
| Pfad | Zweck |
|
| Pfad | Zweck |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
|
||||||
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz |
|
| `/opt/pdf-ocr-hotfolder/lib/common.sh` | gemeinsame Shell-Funktionen für `install.sh`/`update.sh` (mitkopiert) |
|
||||||
|
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz (640, root:\<service-gruppe\>) |
|
||||||
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
|
| `/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>/incoming` | Eingang (Scanner schreibt hier rein) |
|
||||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
|
| `/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>/outgoing` | Ausgang (fertige PDFs) |
|
||||||
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene 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 (0600, letzte 5) |
|
||||||
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
|
|
||||||
|
|
||||||
## 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]`
|
Jede Instanz hat ihre eigene TOML unter `/etc/pdf-ocr-hotfolder/<instanz>.toml`.
|
||||||
```toml
|
Vollständiges, kommentiertes Beispiel: [`config.example.toml`](config.example.toml).
|
||||||
languages = "deu+eng" # Tesseract-Sprachen
|
Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz).
|
||||||
jobs = 4 # Threads pro PDF
|
|
||||||
skip_text = true # bereits OCR-haltige Seiten überspringen
|
| Sektion | Zweck |
|
||||||
pdfa_level = "2" # "1", "2", "3" oder "" für reines PDF
|
|---------|-------|
|
||||||
deskew = true
|
| `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht**, alle **absolut** |
|
||||||
max_workers = 2 # parallele PDFs
|
| `[ocr]` | Sprachen, `jobs`, `skip_text`, `pdfa_level`, `deskew`, `max_workers`, `timeout` (Sekunden **pro Seite**) |
|
||||||
timeout = 1800
|
| `[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
|
||||||
|
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
|
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
### `[upload.nextcloud]`
|
Exit 0 = sauber, 1 = Warnungen, 2 = Fehler. Details:
|
||||||
```toml
|
[docs/UPDATE.md](docs/UPDATE.md#config-prüfung-per---check-config).
|
||||||
enabled = true
|
|
||||||
url = "https://cloud.example.com"
|
|
||||||
username = "scanuser"
|
|
||||||
password = "app-password"
|
|
||||||
remote_path = "Scans/Inbox"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `[upload.sftp]`
|
### Exit-Codes des Dienstes
|
||||||
```toml
|
|
||||||
enabled = true
|
|
||||||
host = "sftp.example.com"
|
|
||||||
username = "scanuser"
|
|
||||||
key_file = "/etc/pdf-ocr-hotfolder/sftp_key"
|
|
||||||
remote_path = "/uploads"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `[notify.email]`
|
| Exit | Bedeutung | Neustart durch systemd |
|
||||||
```toml
|
|------|-----------|------------------------|
|
||||||
enabled = true
|
| `0` | regulärer Stopp | — |
|
||||||
smtp_host = "smtp.example.com"
|
| `1` | nur bei `--once`: mindestens eine PDF fehlgeschlagen | — |
|
||||||
smtp_port = 587
|
| `2` | Config- oder Preflight-Fehler | **nein** (`RestartPreventExitStatus=2`) — die Instanz bleibt sichtbar `failed` |
|
||||||
smtp_user = "alerts@example.com"
|
| `3` | Verzeichnis-Watch gestorben, es würden keine Dateien mehr erkannt | **ja**, genau dafür |
|
||||||
smtp_password = "secret"
|
|
||||||
from_addr = "PDF OCR <alerts@example.com>"
|
Vollständig: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes).
|
||||||
to_addrs = ["admin@example.com"]
|
|
||||||
on = "errors" # always | errors | never
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service-Verwaltung
|
## Service-Verwaltung
|
||||||
|
|
||||||
@@ -130,52 +149,11 @@ journalctl -u pdf-ocr-hotfolder@kunde-a -f
|
|||||||
# Alle Instanzen
|
# Alle Instanzen
|
||||||
sudo systemctl status 'pdf-ocr-hotfolder@*'
|
sudo systemctl status 'pdf-ocr-hotfolder@*'
|
||||||
sudo systemctl restart 'pdf-ocr-hotfolder@*'
|
sudo systemctl restart 'pdf-ocr-hotfolder@*'
|
||||||
|
journalctl -u 'pdf-ocr-hotfolder@*' --since today
|
||||||
```
|
```
|
||||||
|
|
||||||
## Update
|
Ein laufendes OCR darf beim Stoppen zu Ende laufen (`TimeoutStopSec=300`) — ein
|
||||||
|
`stop` kann deshalb pro Instanz bis zu 5 Minuten dauern.
|
||||||
```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`.
|
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
@@ -199,11 +177,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 # 254 Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
`ocrmypdf` muss dafür nicht installiert sein — der Import ist lazy und wird in
|
||||||
|
den Tests gemockt.
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
MIT — © Sonith UG
|
MIT — © Sonith UG
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version:** 0.2.0
|
**Version:** 0.7.2
|
||||||
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
|
||||||
|
|||||||
+58
-5
@@ -1,7 +1,12 @@
|
|||||||
# PDF OCR Hotfolder — Konfiguration
|
# 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]
|
[paths]
|
||||||
|
# ACHTUNG: Alle Pfade MUESSEN absolut sein. Ein relativer Pfad wuerde gegen
|
||||||
|
# das Arbeitsverzeichnis des Dienstes aufgeloest (WorkingDirectory der
|
||||||
|
# systemd-Unit), nicht gegen das Verzeichnis dieser Datei — der Scanner
|
||||||
|
# schriebe dann woanders hin als der Dienst schaut. Der Dienst startet
|
||||||
|
# deshalb mit einem relativen Pfad gar nicht erst.
|
||||||
# Eingangsverzeichnis: hier landen gescannte PDFs
|
# Eingangsverzeichnis: hier landen gescannte PDFs
|
||||||
incoming = "/var/lib/pdf-ocr-hotfolder/incoming"
|
incoming = "/var/lib/pdf-ocr-hotfolder/incoming"
|
||||||
# Ausgangsverzeichnis: fertige durchsuchbare PDFs
|
# Ausgangsverzeichnis: fertige durchsuchbare PDFs
|
||||||
@@ -21,18 +26,64 @@ skip_text = true
|
|||||||
# Auflösung für gerasterte Seiten
|
# Auflösung für gerasterte Seiten
|
||||||
oversample = 300
|
oversample = 300
|
||||||
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
|
# 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;
|
||||||
|
# ocrmypdf lehnt damit die Kombination pdfa_level + skip_text=true komplett ab.
|
||||||
|
# Nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
|
||||||
|
#
|
||||||
|
# Auf Debian 12 lässt sich Ghostscript NICHT anheben: bookworm-backports
|
||||||
|
# enthält kein Ghostscript-Paket. Wer dort PDF/A braucht, hat auf dieser
|
||||||
|
# Distribution keinen Weg — es bleiben pdfa_level = "" (kein PDF/A),
|
||||||
|
# skip_text = false oder eine Distribution mit neuerem Ghostscript
|
||||||
|
# (Debian 13 liefert 10.05.1).
|
||||||
|
#
|
||||||
|
# pdfa_level = "" ist deshalb der Default — aber KEIN genereller Schutz gegen
|
||||||
|
# den Ghostscript-Bug: das gilt erst zusammen mit ocrmypdf >= 17. Bis
|
||||||
|
# ocrmypdf 16.x läuft dieselbe Prüfung auch ohne PDF/A, und dann scheitert mit
|
||||||
|
# skip_text = true jede einzelne Datei. Die Entwarnung hängt also an der
|
||||||
|
# ocrmypdf-Version, nicht an dieser Zeile; requirements.txt pinnt darum 17.x.
|
||||||
|
# Der Preflight prüft beides zusammen und lässt den Dienst gar nicht erst
|
||||||
|
# starten, wenn die Kombination nicht trägt.
|
||||||
|
pdfa_level = ""
|
||||||
# Schiefe Scans automatisch begradigen
|
# Schiefe Scans automatisch begradigen
|
||||||
deskew = true
|
deskew = true
|
||||||
# Hintergrund säubern
|
# Hintergrund säubern
|
||||||
clean = false
|
clean = false
|
||||||
# Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig)
|
# Maximale parallele PDFs (Hauptsystem hat selten mehr als 1-2 gleichzeitig)
|
||||||
max_workers = 2
|
max_workers = 2
|
||||||
# Timeout pro PDF in Sekunden
|
# Max. Sekunden, die Tesseract pro SEITE laufen darf (0 = kein Limit).
|
||||||
timeout = 1800
|
# 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 (Pflicht, relativ wird abgelehnt); nur relevant wenn
|
||||||
|
# original_on_success = "archive"
|
||||||
|
archive_dir = ""
|
||||||
|
|
||||||
[verapdf]
|
[verapdf]
|
||||||
# PDF/A-Validierung (optional)
|
# PDF/A-Validierung (optional)
|
||||||
|
# ACHTUNG: Mit enabled = true muss "binary" auf ein vorhandenes, ausfuehrbares
|
||||||
|
# Programm zeigen. Der Preflight prueft das und laesst den Dienst sonst gar
|
||||||
|
# nicht erst starten (--check-config meldet Exit 2). Grund: ein nicht
|
||||||
|
# aufrufbares veraPDF wuerde jede einzelne PDF als ungueltig werten, das
|
||||||
|
# OCR-Ergebnis nach error/ schieben und das Original laut
|
||||||
|
# original_on_success entsorgen — bei "delete" also Scan fuer Scan die
|
||||||
|
# Vorlage vernichten, waehrend der Dienst als "laeuft" dasteht.
|
||||||
enabled = false
|
enabled = false
|
||||||
binary = "/opt/verapdf/verapdf"
|
binary = "/opt/verapdf/verapdf"
|
||||||
flavour = "1b"
|
flavour = "1b"
|
||||||
@@ -42,7 +93,9 @@ flavour = "1b"
|
|||||||
|
|
||||||
[upload.folder]
|
[upload.folder]
|
||||||
enabled = true
|
enabled = true
|
||||||
# Wenn leer, wird [paths].outgoing verwendet
|
# Wenn leer, wird [paths].outgoing verwendet. Sonst: absoluter Pfad (Pflicht,
|
||||||
|
# relativ wird abgelehnt). Liegt im Ziel schon eine gleichnamige Datei, wird
|
||||||
|
# die neue mit Zeitstempel abgelegt statt die alte zu ueberschreiben.
|
||||||
target = ""
|
target = ""
|
||||||
|
|
||||||
[upload.nextcloud]
|
[upload.nextcloud]
|
||||||
|
|||||||
@@ -0,0 +1,993 @@
|
|||||||
|
# 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 |
|
||||||
|
| Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) |
|
||||||
|
| Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) |
|
||||||
|
| Rechte | `root` — als root direkt `./install.sh`, sonst `sudo ./install.sh` (s. [root oder sudo](#root-oder-sudo)) |
|
||||||
|
| `git` | zum Klonen des Repos — **nicht** vorinstalliert, s. [Pakete, die fehlen können](#pakete-die-auf-einem-frischen-system-fehlen-können) |
|
||||||
|
| 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)) |
|
||||||
|
|
||||||
|
### Pakete, die auf einem frischen System fehlen können
|
||||||
|
|
||||||
|
Auf dem **Proxmox-Debian-Standard-Template** (12 **und** 13) fehlen zwei Dinge,
|
||||||
|
die jede Anleitung stillschweigend voraussetzt — am frisch angelegten Container
|
||||||
|
verifiziert:
|
||||||
|
|
||||||
|
| Fehlt | Folge | Abhilfe |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| **`git`** | `git clone …` schlägt mit `git: command not found` fehl — und ohne Clone gibt es kein Repo, aus dem `install.sh` läuft | `apt install git` |
|
||||||
|
| **`sudo`** | der überall dokumentierte Aufruf `sudo ./install.sh` schlägt mit `sudo: command not found` fehl | entweder `apt install sudo`, oder **einfach als `root` ohne `sudo` arbeiten** |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update
|
||||||
|
apt install -y git # zwingend
|
||||||
|
apt install -y sudo # nur, wenn nicht als root gearbeitet wird
|
||||||
|
```
|
||||||
|
|
||||||
|
### root oder sudo
|
||||||
|
|
||||||
|
Installer und Updater brauchen **root-Rechte** — *wie* man dahin kommt, ist
|
||||||
|
ihnen gleich. Beide Wege sind gleichwertig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# (a) man ist bereits root — im frischen Container der Normalfall
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# (b) man arbeitet als normaler Benutzer und hat sudo
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
In dieser Doku steht durchgehend die Variante mit `sudo`, weil die meisten
|
||||||
|
Systeme es haben. **Wer als `root` arbeitet, lässt das `sudo` bei jedem Befehl
|
||||||
|
einfach weg** — das gilt für alle Kommandos in diesem und den übrigen
|
||||||
|
Dokumenten. Ein fehlendes `sudo` ist kein Grund, es nachzuinstallieren.
|
||||||
|
|
||||||
|
Die System-Pakete installiert der Installer selbst. Die Liste steht als
|
||||||
|
einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen
|
||||||
|
den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh`
|
||||||
|
sourct die Datei, und `update.sh` schneidet den Block zusätzlich noch einmal
|
||||||
|
aus der **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und
|
||||||
|
nicht die eventuell ältere Kopie unter `/opt/pdf-ocr-hotfolder/lib/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
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](#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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systemanforderungen
|
||||||
|
|
||||||
|
CPU und Platte sind unkritisch — **der Arbeitsspeicher ist es nicht.** OCR
|
||||||
|
rastert jede Seite in voller Auflösung ins RAM; der Spitzenbedarf hängt an der
|
||||||
|
Seitengröße, nicht an der Dateigröße der PDF.
|
||||||
|
|
||||||
|
**Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder
|
||||||
|
`max_workers > 2` entsprechend mehr.
|
||||||
|
|
||||||
|
### Dateisystem: ext4, xfs oder zfs
|
||||||
|
|
||||||
|
Der Dienst wird **ausschließlich auf ext4, xfs oder zfs** betrieben. Andere
|
||||||
|
Dateisysteme sind nicht vorgesehen und werden nicht getestet.
|
||||||
|
|
||||||
|
**`incoming/` gehört auf ein lokales, inotify-fähiges Dateisystem — kein
|
||||||
|
CIFS/NFS-Mount.** Der Hotfolder hängt vollständig an inotify (`watchdog`
|
||||||
|
meldet `created`, `moved`, `closed`). inotify ist ein Mechanismus des *lokalen*
|
||||||
|
Kernels: er sieht nur Änderungen, die dieser Kernel selbst ausführt. Schreibt
|
||||||
|
ein anderer Rechner über SMB oder NFS in ein gemountetes Verzeichnis, geht das
|
||||||
|
am lokalen VFS vorbei und **es entsteht gar kein Event** — nicht verzögert,
|
||||||
|
nicht unzuverlässig, sondern grundsätzlich keines.
|
||||||
|
|
||||||
|
Die Folge ist heimtückisch, weil nichts kaputt aussieht: Der Dienst startet,
|
||||||
|
meldet `active (running)`, arbeitet den Bestand beim Start-Scan sauber ab — und
|
||||||
|
bemerkt danach **keine einzige neue Datei mehr**. Es gibt keinen Fehler, keine
|
||||||
|
Meldung, keine Mail. Erst wenn jemand die Instanz neu startet, wird der
|
||||||
|
inzwischen angesammelte Stapel auf einmal verarbeitet.
|
||||||
|
|
||||||
|
Richtiger Aufbau: der Scanner schreibt über SMB/NFS auf **den Rechner, auf dem
|
||||||
|
der Dienst läuft**, und `incoming/` liegt dort auf der lokalen Platte. Der
|
||||||
|
Netz-Export zeigt auf dieses lokale Verzeichnis, nicht umgekehrt. Für die
|
||||||
|
**Ausgabe** gilt die Einschränkung nicht — `outgoing/` und
|
||||||
|
`[upload.folder].target` dürfen auf einem Netz-Share liegen, dorthin wird nur
|
||||||
|
geschrieben.
|
||||||
|
|
||||||
|
### Was 2 GB tatsächlich tragen
|
||||||
|
|
||||||
|
Gemessen auf einem LXC-Container mit **2 GB RAM**, Debian 13 — eine
|
||||||
|
**A4-Seite in 300 dpi** mit `deskew = true`, `oversample = 300`, `jobs = 4`:
|
||||||
|
|
||||||
|
| Messwert | Ergebnis |
|
||||||
|
|----------|----------|
|
||||||
|
| Laufzeit | **19 s** |
|
||||||
|
| `MemoryPeak` des Dienstes (`systemctl show -p MemoryPeak`) | **380 MB** |
|
||||||
|
| `memory.peak` des ganzen Containers | **503 MB** |
|
||||||
|
| `oom_kill` in `/sys/fs/cgroup/memory.events` | **0** |
|
||||||
|
|
||||||
|
**Dieselbe Seite auf derselben Maschine mit 512 MB war genau der OOM-Kill
|
||||||
|
unten.** Der Spitzenbedarf liegt also bei rund einem halben Gigabyte für
|
||||||
|
*eine* Seite bei *einem* Worker — 2 GB lassen damit Luft für den
|
||||||
|
Default `max_workers = 2`, für das Betriebssystem und für einen zweiten
|
||||||
|
Hotfolder, sind aber keine üppige Reserve.
|
||||||
|
|
||||||
|
### Warum 512 MB nachweislich nicht reichen
|
||||||
|
|
||||||
|
Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13,
|
||||||
|
Ghostscript 10.05.1:
|
||||||
|
|
||||||
|
| Vorgang | Ergebnis |
|
||||||
|
|---------|----------|
|
||||||
|
| eine einzelne **A4-Seite in 300 dpi**, `deskew = true` | cgroup-Limit gerissen, Dienst vom **OOM-Killer** beendet |
|
||||||
|
| dieselbe Verarbeitung mit einer kleineren Seite (850 × 1100 px) | läuft sauber durch, ca. 19 s |
|
||||||
|
|
||||||
|
Beleg aus dem `dmesg` des LXC-Hosts:
|
||||||
|
|
||||||
|
```
|
||||||
|
oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201, task=python
|
||||||
|
total-vm:1168764kB, anon-rss:489676kB
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine Seite, ein Worker — und schon knapp 500 MB anonymer Speicher. 512 MB sind
|
||||||
|
damit für 300-dpi-Scans keine knappe, sondern eine unzureichende Dimensionierung.
|
||||||
|
|
||||||
|
### RAM ↔ `max_workers` × `jobs` × Auflösung
|
||||||
|
|
||||||
|
Der Spitzenbedarf multipliziert sich über drei Config-Werte aus
|
||||||
|
[`[ocr]`](#ocr):
|
||||||
|
|
||||||
|
| Key | Default | Wirkung auf den Speicher |
|
||||||
|
|-----|---------|--------------------------|
|
||||||
|
| `max_workers` | `2` | **so viele PDFs gleichzeitig** — jede mit eigenem Seitenpuffer. Der direkte Multiplikator |
|
||||||
|
| `jobs` | `4` | Threads **innerhalb** einer PDF; mehrere Seiten gleichzeitig im Speicher |
|
||||||
|
| `oversample` | `300` | Auflösung gerasterter Seiten — der Bedarf wächst quadratisch mit der dpi |
|
||||||
|
|
||||||
|
Der Default `max_workers = 2` erlaubt also, dass **zwei** solcher Seiten
|
||||||
|
parallel verarbeitet werden. Wer knapp dimensioniert, zieht zuerst
|
||||||
|
`max_workers` auf `1` herunter, danach `jobs`. `oversample` unter 300 zu
|
||||||
|
drücken, spart zwar Speicher, kostet aber Erkennungsqualität — das ist der
|
||||||
|
letzte Hebel, nicht der erste.
|
||||||
|
|
||||||
|
### Wie sich ein OOM-Kill äußert
|
||||||
|
|
||||||
|
Von außen sieht ein OOM-Kill wie ein Anwendungsfehler aus — er ist keiner:
|
||||||
|
|
||||||
|
- Der Dienst ist **weg** bzw. wurde von systemd neu gestartet
|
||||||
|
(`Restart=on-failure`); `systemctl show -p NRestarts` steigt.
|
||||||
|
- Im journal bricht die Verarbeitung **mitten in der Datei** ab, ohne
|
||||||
|
Python-Traceback und ohne `ERROR`-Zeile aus dem Tool.
|
||||||
|
- Die betroffene PDF bleibt in `working/` liegen.
|
||||||
|
|
||||||
|
### Wie man ihn nachweist
|
||||||
|
|
||||||
|
**Im Container:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /sys/fs/cgroup/memory.events
|
||||||
|
# oom_kill 1 <- alles über 0 ist ein Treffer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auf dem LXC-Host** (im Container zeigt `dmesg` diese Zeilen nicht):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dmesg -T | grep -i oom-kill
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Prüfung gehört an den **Anfang** der Fehlersuche, wenn Dateien
|
||||||
|
unerklärlich in `working/` liegen bleiben: ohne sie sucht man den Fehler in
|
||||||
|
ocrmypdf, Tesseract oder der Config, wo keiner ist.
|
||||||
|
|
||||||
|
### Datenverlust ist abgefangen, der OOM bleibt
|
||||||
|
|
||||||
|
Seit **v0.6.0** greift der Dienst beim nächsten Start auf, was in `working/`
|
||||||
|
liegen geblieben ist (siehe [UPDATE.md](UPDATE.md#wiederaufnahme-aus-working)).
|
||||||
|
Eine vom OOM-Killer unterbrochene Datei geht also nicht verloren. Behoben ist
|
||||||
|
damit aber nur die Folge: bei unveränderter Dimensionierung läuft dieselbe Datei
|
||||||
|
nach dem Neustart erneut in denselben OOM — bis `max_workers` sinkt oder das
|
||||||
|
System mehr RAM bekommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt install -y git # fehlt im Proxmox-Standard-Template
|
||||||
|
git clone https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git
|
||||||
|
cd pdf-ocr-hotfolder
|
||||||
|
sudo ./install.sh # als root: ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Welche Clone-URL?
|
||||||
|
|
||||||
|
| Variante | URL | Wann |
|
||||||
|
|----------|-----|------|
|
||||||
|
| **HTTPS** (Normalfall) | `https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git` | braucht **keine** Credentials und funktioniert auf einem frischen Server sofort — der Weg, der im Installations-Test benutzt wurde |
|
||||||
|
| **SSH** | `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git` | nur sinnvoll, wenn auf dem Zielsystem ein **Deploy-Key** hinterlegt ist (z.B. weil auch gepusht werden soll) |
|
||||||
|
|
||||||
|
> ⚠️ **Der SSH-User bei `gitea.sonith.de` heißt `gitea`, nicht `git`.**
|
||||||
|
> `git@gitea.sonith.de:…` ist der Default anderer Git-Hoster und hier **falsch**
|
||||||
|
> — der Clone scheitert dann mit `Permission denied (publickey)`.
|
||||||
|
|
||||||
|
`install.sh` ist **Installer und Instanz-Manager in einem** und idempotent —
|
||||||
|
jeder weitere Aufruf überspringt, was schon steht.
|
||||||
|
|
||||||
|
### So sieht ein Erstlauf aus
|
||||||
|
|
||||||
|
Verifiziert auf frischen Debian-12- und Debian-13-Containern. Die Reihenfolge
|
||||||
|
der Abfragen:
|
||||||
|
|
||||||
|
1. **LXC-Drop-in** — nur im Container: „systemd-Hardening-Drop-in für LXC
|
||||||
|
installieren?" → **ja**, sonst scheitert der Start mit `226/NAMESPACE`
|
||||||
|
([warum](#lxccontainer-error-226namespace)).
|
||||||
|
2. **Ghostscript-Hinweis** — nur auf Debian 12 mit betroffener Version. Kein
|
||||||
|
Abbruch: mit dem Default `pdfa_level = ""` ist die Installation
|
||||||
|
unproblematisch ([Hintergrund](#ghostscript-bug-auf-debian-12)).
|
||||||
|
3. **journald-Warnung** — nur, wenn `systemd-journald` nicht läuft. Typisch für
|
||||||
|
Debian 13 in LXC auf Proxmox. Hier **abbrechen**, journald reparieren und neu
|
||||||
|
anfangen — sonst hat der Dienst kein Log
|
||||||
|
([Abhilfe](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)).
|
||||||
|
4. Dann die **fünf Fragen pro Instanz**: Instanz-Name → Basis-Pfad →
|
||||||
|
Service-User → OCR-Sprachen → Original-Behandlung (Archiv/löschen/liegen
|
||||||
|
lassen). Im Detail: [Die Abfragen pro Instanz](#die-abfragen-pro-instanz).
|
||||||
|
5. Zum Schluss `Weitere Instanz anlegen? [j/N]:` — hier entsteht die zweite
|
||||||
|
Instanz, ohne dass irgendetwas am Basis-Install noch einmal angefasst wird.
|
||||||
|
|
||||||
|
Danach läuft `pdf-ocr-hotfolder@<instanz>.service`. Gegenprobe: eine PDF nach
|
||||||
|
`incoming/` kopieren und `journalctl -u pdf-ocr-hotfolder@<instanz> -f`
|
||||||
|
mitlesen.
|
||||||
|
|
||||||
|
### 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 (inkl. [journald-Prüfung](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)), Default-User `pdfocr`, Code **und `lib/`** 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, in der Reihenfolge der folgenden
|
||||||
|
Abschnitte: Instanz-Name, Basis-Pfad, Service-User, OCR-Sprachen,
|
||||||
|
Original-Behandlung. Alle Antworten gelten **nur für diese Instanz** — nichts
|
||||||
|
davon ist global.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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** —
|
||||||
|
enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf
|
||||||
|
verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern.
|
||||||
|
|
||||||
|
### Wann genau ocrmypdf abbricht
|
||||||
|
|
||||||
|
Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py`
|
||||||
|
(`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der
|
||||||
|
ocrmypdf-Version:
|
||||||
|
|
||||||
|
| ocrmypdf | Die Prüfung greift bei | Heißt für uns |
|
||||||
|
|----------|------------------------|---------------|
|
||||||
|
| **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default |
|
||||||
|
| **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch |
|
||||||
|
|
||||||
|
> ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur
|
||||||
|
> zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf
|
||||||
|
> `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` —
|
||||||
|
> bei grünem `systemctl status` und „Preflight ok".
|
||||||
|
> `requirements.txt` pinnt deshalb 17.x.
|
||||||
|
|
||||||
|
### Was das Tool dagegen tut
|
||||||
|
|
||||||
|
- `pdfa_level = ""` ist der Default (kein PDF/A-Output).
|
||||||
|
- `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede
|
||||||
|
Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der
|
||||||
|
gepinnten Pakete deshalb ausdrücklich aus.
|
||||||
|
- Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die
|
||||||
|
Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`,
|
||||||
|
`pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch
|
||||||
|
führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei
|
||||||
|
einzeln scheitern zu lassen.
|
||||||
|
- `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt
|
||||||
|
ocrmypdf- und Ghostscript-Version an (siehe
|
||||||
|
[UPDATE.md](UPDATE.md#config-prüfung-per---check-config)).
|
||||||
|
- Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update
|
||||||
|
eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt.
|
||||||
|
|
||||||
|
### Abhilfe
|
||||||
|
|
||||||
|
> 🚫 **Es gibt auf Debian 12 kein neueres Ghostscript.** `bookworm-backports`
|
||||||
|
> führt **kein** Ghostscript-Paket — am Paketindex geprüft
|
||||||
|
> (`bookworm-backports/main/binary-amd64`: 2606 Pakete, `ghostscript` nicht
|
||||||
|
> darunter, `systemd`/`golang-go`/`linux-image-amd64` schon). Ältere Fassungen
|
||||||
|
> dieser Anleitung und frühere Versionen des Installers haben genau das
|
||||||
|
> empfohlen — der Rat konnte nie funktionieren.
|
||||||
|
>
|
||||||
|
> **Planungsaussage:** Wer auf Debian 12 **PDF/A in der schnellen Betriebsart**
|
||||||
|
> (`skip_text = true`) braucht, hat dort **keinen Weg** — weder über Backports
|
||||||
|
> noch sonst. Es bleibt: PDF/A aufgeben, `skip_text` aufgeben (Weg 2, kostet
|
||||||
|
> Laufzeit) oder Debian 13. **Das gehört vor die Installation**, nicht in den
|
||||||
|
> Moment, in dem der Preflight abbricht.
|
||||||
|
|
||||||
|
**Weg 1 — `pdfa_level = ""` lassen** (Default, empfohlen). Ohne PDF/A-Ausgabe
|
||||||
|
fasst ocrmypdf ≥ 17 Ghostscript gar nicht an; die betroffene Version ist dann
|
||||||
|
völlig unproblematisch. Der Preis ist PDF/A — das Ergebnis ist eine normale
|
||||||
|
durchsuchbare PDF. Für die übliche Anwendung (Scan wird durchsuchbar) reicht
|
||||||
|
das.
|
||||||
|
|
||||||
|
**Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt
|
||||||
|
statt übersprungen, und die Bedingung greift nicht mehr — PDF/A ist damit auch
|
||||||
|
auf Debian 12 möglich. Das kostet Laufzeit bei PDFs, die bereits eine Textebene
|
||||||
|
haben, und OCRt sie ein zweites Mal.
|
||||||
|
|
||||||
|
**Weg 3 — Distribution mit neuerem Ghostscript.** **Debian 13 liefert
|
||||||
|
Ghostscript 10.05.1** und ist vom Bug nicht betroffen; dort ist PDF/A zusammen
|
||||||
|
mit `skip_text = true` ohne Einschränkung nutzbar. Wer PDF/A verbindlich
|
||||||
|
braucht, installiert von vornherein auf Debian 13.
|
||||||
|
|
||||||
|
| Ich brauche … | Debian 12 | Debian 13 |
|
||||||
|
|---------------|-----------|-----------|
|
||||||
|
| durchsuchbare PDF, kein PDF/A | ✅ Default (`pdfa_level = ""`) | ✅ |
|
||||||
|
| PDF/A **und** `skip_text = true` | ❌ kein Weg | ✅ |
|
||||||
|
| PDF/A mit `skip_text = false` | ✅ (langsamer) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Alle vier Pfade müssen absolut sein.** Ein relativer Pfad wird gegen das
|
||||||
|
Arbeitsverzeichnis des *Prozesses* aufgelöst, bei der Unit also gegen
|
||||||
|
`WorkingDirectory=/opt/pdf-ocr-hotfolder` — **nicht** gegen das Verzeichnis, in
|
||||||
|
dem die Config liegt. `incoming = "in"` legte damit still
|
||||||
|
`/opt/pdf-ocr-hotfolder/in` an: der Scanner schreibt woanders hin als der
|
||||||
|
Dienst schaut, und niemand sieht einen Fehler. Seit 0.7.0 ist das ein
|
||||||
|
Config-Fehler mit **Exit 2**. Dieselbe Regel gilt für
|
||||||
|
[`[output].archive_dir`](#output) und
|
||||||
|
[`[upload.folder].target`](#uploadfolder--uploadnextcloud--uploadsftp);
|
||||||
|
`install.sh` erzeugt ohnehin nur absolute Pfade.
|
||||||
|
|
||||||
|
`incoming` muss außerdem auf einem **lokalen** Dateisystem liegen — siehe
|
||||||
|
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
|
||||||
|
|
||||||
|
### `[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). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 |
|
||||||
|
| `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 (relativ wird abgelehnt), **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.
|
||||||
|
|
||||||
|
**Kollisionen überschreiben nichts.** Liegt im Ziel bereits eine Datei
|
||||||
|
desselben Namens, wird die neue mit Zeitstempel danebengelegt
|
||||||
|
(`scan.pdf` → `scan_20260923-081500.pdf`; bei zwei Dateien innerhalb derselben
|
||||||
|
Sekunde zusätzlich mit Zähler), und es gibt eine Warnung im Journal. Das gilt
|
||||||
|
seit 0.7.0 einheitlich für **`outgoing/`, das Archiv, `error/` und den
|
||||||
|
Ordner-Upload** — vorher ersetzte der zweite Durchlauf das Ergebnis des ersten
|
||||||
|
kommentarlos. Die E-Mail-Benachrichtigung und die Upload-Ziele nennen den
|
||||||
|
tatsächlich geschriebenen Namen.
|
||||||
|
|
||||||
|
**Scheitert das Entsorgen des Originals** (Platte voll, Verzeichnis
|
||||||
|
read-only), gilt der Durchlauf trotzdem als Erfolg: das fertige PDF liegt
|
||||||
|
bereits in `outgoing/` und wird normal ausgeliefert. Es gibt aber eine
|
||||||
|
`ERROR`-Zeile im Journal, und die Mail geht als **„OK mit Warnung"** raus —
|
||||||
|
auch bei `[notify.email].on = "errors"`. Das Original bleibt dann in
|
||||||
|
`working/` liegen und wird beim nächsten Start **erneut** durch das OCR
|
||||||
|
geschickt; es gehört von Hand aufgeräumt und die Ursache behoben.
|
||||||
|
|
||||||
|
### `[verapdf]`
|
||||||
|
|
||||||
|
| Key | Default | Bedeutung |
|
||||||
|
|-----|---------|-----------|
|
||||||
|
| `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI |
|
||||||
|
| `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary, oder ein nackter Name, der im `PATH` gesucht wird |
|
||||||
|
| `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).
|
||||||
|
|
||||||
|
**Mit `enabled = true` wird `binary` im Preflight geprüft** (seit 0.7.0). Zeigt
|
||||||
|
der Pfad nicht auf ein vorhandenes, ausführbares Programm, startet der Dienst
|
||||||
|
gar nicht erst (**Exit 2**), und `--check-config` meldet denselben Fehler.
|
||||||
|
`--check-config` zeigt Binary und Flavour außerdem in der Übersicht an.
|
||||||
|
|
||||||
|
> ⚠️ **Warum das eine harte Sperre ist.** Bis 0.6.3 war ein Tippfehler in
|
||||||
|
> `binary` der gefährlichste Fehler des ganzen Dienstes: `run_verapdf()` fand
|
||||||
|
> das Programm für **jede** Datei nicht, wertete das als „nicht konform",
|
||||||
|
> schob das OCR-Ergebnis nach `error/` — und entsorgte das Original laut
|
||||||
|
> `original_on_success`, beim Default `delete` also die Vorlage. Scan für Scan
|
||||||
|
> verschwanden so die Originale, während die Unit als `active (running)`
|
||||||
|
> dastand.
|
||||||
|
|
||||||
|
**Störung ist kein FAIL.** Lässt sich veraPDF im laufenden Betrieb nicht mehr
|
||||||
|
befragen — Programm verschwunden, JVM startet nicht, Timeout (300 s pro Datei),
|
||||||
|
oder die Ausgabe enthält weder `PASS` noch `FAIL` —, ist das **kein Urteil über
|
||||||
|
die PDF**. In diesem Fall wandern **Original und OCR-Ergebnis** nach `error/`,
|
||||||
|
und das Original wird **weder gelöscht noch archiviert**, unabhängig von
|
||||||
|
`original_on_success`. Im Journal steht die Ursache samt Hinweis auf
|
||||||
|
`--check-config`.
|
||||||
|
|
||||||
|
### `[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; sonst **absoluter** Pfad (relativ wird abgelehnt, Exit 2) |
|
||||||
|
| `[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.
|
||||||
|
|
||||||
|
Liegt im `target` von `[upload.folder]` schon eine gleichnamige Datei, wird sie
|
||||||
|
seit 0.7.0 **nicht mehr ersetzt**, sondern die Kopie mit Zeitstempel
|
||||||
|
danebengelegt (samt Warnung) — dieselbe Regel wie in [`[output]`](#output).
|
||||||
|
|
||||||
|
### `[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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exit-Codes
|
||||||
|
|
||||||
|
Der Dienst und die CLI benutzen vier Codes. `systemctl status` und
|
||||||
|
`journalctl` zeigen sie als `status=<n>`.
|
||||||
|
|
||||||
|
| Exit | Bedeutung | Startet systemd neu? | Was zu tun ist |
|
||||||
|
|------|-----------|----------------------|----------------|
|
||||||
|
| **0** | regulärer Stopp (SIGTERM/SIGINT); bei `--once`: alles verarbeitet, auch „nichts da"; bei `--check-config`: Config sauber | — | nichts |
|
||||||
|
| **1** | nur im Einmal-Betrieb: mindestens eine PDF ist fehlgeschlagen. Bei `--check-config`: Config nutzbar, aber mit **Warnungen** | — | `error/` ansehen bzw. Warnungen nachziehen |
|
||||||
|
| **2** | **Config- oder Preflight-Fehler** — kaputtes/unlesbares TOML, fehlender Pflicht-Key, relativer Pfad, ungültige `[output]`-Werte, fehlendes `tesseract`/`gs`, betroffene Ghostscript-Version, nicht aufrufbares veraPDF | **nein** — `RestartPreventExitStatus=2` in der Unit | Config korrigieren, mit `--check-config` gegenprüfen, dann `systemctl start` |
|
||||||
|
| **3** | **Der Verzeichnis-Watch ist gestorben** — es würden keine neuen Dateien mehr erkannt | **ja**, und genau darum geht es | meist nichts; häuft es sich, `fs.inotify.max_user_watches` und den Mount von `incoming/` prüfen |
|
||||||
|
|
||||||
|
**Zu Exit 2:** Ein Neustart heilt einen Config-Fehler nicht. Ohne
|
||||||
|
`RestartPreventExitStatus=2` startete `Restart=on-failure` die Instanz endlos
|
||||||
|
im 5-Sekunden-Takt neu (das Start-Rate-Limit greift bei `RestartSec=5` nie).
|
||||||
|
Seit 0.7.0 bleibt sie stattdessen sichtbar `failed` stehen — das ist gewollt
|
||||||
|
und soll beim Nachsehen auffallen.
|
||||||
|
|
||||||
|
**Zu Exit 3:** Stirbt der watchdog-Observer im Betrieb (erschöpftes
|
||||||
|
`fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis),
|
||||||
|
blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr —
|
||||||
|
für einen Hotfolder der schlechteste denkbare Zustand. Der Dienst prüft den
|
||||||
|
Observer jetzt sekündlich mit, loggt eine `ERROR`-Zeile mit den möglichen
|
||||||
|
Ursachen und beendet sich mit 3, damit systemd ihn neu startet und der Watch
|
||||||
|
neu aufgesetzt wird. Ein einzelnes Vorkommnis ist damit selbstheilend; ein
|
||||||
|
steigendes `systemctl show -p NRestarts` ist der Hinweis, dass man nachsehen
|
||||||
|
sollte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: Dienst startet nicht / Dateien landen in `error/`
|
||||||
|
|
||||||
|
Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit
|
||||||
|
**Exit 2** nicht mehr, nachdem vorher „alles lief", ist das die gute Nachricht:
|
||||||
|
der Pfad war schon vorher falsch, nur hat es bisher niemand gemerkt. Vorher
|
||||||
|
wurde jede PDF als ungültig gewertet und das Original laut
|
||||||
|
`original_on_success` entsorgt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /opt/verapdf/verapdf # vorhanden? ausführbar (chmod +x)?
|
||||||
|
```
|
||||||
|
|
||||||
|
Korrigieren — oder, wenn die Validierung nicht zwingend gebraucht wird,
|
||||||
|
`[verapdf].enabled = false` setzen. Landen **Original und OCR-Ergebnis
|
||||||
|
gemeinsam** in `error/`, war veraPDF im laufenden Betrieb nicht mehr
|
||||||
|
ansprechbar; das Original ist dann unangetastet, siehe
|
||||||
|
[`[verapdf]`](#verapdf).
|
||||||
|
|
||||||
|
### Dienst startet nicht (Exit 2)
|
||||||
|
|
||||||
|
Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes).
|
||||||
|
Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet.
|
||||||
|
Die Ursache steht im journal und ausführlicher in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
|
--check-config --config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Typische Fälle: kaputtes TOML (die Meldung nennt Zeile und Spalte, sofern der
|
||||||
|
Interpreter sie liefert), ein relativer Pfad in `[paths]`,
|
||||||
|
`[output].archive_dir` oder `[upload.folder].target`, ein leeres `archive_dir`
|
||||||
|
bei `original_on_success = "archive"`, ein nicht aufrufbares veraPDF oder eine
|
||||||
|
betroffene Ghostscript-Version.
|
||||||
|
|
||||||
|
### Dienst läuft, verarbeitet aber nichts mehr
|
||||||
|
|
||||||
|
`systemctl status` sagt `active (running)`, in `incoming/` stapeln sich die
|
||||||
|
PDFs, im Journal passiert nichts. Drei Ursachen, in dieser Reihenfolge prüfen:
|
||||||
|
|
||||||
|
1. **`incoming/` liegt auf einem CIFS/NFS-Mount.** Dann liefert inotify
|
||||||
|
grundsätzlich keine Events — der Dienst verarbeitet nur noch beim Start.
|
||||||
|
`findmnt -T /var/lib/pdf-ocr-hotfolder/<instanz>/incoming` zeigt den Typ;
|
||||||
|
Hintergrund und richtiger Aufbau unter
|
||||||
|
[Dateisystem](#dateisystem-ext4-xfs-oder-zfs).
|
||||||
|
2. **Der Verzeichnis-Watch ist gestorben.** Seit 0.7.0 fällt das auf: der
|
||||||
|
Dienst beendet sich mit [Exit 3](#exit-codes) und systemd startet ihn neu.
|
||||||
|
Im Journal steht die `ERROR`-Zeile, `systemctl show -p NRestarts` steigt.
|
||||||
|
Häuft sich das, ist meist das inotify-Limit erschöpft:
|
||||||
|
```bash
|
||||||
|
cat /proc/sys/fs/inotify/max_user_watches
|
||||||
|
```
|
||||||
|
3. **Es sind gar keine PDFs.** Dateien ohne `.pdf`-Endung werden ignoriert.
|
||||||
|
Beim Start-Scan meldet der Dienst sie seit 0.7.0 als Sammelzeile mit Anzahl
|
||||||
|
und bis zu drei Beispielnamen:
|
||||||
|
```bash
|
||||||
|
journalctl -u pdf-ocr-hotfolder@<instanz> | grep 'ohne .pdf-Endung'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dienst startet nicht (203/EXEC)
|
||||||
|
|
||||||
|
Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade.
|
||||||
|
Siehe [OS-UPGRADE.md](OS-UPGRADE.md).
|
||||||
|
|
||||||
|
### Keine Logs: "No journal files were found"
|
||||||
|
|
||||||
|
**Symptom:** `journalctl -u pdf-ocr-hotfolder@<instanz>` bleibt leer oder meldet
|
||||||
|
`No journal files were found.` — auch dann, wenn der Dienst nachweislich läuft
|
||||||
|
und Dateien verarbeitet.
|
||||||
|
|
||||||
|
**Erste Prüfung:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status systemd-journald
|
||||||
|
```
|
||||||
|
|
||||||
|
Ist `systemd-journald.service` selbst `failed` (beobachtet mit
|
||||||
|
`status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben
|
||||||
|
werden könnte.
|
||||||
|
|
||||||
|
Läuft journald dagegen `active (running)` und das Journal der Instanz ist
|
||||||
|
trotzdem leer (`-- No entries --`), ist meist journald **erst nach dem Dienst**
|
||||||
|
wieder hochgekommen: die Startmeldungen hatten in der Zwischenzeit kein Ziel und
|
||||||
|
sind weg. `sudo systemctl restart 'pdf-ocr-hotfolder@*'` schreibt sie neu —
|
||||||
|
siehe die Warnung im
|
||||||
|
[nächsten Abschnitt](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials).
|
||||||
|
|
||||||
|
**Einordnung.** Der Dienst loggt seit v0.4.1 **ausschließlich** nach journald —
|
||||||
|
es gibt bewusst kein eigenes Logfile und kein Logverzeichnis. Ein kaputtes
|
||||||
|
journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der
|
||||||
|
Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche
|
||||||
|
zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt.
|
||||||
|
|
||||||
|
Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern
|
||||||
|
systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten
|
||||||
|
Abschnitt. Auf Debian 12 tritt sie nicht auf.
|
||||||
|
|
||||||
|
`install.sh` warnt beim Erstinstall in einem Container von sich aus, wenn
|
||||||
|
`systemd-journald` nicht läuft, nennt den Drop-in-Befehl und fragt, ob
|
||||||
|
fortgefahren werden soll.
|
||||||
|
|
||||||
|
**Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im
|
||||||
|
Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am
|
||||||
|
journal vorbei.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop pdf-ocr-hotfolder@<instanz>
|
||||||
|
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \
|
||||||
|
--config /etc/pdf-ocr-hotfolder/<instanz>.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach
|
||||||
|
`/opt/pdf-ocr-hotfolder` kopiert und nur über das Arbeitsverzeichnis gefunden
|
||||||
|
(ohne `cd` gibt es `No module named pdf_ocr_hotfolder`, v0.6.2). Beenden mit
|
||||||
|
`Strg+C`, danach `sudo systemctl start pdf-ocr-hotfolder@<instanz>`. Wer nur den
|
||||||
|
Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe
|
||||||
|
[Manueller Lauf](#manueller-lauf-one-shot)).
|
||||||
|
|
||||||
|
### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS)
|
||||||
|
|
||||||
|
Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht
|
||||||
|
nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern
|
||||||
|
alles, was auf dem Container-journal aufsetzt.
|
||||||
|
|
||||||
|
**Symptom im Container:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status systemd-journald
|
||||||
|
# ● systemd-journald.service - Journal Service
|
||||||
|
# Active: failed
|
||||||
|
# Process: ... (code=exited, status=243/CREDENTIALS)
|
||||||
|
# Main PID exited, status=243/CREDENTIALS
|
||||||
|
|
||||||
|
journalctl -u pdf-ocr-hotfolder@<instanz>
|
||||||
|
# No journal files were found.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die
|
||||||
|
journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials
|
||||||
|
startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür.
|
||||||
|
Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des
|
||||||
|
**Hosts** steht dazu:
|
||||||
|
|
||||||
|
```
|
||||||
|
apparmor="DENIED" operation="mount" profile="lxc-<id>_</var/lib/lxc>" name="/dev/" comm="(sd-mkdcreds)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und
|
||||||
|
ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser,
|
||||||
|
nicht der Container an sich.
|
||||||
|
|
||||||
|
**Dieselbe Ursache legt weitere Units lahm** — beobachtet bei
|
||||||
|
`systemd-logind`, `systemd-networkd`, `console-getty` und
|
||||||
|
`systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor
|
||||||
|
sich; ein Blick auf `systemctl --failed` lohnt sich.
|
||||||
|
|
||||||
|
**Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird
|
||||||
|
per Drop-in auf leer gesetzt und damit abgeschaltet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/systemd/system/systemd-journald.service.d
|
||||||
|
printf '[Service]\nImportCredential=\n' | \
|
||||||
|
sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart systemd-journald
|
||||||
|
sudo journalctl --flush
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `systemctl status systemd-journald` gegenprüfen — muss `active
|
||||||
|
(running)` sein. Für die anderen betroffenen Units gilt dasselbe Muster mit
|
||||||
|
deren Unit-Namen.
|
||||||
|
|
||||||
|
> ⚠️ **Jetzt die Instanzen einmal neu starten — sonst steht man vor einem
|
||||||
|
> leeren Journal.** Wurde der Dienst gestartet, **während** journald tot war,
|
||||||
|
> sind seine Startmeldungen unwiederbringlich weg: sie hatten kein Ziel. Nach
|
||||||
|
> der Reparatur meldet `journalctl -u pdf-ocr-hotfolder@<instanz>` dann
|
||||||
|
> `-- No entries --` — und das sieht exakt so aus wie ein gescheiterter
|
||||||
|
> Reparaturversuch, obwohl journald längst wieder läuft. Der Neustart schreibt
|
||||||
|
> die Startmeldungen neu ins frische Journal:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> sudo systemctl restart 'pdf-ocr-hotfolder@*'
|
||||||
|
> journalctl -u pdf-ocr-hotfolder@<instanz> -n 20
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Erst wenn hier die Startmeldungen stehen, ist die Reparatur belegt. (`restart`
|
||||||
|
> kann das Glob, weil die Units geladen sind — `start` nicht, siehe
|
||||||
|
> [UPDATE.md](UPDATE.md#rollback).)
|
||||||
|
|
||||||
|
> **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im
|
||||||
|
> Container. Richtig behoben wird es auf dem Proxmox-Host: Update von
|
||||||
|
> `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln —
|
||||||
|
> oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der
|
||||||
|
> Container-Config, was die AppArmor-Isolation dieses Containers allerdings
|
||||||
|
> komplett aufgibt.
|
||||||
|
|
||||||
|
### Dienst bricht mitten in der Verarbeitung weg
|
||||||
|
|
||||||
|
Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast
|
||||||
|
immer der OOM-Killer, kein Anwendungsfehler. Nachweis und Dimensionierung unter
|
||||||
|
[Systemanforderungen](#wie-sich-ein-oom-kill-äußert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manueller Lauf (One-Shot)
|
||||||
|
|
||||||
|
Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch
|
||||||
|
Dateien auf, die in `working/` liegen geblieben sind:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./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. Exit 3 gibt es hier
|
||||||
|
nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes).
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **`sudo` oder direkt als `root`.** Alle Befehle dieser Seite brauchen
|
||||||
|
> root-Rechte, nicht `sudo`. Wer als `root` arbeitet — im
|
||||||
|
> Proxmox-Debian-Standard-Template der Normalfall, dort ist `sudo` gar nicht
|
||||||
|
> installiert —, lässt das `sudo` einfach weg. Siehe
|
||||||
|
> [INSTALLATION.md](INSTALLATION.md#root-oder-sudo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
cd /opt/pdf-ocr-hotfolder
|
||||||
|
for f in /etc/pdf-ocr-hotfolder/*.toml; do
|
||||||
|
sudo ./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 — **Debian
|
||||||
|
13 liefert Ghostscript 10.05.1**. Damit ist PDF/A (`[ocr].pdfa_level = "1"`,
|
||||||
|
`"2"` oder `"3"`) zusammen mit `skip_text = true` erstmals ohne Einschränkung
|
||||||
|
nutzbar; auf Debian 12 gab es dafür **keinen** Weg (ein Upgrade aus
|
||||||
|
`bookworm-backports` existiert nicht, dort liegt kein Ghostscript-Paket). Genau
|
||||||
|
das ist oft der Grund für das Upgrade. Hintergrund:
|
||||||
|
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||||
|
|
||||||
|
Hat jemand auf dem alten System nach der früheren, falschen Empfehlung einen
|
||||||
|
`bookworm-backports`-Eintrag unter `/etc/apt/sources.list.d/` angelegt, gehört
|
||||||
|
er nach dem Upgrade entfernt — Ghostscript kam ohnehin nie daher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/bookworm-backports.list
|
||||||
|
sudo apt update
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pins in `requirements.txt`
|
||||||
|
|
||||||
|
Die Python-Abhängigkeiten sind **bewusst fest gepinnt**:
|
||||||
|
|
||||||
|
```
|
||||||
|
ocrmypdf==17.4.1
|
||||||
|
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 — der nächste Sprung wäre ocrmypdf **17 auf 18**, und der
|
||||||
|
reißt sonst alle Instanzen auf einmal, und zwar im Moment des Updates, nicht zu
|
||||||
|
einem Zeitpunkt, den man sich ausgesucht hat.
|
||||||
|
|
||||||
|
> ⚠️ **ocrmypdf darf nicht unter 17 fallen.** Bis einschließlich 16.x prüft
|
||||||
|
> ocrmypdf die Ghostscript-Version auch dann, wenn gar kein PDF/A erzeugt wird —
|
||||||
|
> auf Debian 12 (Ghostscript 10.0.0) scheitert damit **jede** PDF, weil
|
||||||
|
> `skip_text = true` unser Default ist. Genau das war der Ausfall in 0.6.0.
|
||||||
|
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||||
|
|
||||||
|
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. Das gilt
|
||||||
|
auch für die Abhängigkeiten, die ocrmypdf 17 zusätzlich mitbringt (`pydantic`,
|
||||||
|
`pypdfium2`, `fpdf2`, `uharfbuzz`).
|
||||||
|
|
||||||
|
**Beim Anheben:**
|
||||||
|
|
||||||
|
1. **Testmaschine benutzen** — nie direkt auf dem produktiven Hotfolder.
|
||||||
|
2. Prüfen, dass es für die neue Version auf **beiden** Python-Versionen fertige
|
||||||
|
Wheels gibt, sonst wird auf dem Zielsystem kompiliert:
|
||||||
|
```bash
|
||||||
|
pip install --dry-run --only-binary=:all: --python-version 3.11 \
|
||||||
|
--target /tmp/wheelcheck ocrmypdf==<version>
|
||||||
|
pip install --dry-run --only-binary=:all: --python-version 3.13 \
|
||||||
|
--target /tmp/wheelcheck ocrmypdf==<version>
|
||||||
|
```
|
||||||
|
3. Dort `update.sh --rebuild-venv` fahren, damit die Pakete wirklich frisch
|
||||||
|
aufgelöst werden.
|
||||||
|
4. `pytest` muss grün bleiben (254 Tests).
|
||||||
|
5. Eine echte PDF durchschieben — die Test-Suite mockt ocrmypdf, ein Major-Sprung
|
||||||
|
fällt dort also nicht auf. Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh`
|
||||||
|
macht genau das automatisch.
|
||||||
|
6. 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.
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
# 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)
|
||||||
|
sudo ./update.sh --no-smoke-test # ohne Rauchtest durchlaufen
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`sudo` oder direkt als `root`.** `update.sh` braucht root-Rechte, nicht
|
||||||
|
> `sudo`. Wer als `root` arbeitet — im Proxmox-Debian-Standard-Template der
|
||||||
|
> Normalfall, dort ist `sudo` gar nicht installiert —, lässt das `sudo` bei
|
||||||
|
> jedem Befehl dieser Seite einfach weg: `./update.sh`. Ebenso setzt `git pull`
|
||||||
|
> ein installiertes **`git`** voraus; siehe
|
||||||
|
> [INSTALLATION.md](INSTALLATION.md#pakete-die-auf-einem-frischen-system-fehlen-können).
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
`install.sh` und `update.sh` teilen sich seit 0.7.0 die Datei `lib/common.sh`
|
||||||
|
(Log-Funktionen, Root-Prüfung, Layout-Pfade, apt-Paketliste, venv-Prüfung).
|
||||||
|
`update.sh` sourct bevorzugt die Fassung **neben sich** im Repo und fällt auf
|
||||||
|
die installierte unter `/opt/pdf-ocr-hotfolder/lib/` zurück; fehlt sie
|
||||||
|
überall, bricht es sofort ab statt mitten im Lauf. Die apt-Paketliste schneidet
|
||||||
|
es zusätzlich noch einmal per `sed` aus der Repo-Fassung heraus — beim Update
|
||||||
|
soll die neue Liste gelten, nicht die eventuell ältere installierte Kopie.
|
||||||
|
|
||||||
|
## 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 `lib/common.sh` des Repos 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/`, **`lib/`**, `requirements.txt`, `VERSION`, `config.example.toml`, `.repo_path` |
|
||||||
|
| 7 | **Dependencies** | `pip install --upgrade -r requirements.txt` — oder venv-Neubau, falls nötig. Versionssprünge der gepinnten Pakete werden [benannt](#versionssprünge-der-kernabhängigkeiten) |
|
||||||
|
| 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 | **Rauchtest** | eine Test-PDF durch die echte Pipeline, siehe [unten](#rauchtest) |
|
||||||
|
| 13 | **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 **inkl. `lib/`**) | 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` | |
|
||||||
|
| `opt/pdf-ocr-hotfolder/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. Sie
|
||||||
|
liegt im Archiv unter `opt/pdf-ocr-hotfolder/` und landet beim Entpacken nach
|
||||||
|
`/` folglich als `/opt/pdf-ocr-hotfolder/pip-freeze.txt` — also neben der
|
||||||
|
Installation statt im Wurzelverzeichnis. Ein Code-Tausch beim nächsten Update
|
||||||
|
löscht sie nicht (dort fliegen nur `pdf_ocr_hotfolder/` und `lib/`).
|
||||||
|
|
||||||
|
**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@kunde-a
|
||||||
|
sudo systemctl start pdf-ocr-hotfolder@kunde-b # jede Instanz einzeln!
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript nennt diesen Befehl mit dem konkreten Archivnamen selbst — sowohl
|
||||||
|
beim Abbruch als auch bei einer erkannten Regression.
|
||||||
|
|
||||||
|
> ⚠️ **`start` kann kein Glob.** `systemctl stop 'pdf-ocr-hotfolder@*'` trifft
|
||||||
|
> alle laufenden Instanzen, weil systemd dafür die **bereits geladenen** Units
|
||||||
|
> auflösen kann. Beim Starten gibt es nichts aufzulösen:
|
||||||
|
> `systemctl start 'pdf-ocr-hotfolder@*'` startet eine Instanz mit dem
|
||||||
|
> wörtlichen Namen `*` — und scheitert. **Bei mehreren Instanzen muss jede
|
||||||
|
> einzeln gestartet werden.** Welche es sind:
|
||||||
|
> `ls /etc/pdf-ocr-hotfolder/*.toml`. Am Stück:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> for f in /etc/pdf-ocr-hotfolder/*.toml; do
|
||||||
|
> n=$(basename "$f" .toml)
|
||||||
|
> sudo systemctl start "pdf-ocr-hotfolder@$n"
|
||||||
|
> done
|
||||||
|
> systemctl status 'pdf-ocr-hotfolder@*' --no-pager
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Was ein Rollback nachweislich zurückholt
|
||||||
|
|
||||||
|
Einmal real durchgespielt (Debian 12 **und** 13, Update auf den neuen Stand,
|
||||||
|
danach Rollback auf das Backup). Zurück kamen korrekt:
|
||||||
|
|
||||||
|
- **der Code** unter `/opt/pdf-ocr-hotfolder/` inklusive `lib/` — die alte
|
||||||
|
Version lief danach wieder,
|
||||||
|
- **alle Instanz-Configs** unter `/etc/pdf-ocr-hotfolder/` — **mit den
|
||||||
|
`640`-Rechten und dem `root:<service-gruppe>`-Eigentum**; die
|
||||||
|
Klartext-Passwörter bleiben also geschützt, `tar` stellt Modus und Eigentümer
|
||||||
|
mit her,
|
||||||
|
- die **Template-Unit** `pdf-ocr-hotfolder@.service` und
|
||||||
|
- die **Drop-ins** unter `…@*.service.d/` (LXC-Kompat, User-Drop-in).
|
||||||
|
|
||||||
|
Nach `daemon-reload` und dem Einzelstart liefen die Instanzen wieder mit dem
|
||||||
|
alten Stand. Was dabei **nicht** zurückkommt, steht im nächsten Abschnitt.
|
||||||
|
|
||||||
|
### Grenzen des Rollbacks
|
||||||
|
|
||||||
|
Ein Rollback ist ein **Overlay**, kein exaktes Zurücksetzen — beides im Test
|
||||||
|
bestätigt:
|
||||||
|
|
||||||
|
- **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 — nach dem Entpacken unter `/opt/pdf-ocr-hotfolder/pip-freeze.txt`: die
|
||||||
|
dort genannten Versionen lassen sich von Hand wiederherstellen
|
||||||
|
(`venv/bin/pip install -r …`).
|
||||||
|
Im Test lief nach dem Rollback die **alte** Code-Version in der **neuen**
|
||||||
|
venv — was hier gutging, weil sich die Pins nicht geändert hatten. Verlassen
|
||||||
|
darf man sich darauf nicht: nach einem Update mit Versionssprung gehört die
|
||||||
|
venv nach dem Rollback von Hand auf den alten Paketstand gebracht.
|
||||||
|
- **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 — im Test nachgestellt und
|
||||||
|
bestätigt. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionssprünge der Kernabhängigkeiten
|
||||||
|
|
||||||
|
`update.sh` misst die Versionen der in `requirements.txt` gepinnten Pakete
|
||||||
|
**vor** und **nach** `pip install` und benennt jede Änderung:
|
||||||
|
|
||||||
|
```
|
||||||
|
[WARN] DOWNGRADE: ocrmypdf: 17.4.1 -> 16.13.0
|
||||||
|
[INFO] Upgrade: watchdog: 5.0.0 -> 6.0.0
|
||||||
|
[INFO] Neu: requests 2.33.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Beides steht auch noch einmal in der Abschluss-Zusammenfassung, weil es im
|
||||||
|
Fließtext zwischen den pip-Ausgaben untergeht.
|
||||||
|
|
||||||
|
**Downgrades sind der interessante Fall.** Sie entstehen, wenn ein Pin in
|
||||||
|
`requirements.txt` gesenkt wurde. Genau so ist der Totalausfall in 0.6.0
|
||||||
|
entstanden: `ocrmypdf` wurde von 17.4.1 auf 16.13.0 heruntergezogen, das Update
|
||||||
|
lief mit Exit 0 durch, der Dienst meldete `active` — und jede PDF landete in
|
||||||
|
`error/`. Sichtbar war davon nichts außer `[INFO] Dependencies ok ✓`.
|
||||||
|
|
||||||
|
War ein Downgrade nicht beabsichtigt: Pin korrigieren und
|
||||||
|
`sudo ./update.sh --rebuild-venv` erneut fahren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rauchtest
|
||||||
|
|
||||||
|
Nach dem Start schiebt `update.sh` pro Instanz eine winzige Test-PDF durch die
|
||||||
|
**echte** Pipeline und prüft, ob sie in `outgoing/` ankommt.
|
||||||
|
|
||||||
|
Das ist der Schritt, den 0.6.0 gefehlt hat: `systemctl` sagt `active`,
|
||||||
|
`--check-config` sagt `Preflight ok` — und trotzdem scheitert jede einzelne
|
||||||
|
Datei. Ein laufender Dienst ist eben kein Beleg dafür, dass er etwas
|
||||||
|
verarbeitet.
|
||||||
|
|
||||||
|
- Die Test-PDF steckt als base64 **im Skript** (694 Bytes, eine Seite). Es
|
||||||
|
braucht also kein Pillow, kein `gs` und kein `convert` auf dem Zielsystem.
|
||||||
|
- Der Dateiname ist eindeutig (`__smoketest_update_<zeitstempel>_<pid>.pdf`) und
|
||||||
|
kann mit keiner Kundendatei kollidieren.
|
||||||
|
- Wartezeit: `SMOKE_TIMEOUT` Sekunden (Standard 90), danach gilt der Test als
|
||||||
|
durchgefallen. Das Skript hängt nicht.
|
||||||
|
|
||||||
|
### Aufräumen
|
||||||
|
|
||||||
|
Test-PDF **und** Ergebnis werden danach restlos entfernt — in jedem Ausgang,
|
||||||
|
auch bei Fehlschlag und Timeout. Angefasst werden dabei ausschließlich Dateien
|
||||||
|
mit dem Testnamen, in `incoming/`, `working/` (inkl. `__ocr_`-Zwischendatei),
|
||||||
|
`outgoing/`, `error/` und im Archivverzeichnis. In keinem dieser Verzeichnisse
|
||||||
|
bleibt etwas vom Test liegen.
|
||||||
|
|
||||||
|
### Wann der Rauchtest übersprungen wird
|
||||||
|
|
||||||
|
Hat eine Instanz ein aktives Ziel, würde die Testdatei **nach außen** gehen —
|
||||||
|
im Zweifel zum Kunden. Solche Instanzen werden mit klarer Meldung übersprungen:
|
||||||
|
|
||||||
|
| Übersprungen bei | Grund |
|
||||||
|
|------------------|-------|
|
||||||
|
| `[upload.nextcloud].enabled = true` | Testdatei landete in der Nextcloud |
|
||||||
|
| `[upload.sftp].enabled = true` | Testdatei landete auf dem SFTP-Ziel |
|
||||||
|
| `[upload.folder]` mit gesetztem `target` | Zielordner liegt außerhalb von `outgoing/`, oft eine Kundenfreigabe |
|
||||||
|
| `[notify.email].enabled = true` | löst eine Benachrichtigungs-Mail aus |
|
||||||
|
|
||||||
|
`[upload.folder]` **ohne** `target` schreibt nach `outgoing/` und ist damit
|
||||||
|
harmlos — dort läuft der Test normal.
|
||||||
|
|
||||||
|
Für diese Instanzen bleibt der manuelle Weg: eine eigene PDF in `incoming/`
|
||||||
|
legen und `journalctl -u pdf-ocr-hotfolder@<instanz> -f` mitlesen.
|
||||||
|
|
||||||
|
### Wenn der Rauchtest fehlschlägt
|
||||||
|
|
||||||
|
Der Rauchtest setzt den **Exit-Code** des Updates auf 1 und nennt den
|
||||||
|
Journal-Befehl:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] RAUCHTEST FEHLGESCHLAGEN: kunde1
|
||||||
|
[ERROR] Diese Instanzen laufen, verarbeiten aber keine PDFs.
|
||||||
|
[ERROR] Es wurde NICHT zurueckgerollt. Journal ansehen:
|
||||||
|
[ERROR] journalctl -u pdf-ocr-hotfolder@kunde1.service -n 80 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
**Es wird nichts automatisch zurückgerollt.** Der Code ist getauscht, die
|
||||||
|
Instanzen laufen. Rollback nur von Hand und nur bewusst — siehe
|
||||||
|
[Rollback](#rollback).
|
||||||
|
|
||||||
|
Abschalten: `sudo ./update.sh --no-smoke-test`. Dann fällt ein Totalausfall
|
||||||
|
erst der ersten echten Kundendatei auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
cd /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, PDF/A-Level, `skip_text` sowie die
|
||||||
|
installierte ocrmypdf- und Ghostscript-Version, fährt den Preflight
|
||||||
|
(`tesseract`, `gs`, und die Ghostscript-Version gegen die tatsächliche
|
||||||
|
ocrmypdf-Bedingung — siehe [Rauchtest](#rauchtest) und
|
||||||
|
[INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12)) 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 |
|
||||||
|
|
||||||
|
`--check-config` selbst kennt nur 0/1/2. Der **Dienst** kennt seit 0.7.0
|
||||||
|
zusätzlich **Exit 3** (Verzeichnis-Watch gestorben, Neustart erwünscht) — und
|
||||||
|
die Unit startet bei **Exit 2** absichtlich **nicht** mehr neu
|
||||||
|
(`RestartPreventExitStatus=2`), die Instanz bleibt sichtbar `failed` stehen.
|
||||||
|
Für das Update heißt das: eine Instanz mit Config-Fehler verschwindet nicht
|
||||||
|
mehr in einem stillen 5-Sekunden-Crash-Loop, sondern fällt in der
|
||||||
|
Zusammenfassung auf. Die vollständige Tabelle:
|
||||||
|
[INSTALLATION.md](INSTALLATION.md#exit-codes).
|
||||||
|
|
||||||
|
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` von ocrmypdf abgelehnt wird. Der Preflight bricht in
|
||||||
|
dem Fall mit Exit 2 ab; ab Ghostscript 10.02.1 ist PDF/A unproblematisch.
|
||||||
|
|
||||||
|
> **`pdfa_level = ""` allein ist kein Schutz gegen den Ghostscript-Bug.** Das
|
||||||
|
> gilt erst zusammen mit **ocrmypdf ≥ 17**. Bis ocrmypdf 16.x läuft dieselbe
|
||||||
|
> Prüfung auch ohne PDF/A, und dann scheitert mit `skip_text = true` jede
|
||||||
|
> einzelne Datei. Die Entwarnung hängt also an der ocrmypdf-Version.
|
||||||
|
> `requirements.txt` pinnt darum 17.x, und der Preflight prüft beides zusammen.
|
||||||
|
> Hintergrund: [INSTALLATION.md](INSTALLATION.md#ghostscript-bug-auf-debian-12).
|
||||||
|
|
||||||
|
### Relative Pfade — Fehler seit 0.7.0
|
||||||
|
|
||||||
|
Bis 0.6.3 wurde ein relativer Pfad in `[paths]`, `[output].archive_dir` oder
|
||||||
|
`[upload.folder].target` klaglos angenommen und gegen das `WorkingDirectory`
|
||||||
|
der Unit aufgelöst, also unter `/opt/pdf-ocr-hotfolder/`. Seit 0.7.0 ist das
|
||||||
|
ein **Config-Fehler mit Exit 2**: `--check-config` meldet ihn beim Update, und
|
||||||
|
der Dienst startet nicht.
|
||||||
|
|
||||||
|
Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende"
|
||||||
|
Instanz stoppen kann. Er ist gewollt — eine solche Instanz schrieb an einer
|
||||||
|
Stelle, an der niemand sie gesucht hat. Abhilfe: den Pfad absolut eintragen
|
||||||
|
und, falls dort Dateien liegen, den Inhalt von `/opt/pdf-ocr-hotfolder/<pfad>`
|
||||||
|
vorher herüberholen.
|
||||||
|
|
||||||
|
### 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.
|
||||||
+263
-33
@@ -12,27 +12,27 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
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 ./install.sh"
|
|
||||||
exit 1
|
|
||||||
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"
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$SCRIPT_DIR"
|
REPO_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Gemeinsame Funktionen (Logging, Pfade, venv-Pruefung, Paketliste)
|
||||||
|
# ============================================================
|
||||||
|
# Relativ zum Skript aufgeloest, nicht zum Arbeitsverzeichnis — install.sh
|
||||||
|
# wird auch mit absolutem Pfad aufgerufen. Fehlt die Datei, ist hier Schluss;
|
||||||
|
# ein "command not found" mitten im Lauf waere die schlechtere Nachricht.
|
||||||
|
COMMON_LIB="$SCRIPT_DIR/lib/common.sh"
|
||||||
|
if [ ! -r "$COMMON_LIB" ]; then
|
||||||
|
echo "[ERROR] Gemeinsame Funktionsbibliothek nicht gefunden: $COMMON_LIB" >&2
|
||||||
|
echo " install.sh braucht lib/common.sh aus demselben Repo." >&2
|
||||||
|
echo " Repo vollstaendig auschecken und erneut ausfuehren." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck source=lib/common.sh
|
||||||
|
. "$COMMON_LIB"
|
||||||
|
|
||||||
|
require_root "sudo ./install.sh"
|
||||||
|
|
||||||
if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
|
||||||
log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen."
|
log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -44,14 +44,61 @@ fi
|
|||||||
|
|
||||||
install_base() {
|
install_base() {
|
||||||
log_step "System-Pakete installieren"
|
log_step "System-Pakete installieren"
|
||||||
|
local -a PKGS
|
||||||
|
mapfile -t PKGS < <(pdf_ocr_apt_packages)
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends "${PKGS[@]}"
|
||||||
python3 python3-venv python3-pip \
|
|
||||||
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
|
|
||||||
ghostscript qpdf unpaper pngquant \
|
|
||||||
icc-profiles-free ca-certificates curl
|
|
||||||
log_info "System-Pakete ok ✓"
|
log_info "System-Pakete ok ✓"
|
||||||
|
|
||||||
|
# Ghostscript-Versions-Check (Issue #3 + Issue #6)
|
||||||
|
# Die eigentliche Logik steckt in lib/common.sh (check_ghostscript): sie
|
||||||
|
# prueft nur, was wirklich verfuegbar ist, meldet Erfolg ausschliesslich
|
||||||
|
# nach einem Versionsvergleich und laesst keine nutzlose apt-Quelle
|
||||||
|
# zurueck. Ein betroffenes Ghostscript ist kein Abbruchgrund.
|
||||||
|
check_ghostscript
|
||||||
|
|
||||||
|
# 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
|
||||||
|
mkdir -p "$LXC_DROPIN_DIR"
|
||||||
|
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN"
|
||||||
|
systemctl daemon-reload
|
||||||
|
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Dienst loggt ausschliesslich nach journald. Ist journald kaputt,
|
||||||
|
# gibt es gar keine Logs — das faellt sonst erst bei der ersten
|
||||||
|
# Fehlersuche auf. Bekannter Fall: Debian 13 (systemd >= 255) in einer
|
||||||
|
# LXC auf Proxmox 8.4 — das AppArmor-Profil des Hosts blockiert den
|
||||||
|
# Credential-Mount von (sd-mkdcreds), journald scheitert mit
|
||||||
|
# 243/CREDENTIALS. Debian 12 (systemd 252) ist nicht betroffen.
|
||||||
|
if ! systemctl is-active --quiet systemd-journald 2>/dev/null; then
|
||||||
|
log_warn "ACHTUNG: systemd-journald laeuft nicht."
|
||||||
|
log_warn "Der Dienst loggt NUR nach journald — es gaebe hier keine Logs."
|
||||||
|
log_warn "Pruefen: systemctl status systemd-journald"
|
||||||
|
log_warn "Scheitert es mit 243/CREDENTIALS (Debian 13 in LXC auf"
|
||||||
|
log_warn "Proxmox), hilft ein Drop-in im Container:"
|
||||||
|
# Jede Zeile ein eigener log_warn und ohne '\n'-Escapes, damit der
|
||||||
|
# Block zeilenweise mit [WARN]-Praefix herauskommt und sich sauber
|
||||||
|
# kopieren laesst (das Schreiben der Datei bewusst als Einzeiler).
|
||||||
|
log_warn " mkdir -p /etc/systemd/system/systemd-journald.service.d"
|
||||||
|
log_warn " { echo '[Service]'; echo 'ImportCredential='; } > /etc/systemd/system/systemd-journald.service.d/no-credentials.conf"
|
||||||
|
log_warn " systemctl daemon-reload && systemctl restart systemd-journald"
|
||||||
|
log_warn "Details: docs/INSTALLATION.md, Abschnitt Troubleshooting."
|
||||||
|
read -r -p "Trotzdem fortfahren? [J/n]: " JOURNAL_GO
|
||||||
|
JOURNAL_GO="${JOURNAL_GO:-J}"
|
||||||
|
if [[ ! "$JOURNAL_GO" =~ ^[JjYy]$ ]]; then
|
||||||
|
log_error "Abbruch. Erst journald reparieren, dann erneut starten."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log_step "Default-User '$DEFAULT_USER' prüfen"
|
log_step "Default-User '$DEFAULT_USER' prüfen"
|
||||||
if id "$DEFAULT_USER" &>/dev/null; then
|
if id "$DEFAULT_USER" &>/dev/null; then
|
||||||
log_info "'$DEFAULT_USER' existiert bereits"
|
log_info "'$DEFAULT_USER' existiert bereits"
|
||||||
@@ -61,32 +108,51 @@ install_base() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log_step "Verzeichnisse anlegen"
|
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"
|
chown root:"$DEFAULT_USER" "$CONFIG_DIR"
|
||||||
chmod 750 "$CONFIG_DIR"
|
chmod 750 "$CONFIG_DIR"
|
||||||
|
|
||||||
log_step "Code kopieren"
|
log_step "Code kopieren"
|
||||||
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
|
||||||
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
|
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
|
||||||
|
# lib/ muss mit: update.sh sucht die gemeinsamen Funktionen zuerst neben
|
||||||
|
# sich und danach in der Installation.
|
||||||
|
rm -rf "${INSTALL_DIR:?}/lib"
|
||||||
|
cp -r "$REPO_DIR/lib" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
|
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
|
||||||
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
|
||||||
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
|
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
|
||||||
|
|
||||||
log_step "Python venv"
|
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?)."
|
||||||
|
report_venv_issues
|
||||||
|
log_warn "Sie wird gesichert nach: $VENV_SAVED"
|
||||||
|
mv "$INSTALL_DIR/venv" "$VENV_SAVED"
|
||||||
|
fi
|
||||||
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||||
python3 -m venv "$INSTALL_DIR/venv"
|
python3 -m venv "$INSTALL_DIR/venv"
|
||||||
fi
|
fi
|
||||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
|
"$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_info "venv ok ✓"
|
||||||
|
|
||||||
log_step "systemd Template-Unit installieren"
|
log_step "systemd Template-Unit installieren"
|
||||||
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
|
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/$SERVICE_TEMPLATE"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
log_info "Template-Unit installiert ✓"
|
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 +182,66 @@ show_existing_instances() {
|
|||||||
echo
|
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() {
|
create_instance() {
|
||||||
echo
|
echo
|
||||||
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
|
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
|
||||||
@@ -153,23 +279,114 @@ create_instance() {
|
|||||||
fi
|
fi
|
||||||
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..."
|
log_info "Lege Datenverzeichnisse unter $BASE an..."
|
||||||
mkdir -p "$BASE"/{incoming,outgoing,working,error}
|
mkdir -p "$BASE"/{incoming,outgoing,working,error}
|
||||||
|
if [ -n "$ARCHIVE_DIR" ]; then
|
||||||
|
mkdir -p "$ARCHIVE_DIR"
|
||||||
|
fi
|
||||||
chown -R "$SVC_USER":"$SVC_GROUP" "$BASE"
|
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..."
|
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 \
|
sed \
|
||||||
-e "s|/var/lib/pdf-ocr-hotfolder/incoming|$BASE/incoming|" \
|
-e "s|^incoming[[:space:]]*=.*|incoming = \"$ESC_BASE/incoming\"|" \
|
||||||
-e "s|/var/lib/pdf-ocr-hotfolder/outgoing|$BASE/outgoing|" \
|
-e "s|^outgoing[[:space:]]*=.*|outgoing = \"$ESC_BASE/outgoing\"|" \
|
||||||
-e "s|/var/lib/pdf-ocr-hotfolder/working|$BASE/working|" \
|
-e "s|^working[[:space:]]*=.*|working = \"$ESC_BASE/working\"|" \
|
||||||
-e "s|/var/lib/pdf-ocr-hotfolder/error|$BASE/error|" \
|
-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"
|
"$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml"
|
||||||
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
|
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
|
||||||
chmod 640 "$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
|
# Drop-in für abweichenden Service-User
|
||||||
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
|
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
|
||||||
local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d"
|
local DROPIN_DIR="$SYSTEMD_DIR/pdf-ocr-hotfolder@${INST}.service.d"
|
||||||
mkdir -p "$DROPIN_DIR"
|
mkdir -p "$DROPIN_DIR"
|
||||||
cat > "$DROPIN_DIR/user.conf" <<EOF
|
cat > "$DROPIN_DIR/user.conf" <<EOF
|
||||||
[Service]
|
[Service]
|
||||||
@@ -193,6 +410,12 @@ EOF
|
|||||||
echo " Eingang: $BASE/incoming"
|
echo " Eingang: $BASE/incoming"
|
||||||
echo " Ausgang: $BASE/outgoing"
|
echo " Ausgang: $BASE/outgoing"
|
||||||
echo " User: $SVC_USER ($SVC_GROUP)"
|
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
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +428,16 @@ echo "=========================================="
|
|||||||
echo " PDF OCR Hotfolder — Installer"
|
echo " PDF OCR Hotfolder — Installer"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
|
# 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 "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ]; then
|
||||||
log_step "Basis-Installation"
|
log_step "Basis-Installation"
|
||||||
install_base
|
install_base
|
||||||
|
elif ! venv_is_healthy "$INSTALL_DIR/venv"; then
|
||||||
|
log_warn "Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python."
|
||||||
|
report_venv_issues
|
||||||
|
log_step "Basis-Installation wird zur Reparatur erneut ausgefuehrt"
|
||||||
|
install_base
|
||||||
else
|
else
|
||||||
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
|
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
|
||||||
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
|
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
|
||||||
|
|||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# PDF OCR Hotfolder — gemeinsame Shell-Funktionen
|
||||||
|
#
|
||||||
|
# Wird von install.sh und update.sh gesourct:
|
||||||
|
# source "$SCRIPT_DIR/lib/common.sh"
|
||||||
|
#
|
||||||
|
# Diese Datei fuehrt beim Sourcen NICHTS aus, was das System anfasst — sie
|
||||||
|
# enthaelt nur Definitionen und Vorgabewerte. Alle Pfade sind ueber die
|
||||||
|
# Umgebung ueberschreibbar, damit sich die Funktionen gegen eine Fake-Umgebung
|
||||||
|
# testen lassen (siehe PDF_OCR_UPDATE_LIB_ONLY in update.sh).
|
||||||
|
#
|
||||||
|
# Die Datei wird bei Installation und Update nach $INSTALL_DIR/lib/ kopiert,
|
||||||
|
# damit auch ein Lauf ausserhalb des Repos sie findet.
|
||||||
|
#
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
# Mehrfaches Sourcen ist harmlos, aber unnoetig.
|
||||||
|
if [ -n "${PDF_OCR_COMMON_LOADED:-}" ]; then
|
||||||
|
# shellcheck disable=SC2317 # 'exit' greift nur, wenn die Datei nicht gesourct wurde
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
|
fi
|
||||||
|
PDF_OCR_COMMON_LOADED=1
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Ausgabe
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
|
||||||
|
# Die Meldung selbst geht als %s durch, NICHT als %b (frueher: echo -e). Sonst
|
||||||
|
# wird ein '\n' im Text — etwa in einem Befehl, den wir zum Kopieren anzeigen —
|
||||||
|
# zu einem echten Zeilenumbruch: die Folgezeilen haetten dann kein [WARN] davor
|
||||||
|
# und wer den Block kopiert, schleppt das Praefix mit. Nur die Farbcodes
|
||||||
|
# brauchen %b. Eine Meldung pro Zeile — mehrzeilige Hinweise bitte als mehrere
|
||||||
|
# Aufrufe schreiben.
|
||||||
|
log_info() { printf '%b[INFO]%b %s\n' "$GREEN" "$NC" "$*"; }
|
||||||
|
log_warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$NC" "$*"; }
|
||||||
|
log_error() { printf '%b[ERROR]%b %s\n' "$RED" "$NC" "$*"; }
|
||||||
|
log_step() { printf '\n%b==>%b %s\n' "$BLUE" "$NC" "$*"; }
|
||||||
|
|
||||||
|
# Bricht ab, wenn nicht root. $1 = der Aufruf, der gemeint ist.
|
||||||
|
require_root() {
|
||||||
|
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||||
|
log_error "Bitte als root ausfuehren: ${1:-sudo $0}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Installations-Layout — einzige Quelle der Wahrheit
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
: "${INSTALL_DIR:=/opt/pdf-ocr-hotfolder}"
|
||||||
|
: "${CONFIG_DIR:=/etc/pdf-ocr-hotfolder}"
|
||||||
|
: "${DATA_ROOT:=/var/lib/pdf-ocr-hotfolder}"
|
||||||
|
: "${SYSTEMD_DIR:=/etc/systemd/system}"
|
||||||
|
: "${DEFAULT_USER:=pdfocr}"
|
||||||
|
|
||||||
|
# Systempfade, die nur die Ghostscript-Pruefung braucht. Ueberschreibbar,
|
||||||
|
# damit sich die Pruefung gegen eine Fake-Umgebung testen laesst.
|
||||||
|
: "${OS_RELEASE_FILE:=/etc/os-release}"
|
||||||
|
: "${APT_SOURCES_LIST:=/etc/apt/sources.list}"
|
||||||
|
: "${APT_SOURCES_DIR:=/etc/apt/sources.list.d}"
|
||||||
|
|
||||||
|
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
|
||||||
|
LXC_DROPIN_DIR="$SYSTEMD_DIR/${SERVICE_TEMPLATE}.d"
|
||||||
|
# shellcheck disable=SC2034 # wird von install.sh und update.sh genutzt
|
||||||
|
LXC_DROPIN="$LXC_DROPIN_DIR/lxc-compat.conf"
|
||||||
|
|
||||||
|
# Pfad dieser Datei relativ zum Repo- bzw. Installationsverzeichnis. update.sh
|
||||||
|
# schneidet damit die Paketliste aus der Repo-Fassung heraus (siehe unten).
|
||||||
|
# shellcheck disable=SC2034 # wird von update.sh genutzt
|
||||||
|
COMMON_LIB_REL="lib/common.sh"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# System-Pakete
|
||||||
|
# ============================================================
|
||||||
|
# Der folgende Block wird von update.sh aus DIESER Datei herausgeschnitten
|
||||||
|
# (sed auf die BEGIN/END-Marken) und dort ausgewertet: beim Update soll die
|
||||||
|
# Liste aus dem Repo gelten, nicht die vielleicht aeltere, bereits gesourcte
|
||||||
|
# aus dem Installationsverzeichnis. 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 ---
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Ghostscript
|
||||||
|
# ============================================================
|
||||||
|
# Ghostscript 10.0.0 bis 10.02.0 erzeugt fehlerhafte PDF/A-Ausgaben: zusammen
|
||||||
|
# mit [ocr].pdfa_level UND skip_text = true scheitert ocrmypdf an jeder Datei.
|
||||||
|
# Mit leerem pdfa_level (der Default) ist so ein Ghostscript voellig
|
||||||
|
# unproblematisch — die Pruefung ist deshalb ein Hinweis, kein Abbruchgrund.
|
||||||
|
|
||||||
|
# Betroffen? $1 = Ausgabe von 'gs --version'.
|
||||||
|
gs_version_affected() {
|
||||||
|
case "$1" in
|
||||||
|
10.0.0|10.00.0|10.01.*|10.02.0) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Version, die gs meldet; leer, wenn gs nicht aufrufbar ist.
|
||||||
|
gs_version() {
|
||||||
|
command -v gs >/dev/null 2>&1 || return 0
|
||||||
|
gs --version 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Codename der Distribution (VERSION_CODENAME aus os-release); leer, wenn
|
||||||
|
# unbekannt.
|
||||||
|
os_codename() {
|
||||||
|
[ -r "$OS_RELEASE_FILE" ] || return 0
|
||||||
|
sed -n 's/^VERSION_CODENAME=//p' "$OS_RELEASE_FILE" | tr -d '"' | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ist $1 (z.B. bookworm-backports) schon irgendwo als apt-Quelle eingetragen?
|
||||||
|
apt_release_configured() {
|
||||||
|
grep -RqsF -- "$1" "$APT_SOURCES_LIST" "$APT_SOURCES_DIR" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installierte Paketversion laut dpkg; leer, wenn das Paket nicht da ist.
|
||||||
|
apt_installed_version() {
|
||||||
|
command -v dpkg-query >/dev/null 2>&1 || return 0
|
||||||
|
dpkg-query -W -f '${Version}' "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hoechste Version von Paket $1, die aus Release $2 (z.B. bookworm-backports)
|
||||||
|
# zu haben ist — gelesen aus der Versionstabelle von 'apt-cache policy'.
|
||||||
|
# Leer, wenn das Release das Paket nicht fuehrt. Bewusst nicht hart verdrahtet:
|
||||||
|
# liefert Debian spaeter doch ein Backports-Ghostscript, greift der Pfad.
|
||||||
|
apt_version_in_release() {
|
||||||
|
local pkg="$1" release="$2"
|
||||||
|
command -v apt-cache >/dev/null 2>&1 || return 0
|
||||||
|
LC_ALL=C apt-cache policy "$pkg" 2>/dev/null | awk -v rel="$release" '
|
||||||
|
$1 == "***" { ver = $2; next }
|
||||||
|
NF == 2 && $1 ~ /^[0-9]/ { ver = $1; next }
|
||||||
|
ver != "" && index($0, rel) { print ver; exit }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Entfernt eine Quelle, die NUR fuer die Pruefung angelegt wurde ($2 = 1).
|
||||||
|
# War sie vorher schon da, bleibt sie unangetastet — sie kann von etwas
|
||||||
|
# anderem stammen.
|
||||||
|
gs_drop_probe_source() {
|
||||||
|
local list_file="$1" added="$2"
|
||||||
|
[ "$added" = "1" ] || return 0
|
||||||
|
rm -f "$list_file" 2>/dev/null || true
|
||||||
|
log_warn "Quelle wieder entfernt: $list_file (sie hat nichts gebracht)"
|
||||||
|
apt-get update -qq || log_warn "'apt-get update' nach dem Aufraeumen schlug fehl."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Versucht, Ghostscript aus Release $1 zu aktualisieren. $2 = bisherige
|
||||||
|
# gs-Version. Rueckgabe 0 NUR, wenn hinterher nachweislich eine andere,
|
||||||
|
# nicht mehr betroffene Version laeuft.
|
||||||
|
gs_try_release_upgrade() {
|
||||||
|
local release="$1" old_ver="$2"
|
||||||
|
local list_file="$APT_SOURCES_DIR/${release}.list"
|
||||||
|
local added=0 cand installed new_ver new_installed
|
||||||
|
|
||||||
|
# Ohne apt-cache/dpkg laesst sich nicht feststellen, was dort ueberhaupt
|
||||||
|
# liegt — dann wird auch nichts eingetragen.
|
||||||
|
if ! command -v apt-cache >/dev/null 2>&1 || ! command -v dpkg >/dev/null 2>&1; then
|
||||||
|
log_warn "apt-cache oder dpkg fehlt — der Paketstand von $release ist nicht pruefbar."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if apt_release_configured "$release"; then
|
||||||
|
log_info "$release ist bereits eingetragen — die Quelle bleibt, wie sie ist."
|
||||||
|
else
|
||||||
|
log_info "Trage $release voruebergehend ein, um den Paketstand zu pruefen..."
|
||||||
|
mkdir -p "$APT_SOURCES_DIR" 2>/dev/null || true
|
||||||
|
if ! printf 'deb http://deb.debian.org/debian %s main\n' "$release" > "$list_file" 2>/dev/null; then
|
||||||
|
log_warn "$list_file liess sich nicht schreiben — Pruefung nicht moeglich."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
added=1
|
||||||
|
if ! apt-get update -qq; then
|
||||||
|
log_warn "'apt-get update' schlug fehl — $release nicht auswertbar."
|
||||||
|
gs_drop_probe_source "$list_file" "$added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
installed="$(apt_installed_version ghostscript)"
|
||||||
|
cand="$(apt_version_in_release ghostscript "$release")"
|
||||||
|
|
||||||
|
if [ -z "$cand" ]; then
|
||||||
|
log_warn "$release fuehrt gar kein Paket 'ghostscript' — von dort kommt nichts."
|
||||||
|
gs_drop_probe_source "$list_file" "$added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$installed" ] && ! dpkg --compare-versions "$cand" gt "$installed"; then
|
||||||
|
log_warn "$release hat ghostscript $cand — nicht neuer als das installierte $installed."
|
||||||
|
gs_drop_probe_source "$list_file" "$added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "$release bietet ghostscript $cand (installiert: ${installed:-unbekannt}) — installiere..."
|
||||||
|
if ! apt-get install -y -t "$release" ghostscript; then
|
||||||
|
log_warn "Installation aus $release schlug fehl — Ghostscript bleibt, wie es war."
|
||||||
|
gs_drop_probe_source "$list_file" "$added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Erfolg wird NUR gemeldet, wenn sich die Version wirklich geaendert hat.
|
||||||
|
new_ver="$(gs_version)"
|
||||||
|
new_installed="$(apt_installed_version ghostscript)"
|
||||||
|
if [ "$new_ver" = "$old_ver" ] && [ "$new_installed" = "$installed" ]; then
|
||||||
|
log_warn "Ghostscript unveraendert: $old_ver (Paket ${installed:-unbekannt})."
|
||||||
|
log_warn "Das Upgrade aus $release hat nichts bewirkt."
|
||||||
|
gs_drop_probe_source "$list_file" "$added"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Ghostscript: $old_ver -> $new_ver (Paket ${installed:-unbekannt} -> ${new_installed:-unbekannt})"
|
||||||
|
if gs_version_affected "$new_ver"; then
|
||||||
|
log_warn "Auch $new_ver liegt noch im betroffenen Bereich (10.0.0-10.02.0)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_info "Ghostscript ist jetzt frei vom PDF/A-Fehler ✓"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sagt dem Admin, was wirklich zur Auswahl steht. Kein Verweis auf Wege, die
|
||||||
|
# es nicht gibt.
|
||||||
|
gs_report_options() {
|
||||||
|
log_warn "Es bleibt bei der betroffenen Ghostscript-Version. Echte Optionen:"
|
||||||
|
log_warn " 1. Nichts tun: [ocr].pdfa_level leer lassen (Default). Dann ist der"
|
||||||
|
log_warn " Fehler folgenlos — die Ausgabe ist nur kein PDF/A."
|
||||||
|
log_warn " 2. PDF/A wirklich noetig? Dann [ocr].skip_text = false setzen."
|
||||||
|
log_warn " 3. Neuere Distribution: Debian 13 (trixie) liefert Ghostscript 10.05.1."
|
||||||
|
log_warn "Die Installation laeuft normal weiter."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vollstaendige Pruefung fuer den Installer. Gibt immer 0 zurueck: ein
|
||||||
|
# betroffenes Ghostscript ist ein Hinweis, kein Abbruch.
|
||||||
|
check_ghostscript() {
|
||||||
|
local ver codename release answer
|
||||||
|
|
||||||
|
ver="$(gs_version)"
|
||||||
|
if [ -z "$ver" ]; then
|
||||||
|
log_warn "Ghostscript ist nicht aufrufbar — Versionspruefung uebersprungen."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log_info "Ghostscript: $ver"
|
||||||
|
gs_version_affected "$ver" || return 0
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_warn "═══════════════════════════════════════════════════════════════"
|
||||||
|
log_warn "Ghostscript $ver ist vom PDF/A-Fehler betroffen (10.0.0-10.02.0)."
|
||||||
|
log_warn "Betroffen sind nur Instanzen mit [ocr].pdfa_level UND skip_text = true;"
|
||||||
|
log_warn "mit leerem pdfa_level (Default) ist die Version unauffaellig."
|
||||||
|
log_warn "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo
|
||||||
|
|
||||||
|
codename="$(os_codename)"
|
||||||
|
if [ "$codename" = "bookworm" ]; then
|
||||||
|
release="bookworm-backports"
|
||||||
|
# Bewusst als Frage nach dem PRUEFEN formuliert: ob dort ueberhaupt ein
|
||||||
|
# neueres Ghostscript liegt, steht erst nach 'apt-cache policy' fest.
|
||||||
|
read -r -p "In $release nach einem neueren Ghostscript suchen? [J/n]: " answer || answer=""
|
||||||
|
answer="${answer:-J}"
|
||||||
|
if [[ "$answer" =~ ^[JjYy]$ ]]; then
|
||||||
|
if gs_try_release_upgrade "$release" "$ver"; then
|
||||||
|
echo
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_info "Uebersprungen."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn "Kein Debian 12 (bookworm) erkannt${codename:+ (Codename: $codename)} — es gibt hier keinen Backports-Weg."
|
||||||
|
fi
|
||||||
|
|
||||||
|
gs_report_options
|
||||||
|
echo
|
||||||
|
return 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.
|
||||||
|
#
|
||||||
|
# Drei Faelle fuehren zum Neubau: (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), (3) pyvenv.cfg und Interpreter widersprechen sich.
|
||||||
|
# Keine Versionsnummer ist hier hartcodiert.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gibt die von venv_is_healthy gesammelten Befunde als Warnungen aus.
|
||||||
|
report_venv_issues() {
|
||||||
|
local issue
|
||||||
|
for issue in "${VENV_ISSUES[@]:-}"; do
|
||||||
|
[ -n "$issue" ] || continue
|
||||||
|
log_warn " - $issue"
|
||||||
|
done
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.7.2"
|
||||||
|
|||||||
@@ -4,21 +4,143 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .config import load_config
|
from .config import Config, ConfigError, config_warnings, load_config
|
||||||
from .service import HotfolderService, PreflightError
|
from .service import (
|
||||||
|
HotfolderService,
|
||||||
|
PreflightError,
|
||||||
|
check_output_config,
|
||||||
|
check_preflight,
|
||||||
|
detect_ghostscript_version,
|
||||||
|
detect_ocrmypdf_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
def _setup_logging(level: str) -> None:
|
||||||
|
# stream explizit auf stdout: der Default von basicConfig() ist stderr,
|
||||||
|
# README und docs/INSTALLATION.md versprechen aber stdout. Für journald
|
||||||
|
# ist das egal, für den dort beschriebenen Vordergrund-Notbehelf und für
|
||||||
|
# jeden, der die Ausgabe weiterleitet, nicht.
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, level.upper(), logging.INFO),
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
stream=sys.stdout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _toml_error_text(cfg_path: Path, exc: tomllib.TOMLDecodeError) -> str:
|
||||||
|
"""Formuliert die Meldung für kaputtes TOML — mit Zeile/Spalte, wenn möglich.
|
||||||
|
|
||||||
|
`TOMLDecodeError.lineno`/`.colno` gibt es erst ab Python 3.14. Auf Debian
|
||||||
|
12 (Python 3.11) fehlen die Attribute, dort steht die Position nur im
|
||||||
|
Meldungstext ("... (at line 3, column 12)") — deshalb `getattr` statt
|
||||||
|
direktem Zugriff.
|
||||||
|
"""
|
||||||
|
lineno = getattr(exc, "lineno", None)
|
||||||
|
colno = getattr(exc, "colno", None)
|
||||||
|
pos = f" (Zeile {lineno}, Spalte {colno})" if lineno is not None else ""
|
||||||
|
return f"{cfg_path} ist kein gültiges TOML{pos}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
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: {_toml_error_text(cfg_path, 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)'}")
|
||||||
|
print(f" skip_text = {'an' if cfg.ocr.skip_text else 'aus'}")
|
||||||
|
print(f" ocrmypdf = {detect_ocrmypdf_version() or '(nicht installiert)'}")
|
||||||
|
print(f" Ghostscript = {detect_ghostscript_version() or '(nicht gefunden)'}")
|
||||||
|
|
||||||
|
if cfg.verapdf.enabled:
|
||||||
|
print(f" veraPDF = {cfg.verapdf.binary} (Flavour "
|
||||||
|
f"{cfg.verapdf.flavour})")
|
||||||
|
else:
|
||||||
|
print(" veraPDF = (aus)")
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
try:
|
||||||
|
check_preflight(cfg.ocr.pdfa_level, cfg.ocr.skip_text,
|
||||||
|
cfg.verapdf.enabled, cfg.verapdf.binary)
|
||||||
|
print(" Preflight ok (tesseract, gs"
|
||||||
|
+ (", veraPDF" if cfg.verapdf.enabled else "")
|
||||||
|
+ " vorhanden, Ghostscript-Version "
|
||||||
|
"passt zu ocrmypdf + [ocr]-Einstellungen).")
|
||||||
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="pdf-ocr-hotfolder",
|
prog="pdf-ocr-hotfolder",
|
||||||
@@ -29,6 +151,9 @@ def main() -> int:
|
|||||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||||
parser.add_argument("--once", action="store_true",
|
parser.add_argument("--once", action="store_true",
|
||||||
help="Nur bestehende Dateien verarbeiten und beenden")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cfg_path = Path(args.config)
|
cfg_path = Path(args.config)
|
||||||
@@ -36,8 +161,29 @@ def main() -> int:
|
|||||||
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
|
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
|
||||||
return 2
|
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)
|
cfg = load_config(cfg_path)
|
||||||
|
except ConfigError as e:
|
||||||
|
print(f"FEHLER: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except tomllib.TOMLDecodeError as e:
|
||||||
|
# Ohne diesen Zweig endet ein Tippfehler in der Config (unbalancierte
|
||||||
|
# Anführungszeichen o.ä.) beim Dienststart in einem nackten Traceback.
|
||||||
|
# Dieselbe Behandlung wie in --check-config: verständliche Meldung,
|
||||||
|
# Exit 2 = Config-Fehler.
|
||||||
|
print(f"FEHLER: {_toml_error_text(cfg_path, e)}", file=sys.stderr)
|
||||||
|
print("Der Dienst startet nicht. Config korrigieren und mit "
|
||||||
|
"--check-config gegenprüfen.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except OSError as e:
|
||||||
|
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
_setup_logging(cfg.log_level)
|
_setup_logging(cfg.log_level)
|
||||||
|
_log_config_warnings(cfg)
|
||||||
|
|
||||||
service = HotfolderService(cfg)
|
service = HotfolderService(cfg)
|
||||||
|
|
||||||
@@ -50,12 +196,14 @@ def main() -> int:
|
|||||||
return 1 if errors > 0 else 0
|
return 1 if errors > 0 else 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service.run()
|
# run() liefert 0 bei regulärem Stopp und EXIT_OBSERVER_DEAD, wenn der
|
||||||
|
# Verzeichnis-Watch gestorben ist — Letzteres muss nach außen
|
||||||
|
# durchschlagen, sonst startet systemd den Dienst nicht neu.
|
||||||
|
return service.run()
|
||||||
except PreflightError as e:
|
except PreflightError as e:
|
||||||
print(f"FEHLER: {e}", file=sys.stderr)
|
print(f"FEHLER: {e}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+195
-7
@@ -7,6 +7,10 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""Konfigurationsdatei ist unvollständig oder fehlerhaft."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Paths:
|
class Paths:
|
||||||
incoming: Path
|
incoming: Path
|
||||||
@@ -21,11 +25,34 @@ class OcrConfig:
|
|||||||
jobs: int = 4
|
jobs: int = 4
|
||||||
skip_text: bool = True
|
skip_text: bool = True
|
||||||
oversample: int = 300
|
oversample: int = 300
|
||||||
pdfa_level: str = "2"
|
# Default bewusst leer: mit Ghostscript 10.0.0-10.02.0 (Debian-12-Default)
|
||||||
|
# lehnt ocrmypdf die Kombination pdfa_level + skip_text ab (Issue #3).
|
||||||
|
# ACHTUNG, kein Freibrief: pdfa_level = "" allein schützt nur zusammen mit
|
||||||
|
# ocrmypdf >= 17. Bis 16.x läuft dieselbe Prüfung auch ohne PDF/A und
|
||||||
|
# blockiert dann JEDE Datei — deshalb pinnt requirements.txt 17.x und der
|
||||||
|
# Preflight prüft die installierte ocrmypdf-Version mit (siehe
|
||||||
|
# service._gs_block_reason).
|
||||||
|
# Auf Debian 12 gibt es KEIN neueres Ghostscript (bookworm-backports führt
|
||||||
|
# kein Ghostscript-Paket) — PDF/A ist dort mit skip_text schlicht nicht zu
|
||||||
|
# haben.
|
||||||
|
pdfa_level: str = ""
|
||||||
deskew: bool = True
|
deskew: bool = True
|
||||||
clean: bool = False
|
clean: bool = False
|
||||||
max_workers: int = 2
|
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
|
@dataclass
|
||||||
@@ -79,12 +106,25 @@ class EmailNotify:
|
|||||||
class Config:
|
class Config:
|
||||||
paths: Paths
|
paths: Paths
|
||||||
ocr: OcrConfig
|
ocr: OcrConfig
|
||||||
|
output: OutputConfig
|
||||||
verapdf: VeraPdfConfig
|
verapdf: VeraPdfConfig
|
||||||
folder: FolderUpload
|
folder: FolderUpload
|
||||||
nextcloud: NextcloudUpload
|
nextcloud: NextcloudUpload
|
||||||
sftp: SftpUpload
|
sftp: SftpUpload
|
||||||
email: EmailNotify
|
email: EmailNotify
|
||||||
log_level: str = "INFO"
|
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]:
|
def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||||
@@ -94,21 +134,108 @@ def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
|||||||
return cur if isinstance(cur, dict) else {}
|
return cur if isinstance(cur, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_absolute(value: str, label: str, cfg_path: Path,
|
||||||
|
beispiel: str) -> None:
|
||||||
|
"""Weist relative Pfadangaben zurück.
|
||||||
|
|
||||||
|
Ein relativer Pfad wird gegen das Arbeitsverzeichnis des Prozesses
|
||||||
|
aufgelöst — bei der systemd-Unit also gegen `WorkingDirectory`
|
||||||
|
(/opt/pdf-ocr-hotfolder). `incoming = "in"` legte damit stillschweigend
|
||||||
|
/opt/pdf-ocr-hotfolder/in an: der Scanner schreibt woanders hin als der
|
||||||
|
Dienst schaut, und niemand sieht einen Fehler. Absolute Pfade sind die
|
||||||
|
einzige sinnvolle Angabe; install.sh erzeugt ohnehin nur solche.
|
||||||
|
"""
|
||||||
|
if not value or Path(value).is_absolute():
|
||||||
|
return
|
||||||
|
raise ConfigError(
|
||||||
|
f"{cfg_path}: {label} = {value!r} ist ein relativer Pfad. Hier sind "
|
||||||
|
f"nur absolute Pfade zulässig — ein relativer würde gegen das "
|
||||||
|
f"Arbeitsverzeichnis des Dienstes aufgelöst "
|
||||||
|
f"(WorkingDirectory, also z.B. /opt/pdf-ocr-hotfolder/{value}) und "
|
||||||
|
f"nicht gegen das Verzeichnis, in dem die Config liegt. "
|
||||||
|
f'Bitte absolut angeben, z.B. "{beispiel}".'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
value = str(value)
|
||||||
|
_require_absolute(value, f"In der Sektion [paths] der Eintrag '{key}'",
|
||||||
|
cfg_path, f"/var/lib/pdf-ocr-hotfolder/{key}")
|
||||||
|
return Path(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:
|
def load_config(path: str | Path) -> Config:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
data = tomllib.load(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")
|
p = _section(data, "paths")
|
||||||
paths = Paths(
|
paths = Paths(
|
||||||
incoming=Path(p["incoming"]),
|
incoming=_require_path(p, "incoming", path),
|
||||||
outgoing=Path(p["outgoing"]),
|
outgoing=_require_path(p, "outgoing", path),
|
||||||
working=Path(p["working"]),
|
working=_require_path(p, "working", path),
|
||||||
error=Path(p["error"]),
|
error=_require_path(p, "error", path),
|
||||||
)
|
)
|
||||||
|
|
||||||
ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items()
|
ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items()
|
||||||
if k in OcrConfig.__annotations__})
|
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()
|
verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items()
|
||||||
if k in VeraPdfConfig.__annotations__})
|
if k in VeraPdfConfig.__annotations__})
|
||||||
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
|
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
|
||||||
@@ -120,10 +247,71 @@ def load_config(path: str | Path) -> Config:
|
|||||||
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
|
email = EmailNotify(**{k: v for k, v in _section(data, "notify", "email").items()
|
||||||
if k in EmailNotify.__annotations__})
|
if k in EmailNotify.__annotations__})
|
||||||
|
|
||||||
|
# Dieselbe Regel wie für [paths]: beides sind Verzeichnisse, in die der
|
||||||
|
# Dienst schreibt, und beide wären relativ aufgelöst schlicht falsch.
|
||||||
|
_require_absolute(str(output.archive_dir), "[output].archive_dir", path,
|
||||||
|
"/var/lib/pdf-ocr-hotfolder/archive")
|
||||||
|
_require_absolute(str(folder.target), "[upload.folder].target", path,
|
||||||
|
"/srv/scans/fertig")
|
||||||
|
|
||||||
log_level = _section(data, "logging").get("level", "INFO")
|
log_level = _section(data, "logging").get("level", "INFO")
|
||||||
|
|
||||||
return Config(
|
return Config(
|
||||||
paths=paths, ocr=ocr, verapdf=verapdf,
|
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
|
||||||
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
|
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
|
||||||
log_level=log_level,
|
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, wegen dem ocrmypdf die Kombination mit skip_text ablehnt "
|
||||||
|
"(Issue #3). Der Preflight bricht ab, falls die installierte "
|
||||||
|
"Ghostscript-Version betroffen ist; ab 10.02.1 ist alles in "
|
||||||
|
"Ordnung. Auf Debian 12 lässt sich Ghostscript nicht anheben — "
|
||||||
|
"bookworm-backports enthält kein Ghostscript. Dort bleiben nur "
|
||||||
|
"pdfa_level = \"\" (kein PDF/A), skip_text = false oder eine "
|
||||||
|
"Distribution mit neuerem Ghostscript (Debian 13: 10.05.1)."
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|||||||
+288
-16
@@ -2,15 +2,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import OcrConfig, VeraPdfConfig
|
from .config import OcrConfig, OutputConfig, VeraPdfConfig
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
class VeraPdfUnavailable(RuntimeError):
|
||||||
|
"""veraPDF konnte nicht befragt werden — Programm fehlt, startet nicht, Timeout.
|
||||||
|
|
||||||
|
Ausdrücklich KEIN inhaltliches Urteil über die PDF. Der Unterschied ist
|
||||||
|
existenziell: ein nicht startbares veraPDF, das wie ein FAIL behandelt
|
||||||
|
wird, schiebt JEDES OCR-Ergebnis nach error/ und entsorgt das Original
|
||||||
|
laut [output].original_on_success — bei dessen Default `delete` also
|
||||||
|
Scan für Scan die Vorlage, während der Dienst als "läuft" dasteht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# veraPDF schreibt mit `--format text` pro Datei eine Zeile, die mit dem
|
||||||
|
# Urteil beginnt. Steht in der Ausgabe weder PASS noch FAIL, hat veraPDF gar
|
||||||
|
# nichts geprüft (fehlendes Java, kaputter Wrapper, falsches Flavour) — das
|
||||||
|
# ist kein "nicht konform", sondern ein fehlendes Urteil.
|
||||||
|
_VERAPDF_VERDICTS = ("PASS", "FAIL")
|
||||||
|
|
||||||
|
# Sekunden, die veraPDF pro Datei laufen darf
|
||||||
|
VERAPDF_TIMEOUT = 300
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessResult:
|
class ProcessResult:
|
||||||
@@ -19,6 +75,9 @@ class ProcessResult:
|
|||||||
success: bool
|
success: bool
|
||||||
error: str = ""
|
error: str = ""
|
||||||
verapdf_passed: bool | None = None
|
verapdf_passed: bool | None = None
|
||||||
|
# Gesetzt, wenn der Durchlauf erfolgreich war, aber etwas Nennenswertes
|
||||||
|
# danebenlief (aktuell: das Original ließ sich nicht entsorgen).
|
||||||
|
warning: str = ""
|
||||||
|
|
||||||
|
|
||||||
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||||
@@ -39,29 +98,79 @@ def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
|||||||
else:
|
else:
|
||||||
kwargs["output_type"] = "pdf"
|
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)
|
log.info("OCR start: %s", src.name)
|
||||||
ocrmypdf.ocr(str(src), str(dst), **kwargs)
|
ocrmypdf.ocr(str(src), str(dst), **kwargs)
|
||||||
log.info("OCR done: %s", dst.name)
|
log.info("OCR done: %s", dst.name)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_verapdf_binary(binary: str) -> str | None:
|
||||||
|
"""Sucht das veraPDF-Programm und prüft, ob es ausführbar ist.
|
||||||
|
|
||||||
|
Beide Schreibweisen sind zulässig: ein Pfad (`/opt/verapdf/verapdf`, der
|
||||||
|
Default) wird direkt geprüft, ein nackter Name (`verapdf`) im PATH
|
||||||
|
gesucht.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Der aufrufbare Pfad oder None.
|
||||||
|
"""
|
||||||
|
if not binary:
|
||||||
|
return None
|
||||||
|
if os.sep in binary:
|
||||||
|
p = Path(binary)
|
||||||
|
return str(p) if p.is_file() and os.access(p, os.X_OK) else None
|
||||||
|
return shutil.which(binary)
|
||||||
|
|
||||||
|
|
||||||
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
|
def run_verapdf(pdf: Path, cfg: VeraPdfConfig) -> bool:
|
||||||
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform."""
|
"""Validiert PDF/A mit veraPDF (CLI). Gibt True zurück, wenn konform.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True = konform (PASS), False = nicht konform (FAIL).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
VeraPdfUnavailable: veraPDF ließ sich nicht befragen. Das ist kein
|
||||||
|
FAIL — siehe Klassen-Docstring.
|
||||||
|
"""
|
||||||
if not cfg.enabled:
|
if not cfg.enabled:
|
||||||
return True
|
return True
|
||||||
if not Path(cfg.binary).exists():
|
binary = resolve_verapdf_binary(cfg.binary)
|
||||||
log.warning("veraPDF binary nicht gefunden: %s", cfg.binary)
|
if binary is None:
|
||||||
return False
|
raise VeraPdfUnavailable(
|
||||||
|
f"[verapdf].binary = {cfg.binary!r} existiert nicht oder ist nicht "
|
||||||
|
"ausführbar"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[cfg.binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
[binary, "--flavour", cfg.flavour, "--format", "text", str(pdf)],
|
||||||
capture_output=True, text=True, timeout=300,
|
capture_output=True, text=True, timeout=VERAPDF_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
raise VeraPdfUnavailable(
|
||||||
|
f"veraPDF hat für {pdf.name} nach {VERAPDF_TIMEOUT} s nicht "
|
||||||
|
"geantwortet"
|
||||||
|
) from e
|
||||||
|
except OSError as e:
|
||||||
|
raise VeraPdfUnavailable(f"veraPDF ({binary}) nicht startbar: {e}") from e
|
||||||
|
|
||||||
|
if not any(v in result.stdout for v in _VERAPDF_VERDICTS):
|
||||||
|
ausgabe = (result.stdout + result.stderr).strip().replace("\n", " ")
|
||||||
|
raise VeraPdfUnavailable(
|
||||||
|
f"veraPDF ({binary}) hat kein Urteil geliefert "
|
||||||
|
f"(Exit {result.returncode}): {ausgabe[:300] or '(keine Ausgabe)'}"
|
||||||
|
)
|
||||||
|
|
||||||
ok = result.returncode == 0 and "PASS" in result.stdout
|
ok = result.returncode == 0 and "PASS" in result.stdout
|
||||||
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
log.info("veraPDF %s: %s", "PASS" if ok else "FAIL", pdf.name)
|
||||||
return ok
|
return ok
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log.error("veraPDF Timeout: %s", pdf.name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(
|
def process_pdf(
|
||||||
@@ -71,12 +180,30 @@ def process_pdf(
|
|||||||
error_dir: Path,
|
error_dir: Path,
|
||||||
ocr_cfg: OcrConfig,
|
ocr_cfg: OcrConfig,
|
||||||
vera_cfg: VeraPdfConfig,
|
vera_cfg: VeraPdfConfig,
|
||||||
|
output_cfg: OutputConfig,
|
||||||
) -> ProcessResult:
|
) -> ProcessResult:
|
||||||
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
|
"""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_src = working_dir / src.name
|
||||||
work_out = working_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 / f"OCR_{src.name}"
|
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:
|
try:
|
||||||
shutil.move(str(src), str(work_src))
|
shutil.move(str(src), str(work_src))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -91,22 +218,167 @@ def process_pdf(
|
|||||||
|
|
||||||
vera_ok: bool | None = None
|
vera_ok: bool | None = None
|
||||||
if vera_cfg.enabled:
|
if vera_cfg.enabled:
|
||||||
|
try:
|
||||||
vera_ok = run_verapdf(work_out, vera_cfg)
|
vera_ok = run_verapdf(work_out, vera_cfg)
|
||||||
if not vera_ok:
|
except VeraPdfUnavailable as e:
|
||||||
|
# Kein Urteil über die Datei — also darf auch nichts entsorgt
|
||||||
|
# werden. Original UND OCR-Ergebnis gehen nach error/; das
|
||||||
|
# Original bleibt damit unabhängig von
|
||||||
|
# [output].original_on_success erhalten.
|
||||||
|
log.error(
|
||||||
|
"veraPDF nicht aufrufbar (%s) — %s wird NICHT als ungültig "
|
||||||
|
"gewertet: Original und OCR-Ergebnis liegen in %s, das "
|
||||||
|
"Original wurde weder gelöscht noch archiviert. "
|
||||||
|
"[verapdf].binary prüfen (--check-config)",
|
||||||
|
e, src.name, error_dir,
|
||||||
|
)
|
||||||
_move_to_error(work_out, error_dir)
|
_move_to_error(work_out, error_dir)
|
||||||
work_src.unlink(missing_ok=True)
|
_move_to_error(work_src, error_dir)
|
||||||
|
return ProcessResult(src, final_out, False,
|
||||||
|
f"veraPDF nicht aufrufbar: {e}")
|
||||||
|
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)
|
||||||
|
_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,
|
return ProcessResult(src, final_out, False,
|
||||||
"verapdf validation failed", verapdf_passed=False)
|
"verapdf validation failed", verapdf_passed=False)
|
||||||
|
|
||||||
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
outgoing_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Liegt in outgoing/ schon eine Datei desselben Namens (Scanner liefert
|
||||||
|
# denselben Dateinamen ein zweites Mal, oder das Vorgängerergebnis wurde
|
||||||
|
# noch nicht abgeholt), würde der move sie kommentarlos überschreiben.
|
||||||
|
# Stattdessen derselbe Zeitstempel-Ausweg wie im Archiv.
|
||||||
|
final_out = _collision_free_path(final_out)
|
||||||
|
if final_out.name != out_name:
|
||||||
|
log.warning(
|
||||||
|
"In %s liegt bereits eine Datei %s — das neue OCR-Ergebnis wird "
|
||||||
|
"als %s abgelegt, damit das ältere nicht überschrieben wird",
|
||||||
|
outgoing_dir, out_name, final_out.name,
|
||||||
|
)
|
||||||
shutil.move(str(work_out), str(final_out))
|
shutil.move(str(work_out), str(final_out))
|
||||||
|
# Scheitert die Entsorgung des Originals (Platte voll, read-only), ist der
|
||||||
|
# Durchlauf trotzdem gelungen: das fertige PDF liegt bereits in outgoing/.
|
||||||
|
# Der Fehler darf ihn deshalb nicht entwerten — sonst unterbleibt der
|
||||||
|
# Upload und das Ergebnis bleibt liegen. Er wird als Warnung
|
||||||
|
# weitergereicht und landet in der Benachrichtigung.
|
||||||
|
warning = _dispose_original(work_src, src.name, output_cfg)
|
||||||
|
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok,
|
||||||
|
warning=warning)
|
||||||
|
|
||||||
|
|
||||||
|
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 _collision_free_path(dest: Path) -> Path:
|
||||||
|
"""Weicht einem schon belegten Zielnamen per Zeitstempel-Suffix aus.
|
||||||
|
|
||||||
|
Einheitlich für outgoing/ und Archiv: `scan.pdf` wird zu
|
||||||
|
`scan_20260923-081500.pdf`. Ist auch der Zeitstempel-Name belegt (zwei
|
||||||
|
Dateien innerhalb derselben Sekunde, z.B. bei mehreren Workern), wird
|
||||||
|
zusätzlich hochgezählt — sonst überschriebe der anschließende `move` doch
|
||||||
|
wieder still.
|
||||||
|
|
||||||
|
Der Rest bleibt unverändert: existiert das Ziel nicht, kommt es
|
||||||
|
unverändert zurück.
|
||||||
|
"""
|
||||||
|
if not dest.exists():
|
||||||
|
return dest
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
candidate = dest.with_name(f"{dest.stem}_{ts}{dest.suffix}")
|
||||||
|
counter = 2
|
||||||
|
while candidate.exists():
|
||||||
|
candidate = dest.with_name(f"{dest.stem}_{ts}-{counter}{dest.suffix}")
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> str:
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
Wirft bewusst NICHT: zum Aufrufzeitpunkt liegt das fertige PDF schon in
|
||||||
|
outgoing/. Eine Exception von hier würde den gelungenen Durchlauf im
|
||||||
|
Catch-all des Service in einen Fehler verwandeln — mitsamt
|
||||||
|
ausgefallenem Upload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Leerer String = erledigt. Sonst die Fehlermeldung (bereits geloggt).
|
||||||
|
"""
|
||||||
|
if not work_src.exists():
|
||||||
|
return ""
|
||||||
|
mode = cfg.original_on_success
|
||||||
|
if mode == "archive" and cfg.archive_dir:
|
||||||
|
archive = Path(cfg.archive_dir)
|
||||||
|
try:
|
||||||
|
archive.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Bei Namens-Kollision mit Timestamp umbenennen (gleicher Weg wie
|
||||||
|
# für das Ergebnis in outgoing/)
|
||||||
|
dest = _collision_free_path(archive / original_name)
|
||||||
|
shutil.move(str(work_src), str(dest))
|
||||||
|
except OSError as e:
|
||||||
|
return _disposal_failed(work_src, original_name,
|
||||||
|
f"nicht nach {archive} archiviert", e)
|
||||||
|
log.info("Original archiviert: %s", dest)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if mode == "archive":
|
||||||
|
log.error("original_on_success=archive aber archive_dir ist leer — "
|
||||||
|
"lösche stattdessen")
|
||||||
|
elif mode != "delete":
|
||||||
|
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
|
||||||
|
try:
|
||||||
work_src.unlink(missing_ok=True)
|
work_src.unlink(missing_ok=True)
|
||||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
|
except OSError as e:
|
||||||
|
return _disposal_failed(work_src, original_name, "nicht gelöscht", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _disposal_failed(work_src: Path, original_name: str, was: str,
|
||||||
|
exc: OSError) -> str:
|
||||||
|
"""Einheitliche Meldung, wenn das Original nicht entsorgt werden konnte."""
|
||||||
|
msg = (
|
||||||
|
f"Original {original_name} konnte {was} werden ({exc}). Das OCR-PDF ist "
|
||||||
|
f"fertig und wird normal ausgeliefert, das Original liegt aber "
|
||||||
|
f"weiterhin in {work_src.parent} — es wird beim nächsten Start dort "
|
||||||
|
f"aufgegriffen und ein zweites Mal durch das OCR geschickt. Bitte "
|
||||||
|
f"{work_src} von Hand aufräumen und die Ursache beheben "
|
||||||
|
f"(Plattenplatz, Schreibrechte)."
|
||||||
|
)
|
||||||
|
log.error("%s", msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def _move_to_error(p: Path, error_dir: Path) -> None:
|
def _move_to_error(p: Path, error_dir: Path) -> None:
|
||||||
|
"""Verschiebt eine Datei ins error-Verzeichnis, ohne dort etwas zu überschreiben.
|
||||||
|
|
||||||
|
Scheitert dieselbe `scan.pdf` zweimal, ersetzte die zweite bisher still die
|
||||||
|
erste — dieselbe Datenverlust-Klasse wie in outgoing/. Deshalb derselbe
|
||||||
|
Zeitstempel-Ausweg über `_collision_free_path()`.
|
||||||
|
"""
|
||||||
error_dir.mkdir(parents=True, exist_ok=True)
|
error_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = _collision_free_path(error_dir / p.name)
|
||||||
|
if dest.name != p.name:
|
||||||
|
log.warning(
|
||||||
|
"In %s liegt bereits eine Datei %s — die neue wird als %s abgelegt, "
|
||||||
|
"damit die ältere nicht überschrieben wird",
|
||||||
|
error_dir, p.name, dest.name,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
shutil.move(str(p), str(error_dir / p.name))
|
shutil.move(str(p), str(dest))
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|
log.exception("Konnte %s nicht in error-Verzeichnis verschieben", p)
|
||||||
|
|||||||
+509
-26
@@ -2,18 +2,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
from .config import Config
|
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,
|
||||||
|
resolve_verapdf_binary,
|
||||||
|
)
|
||||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -23,14 +33,164 @@ class PreflightError(RuntimeError):
|
|||||||
"""Erforderliche externe Binaries fehlen."""
|
"""Erforderliche externe Binaries fehlen."""
|
||||||
|
|
||||||
|
|
||||||
|
# Exit-Code, mit dem sich der Dienst bei totem watchdog-Observer beendet.
|
||||||
|
# Bewusst NICHT 2: die Unit setzt RestartPreventExitStatus=2 für Config- und
|
||||||
|
# Preflight-Fehler, die ein Neustart nicht heilt. Ein toter Observer soll
|
||||||
|
# dagegen genau das — neu starten, damit der inotify-Watch neu aufgesetzt wird.
|
||||||
|
EXIT_OBSERVER_DEAD = 3
|
||||||
|
|
||||||
|
|
||||||
# Pflicht-Binaries für ocrmypdf
|
# Pflicht-Binaries für ocrmypdf
|
||||||
_REQUIRED_BINARIES = ("tesseract", "gs")
|
_REQUIRED_BINARIES = ("tesseract", "gs")
|
||||||
|
|
||||||
|
# Ghostscript-Versionen mit bekanntem 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 check_preflight() -> None:
|
# Ab dieser ocrmypdf-Major steht die Ghostscript-Pruefung hinter
|
||||||
"""Prüft, ob alle externen Abhängigkeiten (Tesseract, Ghostscript) installiert sind.
|
# `if options.output_type.startswith('pdfa')` — mit pdfa_level = "" wird
|
||||||
|
# Ghostscript gar nicht angefasst und die Pruefung greift nicht.
|
||||||
|
# Darunter (16.x und aelter) laeuft sie BEDINGUNGSLOS, also auch bei
|
||||||
|
# output_type="pdf": dort reicht skip_text=true, um auf Debian 12 jede
|
||||||
|
# einzelne PDF scheitern zu lassen. Quelle jeweils
|
||||||
|
# ocrmypdf/builtin_plugins/ghostscript.py::check_options().
|
||||||
|
_OCRMYPDF_GS_GUARD_MAJOR = 17
|
||||||
|
|
||||||
Wirft PreflightError mit Liste der fehlenden Binaries.
|
|
||||||
|
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 bekannten 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 detect_ocrmypdf_version() -> str | None:
|
||||||
|
"""Liest die installierte ocrmypdf-Version aus den Paket-Metadaten.
|
||||||
|
|
||||||
|
Bewusst über `importlib.metadata` statt über einen Import: das ist
|
||||||
|
billiger und funktioniert auch in den Tests, in denen ocrmypdf gar nicht
|
||||||
|
installiert ist (dann None).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
return version("ocrmypdf")
|
||||||
|
except Exception: # noqa: BLE001 - fehlende Metadaten dürfen nichts umwerfen
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ocrmypdf_checks_gs_always(version: str | None) -> bool:
|
||||||
|
"""True, wenn ocrmypdf die Ghostscript-Pruefung unabhaengig vom output_type fährt.
|
||||||
|
|
||||||
|
Das ist bei 16.x und aelter der Fall (siehe `_OCRMYPDF_GS_GUARD_MAJOR`).
|
||||||
|
Ist die Version unbekannt, wird `False` angenommen: der Pin in
|
||||||
|
requirements.txt steht auf 17.x, und ein Fehlalarm, der den Dienst nicht
|
||||||
|
starten laesst, waere schlimmer als die fehlende Warnung.
|
||||||
|
"""
|
||||||
|
if not version:
|
||||||
|
return False
|
||||||
|
parsed = _parse_version(version)
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
return parsed[0] < _OCRMYPDF_GS_GUARD_MAJOR
|
||||||
|
|
||||||
|
|
||||||
|
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_verapdf_binary(enabled: bool, binary: str) -> None:
|
||||||
|
"""Prüft das in [verapdf].binary konfigurierte Programm — wenn aktiviert.
|
||||||
|
|
||||||
|
Ohne diese Prüfung ist ein Tippfehler im Pfad der gefährlichste Fehler des
|
||||||
|
ganzen Dienstes: `run_verapdf()` findet das Programm für JEDE Datei nicht,
|
||||||
|
das OCR-Ergebnis wandert nach error/, und `_dispose_original()` löscht bei
|
||||||
|
`original_on_success = "delete"` (dem Default) das Original. Scan für Scan
|
||||||
|
verschwinden so die Vorlagen, während die Unit als `active (running)`
|
||||||
|
dasteht.
|
||||||
|
"""
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
if not binary:
|
||||||
|
raise PreflightError(
|
||||||
|
"[verapdf].enabled = true, aber [verapdf].binary ist leer. "
|
||||||
|
"Entweder den Pfad zum veraPDF-Programm eintragen oder "
|
||||||
|
"[verapdf].enabled = false setzen."
|
||||||
|
)
|
||||||
|
if resolve_verapdf_binary(binary) is None:
|
||||||
|
raise PreflightError(
|
||||||
|
f"[verapdf].enabled = true, aber [verapdf].binary = {binary!r} "
|
||||||
|
"existiert nicht oder ist nicht ausführbar. Der Dienst startet "
|
||||||
|
"bewusst nicht: ein nicht aufrufbares veraPDF würde sonst jede "
|
||||||
|
"einzelne PDF als ungültig werten, das OCR-Ergebnis nach error/ "
|
||||||
|
"schieben und das Original laut [output].original_on_success "
|
||||||
|
"entsorgen. Pfad korrigieren (chmod +x nicht vergessen) oder "
|
||||||
|
"[verapdf].enabled = false setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_preflight(pdfa_level: str = "", skip_text: bool = False,
|
||||||
|
verapdf_enabled: bool = False,
|
||||||
|
verapdf_binary: str = "") -> None:
|
||||||
|
"""Prüft externe Abhängigkeiten.
|
||||||
|
|
||||||
|
- Tesseract und Ghostscript müssen im PATH sein
|
||||||
|
- Die Ghostscript-Version wird gegen den bekannten 10.0.0–10.02.0 Bug
|
||||||
|
geprüft, und zwar genau unter der Bedingung, unter der ocrmypdf selbst
|
||||||
|
abbricht (siehe `_gs_block_reason`).
|
||||||
|
- Ist [verapdf].enabled gesetzt, muss auch das dort konfigurierte
|
||||||
|
Programm vorhanden und ausführbar sein (siehe `check_verapdf_binary`).
|
||||||
|
|
||||||
|
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
|
||||||
"""
|
"""
|
||||||
missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None]
|
missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -39,6 +199,94 @@ def check_preflight() -> None:
|
|||||||
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
|
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reason = _gs_block_reason(pdfa_level, skip_text)
|
||||||
|
if reason:
|
||||||
|
raise PreflightError(reason)
|
||||||
|
|
||||||
|
check_verapdf_binary(verapdf_enabled, verapdf_binary)
|
||||||
|
|
||||||
|
|
||||||
|
def _gs_block_reason(pdfa_level: str, skip_text: bool) -> str | None:
|
||||||
|
"""Liefert die Fehlermeldung, wenn ocrmypdf mit diesem Ghostscript abbricht.
|
||||||
|
|
||||||
|
Abgebildet wird die reale Bedingung aus
|
||||||
|
`ocrmypdf/builtin_plugins/ghostscript.py::check_options()`:
|
||||||
|
|
||||||
|
betroffene GS-Version UND (skip_text ODER redo_ocr)
|
||||||
|
UND (PDF/A-Ausgabe ODER ocrmypdf < 17)
|
||||||
|
|
||||||
|
Die letzte Klammer ist der Teil, der v0.6.0 durchrutschen ließ: bis
|
||||||
|
einschließlich ocrmypdf 16.x steht die Pruefung ohne jeden Guard in
|
||||||
|
`check_options()` und schlaegt deshalb auch bei `output_type="pdf"` zu.
|
||||||
|
Ab 17.0.0 umschliesst sie ein
|
||||||
|
`if options.output_type.startswith('pdfa'):` — ohne PDF/A wird
|
||||||
|
Ghostscript nicht angefasst.
|
||||||
|
|
||||||
|
`redo_ocr` kennt unsere Config nicht (es gibt keinen entsprechenden Key in
|
||||||
|
`OcrConfig`), deshalb steht es hier bewusst nicht in der Bedingung.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fehlermeldung oder None, wenn die Kombination unkritisch ist.
|
||||||
|
"""
|
||||||
|
if not skip_text:
|
||||||
|
# Weder skip_text noch redo_ocr — ocrmypdf fasst den Pfad nicht an.
|
||||||
|
return None
|
||||||
|
|
||||||
|
gs_version = detect_ghostscript_version()
|
||||||
|
if not is_ghostscript_broken(gs_version):
|
||||||
|
return None
|
||||||
|
|
||||||
|
ocrmypdf_version = detect_ocrmypdf_version()
|
||||||
|
always = ocrmypdf_checks_gs_always(ocrmypdf_version)
|
||||||
|
if not pdfa_level and not always:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pdfa_level:
|
||||||
|
ursache = (
|
||||||
|
f"[ocr].pdfa_level = {pdfa_level!r} (PDF/A-Ausgabe) zusammen mit "
|
||||||
|
"[ocr].skip_text = true"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ursache = (
|
||||||
|
f"[ocr].skip_text = true und ocrmypdf {ocrmypdf_version} — bis "
|
||||||
|
f"einschließlich {_OCRMYPDF_GS_GUARD_MAJOR - 1}.x prüft ocrmypdf "
|
||||||
|
"Ghostscript auch dann, wenn gar kein PDF/A erzeugt wird. Jede "
|
||||||
|
"einzelne PDF würde in error/ landen"
|
||||||
|
)
|
||||||
|
|
||||||
|
if pdfa_level:
|
||||||
|
abhilfe = (
|
||||||
|
"Abhilfe — eines davon: "
|
||||||
|
"(1) [ocr].pdfa_level = \"\" setzen (der Default; ohne PDF/A-Ausgabe "
|
||||||
|
"fasst ocrmypdf >= 17 Ghostscript gar nicht an, die betroffene "
|
||||||
|
"Version ist dann unproblematisch) — kostet allerdings PDF/A; "
|
||||||
|
"(2) [ocr].skip_text = false setzen (dann wird vorhandener Text neu "
|
||||||
|
"erkannt statt übersprungen, das kostet Laufzeit); "
|
||||||
|
"(3) eine Distribution mit neuerem Ghostscript einsetzen — "
|
||||||
|
"Debian 13 liefert 10.05.1."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
abhilfe = (
|
||||||
|
"Abhilfe — eines davon: "
|
||||||
|
"(1) ocrmypdf >= 17 einsetzen (requirements.txt pinnt 17.x; dort "
|
||||||
|
"wird Ghostscript ohne PDF/A-Ausgabe gar nicht angefasst) — nach "
|
||||||
|
"einem Downgrade also die venv neu bauen; "
|
||||||
|
"(2) [ocr].skip_text = false setzen (dann wird vorhandener Text neu "
|
||||||
|
"erkannt statt übersprungen, das kostet Laufzeit); "
|
||||||
|
"(3) eine Distribution mit neuerem Ghostscript einsetzen — "
|
||||||
|
"Debian 13 liefert 10.05.1."
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Ghostscript {gs_version} ist von einem bekannten Fehler betroffen "
|
||||||
|
"(10.0.0–10.02.0, der Debian-12-Standard) und wird von ocrmypdf "
|
||||||
|
f"abgelehnt: {ursache}. "
|
||||||
|
+ abhilfe
|
||||||
|
+ " Ein Ghostscript-Upgrade auf Debian 12 gibt es NICHT: "
|
||||||
|
"bookworm-backports enthält kein Ghostscript-Paket. Wer auf Debian 12 "
|
||||||
|
"PDF/A zusammen mit skip_text = true braucht, hat dort keinen Weg."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_pdf(path: Path) -> bool:
|
def _is_pdf(path: Path) -> bool:
|
||||||
return path.suffix.lower() == ".pdf" and path.is_file()
|
return path.suffix.lower() == ".pdf" and path.is_file()
|
||||||
@@ -112,8 +360,20 @@ class HotfolderService:
|
|||||||
|
|
||||||
# ---- Lifecycle ----
|
# ---- Lifecycle ----
|
||||||
|
|
||||||
def run(self) -> None:
|
def _preflight(self) -> None:
|
||||||
check_preflight()
|
check_preflight(self.cfg.ocr.pdfa_level, self.cfg.ocr.skip_text,
|
||||||
|
self.cfg.verapdf.enabled, self.cfg.verapdf.binary)
|
||||||
|
check_output_config(self.cfg.output.original_on_success,
|
||||||
|
self.cfg.output.archive_dir,
|
||||||
|
self.cfg.output.name_mode)
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
"""Startet den Dienst und läuft, bis gestoppt wird.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
0 bei regulärem Stopp (SIGTERM/SIGINT), sonst `EXIT_OBSERVER_DEAD`.
|
||||||
|
"""
|
||||||
|
self._preflight()
|
||||||
self.ensure_dirs()
|
self.ensure_dirs()
|
||||||
self._scan_existing()
|
self._scan_existing()
|
||||||
|
|
||||||
@@ -126,18 +386,49 @@ class HotfolderService:
|
|||||||
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
signal.signal(signal.SIGINT, lambda *_: self._stop.set())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not self._stop.is_set():
|
return self._wait_loop()
|
||||||
self._stop.wait(1.0)
|
|
||||||
finally:
|
finally:
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
|
|
||||||
|
def _wait_loop(self) -> int:
|
||||||
|
"""Hauptschleife: wartet auf den Stopp und bewacht den Observer.
|
||||||
|
|
||||||
|
Stirbt der watchdog-Observer im Betrieb (erschöpftes
|
||||||
|
inotify-Watch-Limit, ersetztes oder neu gemountetes Verzeichnis),
|
||||||
|
blieb die Unit bisher `active (running)` und verarbeitete nichts mehr:
|
||||||
|
kein Log, keine Mail, niemand merkt es. Für einen Hotfolder ist das
|
||||||
|
der schlechteste denkbare Zustand. Deshalb wird der Observer
|
||||||
|
sekündlich mitgeprüft und der Dienst im Ernstfall mit
|
||||||
|
`EXIT_OBSERVER_DEAD` beendet, damit systemd ihn per
|
||||||
|
`Restart=on-failure` neu startet und den Watch neu aufsetzt.
|
||||||
|
"""
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._stop.wait(1.0)
|
||||||
|
if self._stop.is_set():
|
||||||
|
# Regulärer Stopp — hier darf kein Fehlalarm entstehen, auch
|
||||||
|
# wenn der Observer planmäßig schon gestoppt wurde.
|
||||||
|
break
|
||||||
|
if self._observer is not None and not self._observer.is_alive():
|
||||||
|
log.error(
|
||||||
|
"Der Verzeichnis-Watch auf %s ist gestorben — es werden "
|
||||||
|
"KEINE neuen Dateien mehr erkannt. Mögliche Ursachen: "
|
||||||
|
"erschöpftes inotify-Watch-Limit "
|
||||||
|
"(fs.inotify.max_user_watches), ersetztes oder neu "
|
||||||
|
"gemountetes Verzeichnis. Der Dienst beendet sich mit "
|
||||||
|
"Exit %d, damit systemd ihn neu startet und der Watch "
|
||||||
|
"neu aufgesetzt wird.",
|
||||||
|
self.cfg.paths.incoming, EXIT_OBSERVER_DEAD,
|
||||||
|
)
|
||||||
|
return EXIT_OBSERVER_DEAD
|
||||||
|
return 0
|
||||||
|
|
||||||
def run_once(self) -> int:
|
def run_once(self) -> int:
|
||||||
"""Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich.
|
"""Verarbeitet alle bereits liegenden PDFs (incoming/ + working/) und beendet sich.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||||
"""
|
"""
|
||||||
check_preflight()
|
self._preflight()
|
||||||
self.ensure_dirs()
|
self.ensure_dirs()
|
||||||
self._scan_existing()
|
self._scan_existing()
|
||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
@@ -156,10 +447,106 @@ class HotfolderService:
|
|||||||
# ---- Queue ----
|
# ---- Queue ----
|
||||||
|
|
||||||
def _scan_existing(self) -> None:
|
def _scan_existing(self) -> None:
|
||||||
"""Beim Start: bereits liegende PDFs aufgreifen."""
|
"""Beim Start: bereits liegende PDFs aufgreifen.
|
||||||
for p in self.cfg.paths.incoming.iterdir():
|
|
||||||
|
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()
|
||||||
|
fremd: list[str] = []
|
||||||
|
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||||
if _is_pdf(p):
|
if _is_pdf(p):
|
||||||
self.enqueue(p)
|
self.enqueue(p)
|
||||||
|
elif p.is_file():
|
||||||
|
fremd.append(p.name)
|
||||||
|
self._report_non_pdf(fremd)
|
||||||
|
|
||||||
|
def _report_non_pdf(self, names: list[str]) -> None:
|
||||||
|
"""Meldet einmalig, wie viele Fremddateien in incoming/ liegen.
|
||||||
|
|
||||||
|
Alles ohne .pdf-Endung wird ignoriert und sammelte sich bisher stumm
|
||||||
|
an — Scanner-Fehlablagen, abgebrochene Uploads, Thumbnails. Eine
|
||||||
|
Sammelmeldung beim Start-Scan, keine Zeile pro Datei und nichts im
|
||||||
|
laufenden Betrieb: das soll auffallen, nicht spammen.
|
||||||
|
"""
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
beispiele = ", ".join(names[:3])
|
||||||
|
if len(names) > 3:
|
||||||
|
beispiele += f", … (+{len(names) - 3} weitere)"
|
||||||
|
log.warning(
|
||||||
|
"In %s liegen %d Datei(en) ohne .pdf-Endung — sie werden nicht "
|
||||||
|
"verarbeitet und bleiben dort liegen: %s",
|
||||||
|
self.cfg.paths.incoming, len(names), beispiele,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
def enqueue(self, path: Path) -> None:
|
||||||
if not _is_pdf(path):
|
if not _is_pdf(path):
|
||||||
@@ -181,13 +568,33 @@ class HotfolderService:
|
|||||||
|
|
||||||
# ---- Processing ----
|
# ---- 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:
|
def _process(self, path: Path) -> None:
|
||||||
if not _wait_until_stable(path):
|
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
|
return
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
result: ProcessResult = process_pdf(
|
result: ProcessResult = process_pdf(
|
||||||
src=path,
|
src=path,
|
||||||
working_dir=self.cfg.paths.working,
|
working_dir=self.cfg.paths.working,
|
||||||
@@ -195,26 +602,102 @@ class HotfolderService:
|
|||||||
error_dir=self.cfg.paths.error,
|
error_dir=self.cfg.paths.error,
|
||||||
ocr_cfg=self.cfg.ocr,
|
ocr_cfg=self.cfg.ocr,
|
||||||
vera_cfg=self.cfg.verapdf,
|
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
|
||||||
|
|
||||||
with self._lock:
|
if not result.success:
|
||||||
if result.success:
|
self._count_error()
|
||||||
self._success_count += 1
|
self._notify(result)
|
||||||
else:
|
return
|
||||||
self._error_count += 1
|
|
||||||
|
|
||||||
if result.success:
|
failed = self._dispatch_uploads(result.output)
|
||||||
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)
|
self._notify(result)
|
||||||
|
|
||||||
def _dispatch_uploads(self, pdf: Path) -> None:
|
def _rescue_to_error(self, src: Path) -> None:
|
||||||
upload_folder(pdf, self.cfg.folder, self.cfg.paths.outgoing)
|
"""Bringt eine Datei nach einer unerwarteten Exception ins error-Verzeichnis.
|
||||||
if self.cfg.nextcloud.enabled:
|
|
||||||
upload_nextcloud(pdf, self.cfg.nextcloud)
|
Die Datei kann je nach Abbruchzeitpunkt noch in incoming/ oder schon in
|
||||||
if self.cfg.sftp.enabled:
|
working/ liegen. Der erste Treffer wird verschoben (keine Doppel-Moves),
|
||||||
upload_sftp(pdf, self.cfg.sftp)
|
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:
|
def _notify(self, result: ProcessResult) -> None:
|
||||||
|
if result.success and result.warning:
|
||||||
|
# Erfolgreich verarbeitet, aber das Original blieb liegen. Der
|
||||||
|
# Durchlauf zählt als Erfolg (das PDF ist fertig und ausgeliefert),
|
||||||
|
# die Mail geht aber als Nicht-Erfolg raus, damit sie auch bei
|
||||||
|
# [notify.email].on = "errors" zugestellt wird — sonst wäre das
|
||||||
|
# genau wieder ein stiller Fehlerpfad.
|
||||||
|
subject = f"[pdf-ocr] OK mit Warnung: {result.source.name}"
|
||||||
|
body = (
|
||||||
|
f"Datei verarbeitet: {result.output}\n\n"
|
||||||
|
f"ACHTUNG: {result.warning}\n"
|
||||||
|
)
|
||||||
|
notify_email(self.cfg.email, subject, body, False)
|
||||||
|
return
|
||||||
if result.success:
|
if result.success:
|
||||||
subject = f"[pdf-ocr] OK: {result.source.name}"
|
subject = f"[pdf-ocr] OK: {result.source.name}"
|
||||||
body = f"Datei verarbeitet: {result.output}\n"
|
body = f"Datei verarbeitet: {result.output}\n"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
@@ -12,6 +13,7 @@ import paramiko
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
|
from .config import EmailNotify, FolderUpload, NextcloudUpload, SftpUpload
|
||||||
|
from .processor import _collision_free_path
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,7 +27,18 @@ def upload_folder(pdf: Path, cfg: FolderUpload, default_target: Path) -> bool:
|
|||||||
try:
|
try:
|
||||||
if pdf.resolve() == dest.resolve():
|
if pdf.resolve() == dest.resolve():
|
||||||
return True
|
return True
|
||||||
dest.write_bytes(pdf.read_bytes())
|
# Gleichnamige Datei im Ziel wurde bisher kommentarlos ersetzt.
|
||||||
|
# Derselbe Zeitstempel-Ausweg wie in outgoing/, archive/ und error/.
|
||||||
|
dest = _collision_free_path(dest)
|
||||||
|
if dest.name != pdf.name:
|
||||||
|
log.warning(
|
||||||
|
"In %s liegt bereits eine Datei %s — die Kopie wird als %s "
|
||||||
|
"abgelegt, damit die ältere nicht überschrieben wird",
|
||||||
|
target, pdf.name, dest.name,
|
||||||
|
)
|
||||||
|
# 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)
|
log.info("Folder upload OK: %s", dest)
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
+24
-4
@@ -1,4 +1,24 @@
|
|||||||
ocrmypdf>=16.0
|
# Feste Pins: ein Update darf nicht ungefragt einen Major-Sprung einziehen
|
||||||
watchdog>=4.0
|
# (der naechste waere ocrmypdf 18 — der reisst sonst alle Instanzen auf einmal).
|
||||||
requests>=2.31
|
# Geprueft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13) — fuer beide
|
||||||
paramiko>=3.4
|
# gibt es fertige Wheels, es wird nichts kompiliert.
|
||||||
|
# Beim Anheben: update.sh --rebuild-venv auf einer Testmaschine fahren.
|
||||||
|
#
|
||||||
|
# ocrmypdf: MUSS 17.x sein, 16.x ist fuer uns unbrauchbar (v0.6.1).
|
||||||
|
# In ocrmypdf 16.x laeuft die Ghostscript-Pruefung in
|
||||||
|
# builtin_plugins/ghostscript.py:check_options() BEDINGUNGSLOS, also auch bei
|
||||||
|
# output_type="pdf". Auf Debian 12 (Ghostscript 10.0.0) bricht damit
|
||||||
|
# JEDE PDF ab, sobald skip_text=true gesetzt ist — und das ist unser Default:
|
||||||
|
# if Version('10.0.0') <= gs_version < Version('10.02.1') and (
|
||||||
|
# options.skip_text or options.redo_ocr
|
||||||
|
# ): raise MissingDependencyError(...)
|
||||||
|
# Ab 17.0.0 steckt genau dieser Block in einem
|
||||||
|
# `if options.output_type.startswith('pdfa'):` — bei pdfa_level = "" wird
|
||||||
|
# Ghostscript gar nicht erst angefasst und die Pruefung greift nicht mehr.
|
||||||
|
# Deshalb hier 17.x. 17.4.1 ist die im Feld auf Debian 12 + gs 10.0.0
|
||||||
|
# verifizierte Version; 17.12.1 traegt denselben Guard und waere der
|
||||||
|
# naechste Kandidat, ist aber noch nicht auf einer Testmaschine gefahren.
|
||||||
|
ocrmypdf==17.4.1
|
||||||
|
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,21 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=pdfocr
|
User=pdfocr
|
||||||
Group=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
|
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
# Exit 2 = Konfigurations- oder Preflight-Fehler. Den behebt kein Neustart,
|
||||||
|
# also nicht endlos im 5-Sekunden-Takt neu starten, sondern stehenbleiben —
|
||||||
|
# die Instanz steht dann als 'failed' da und faellt beim Nachsehen auf.
|
||||||
|
# (Restart=on-failure wuerde sonst JEDEN Exit != 0 neu starten; das
|
||||||
|
# Start-Rate-Limit greift bei RestartSec=5 nie.)
|
||||||
|
RestartPreventExitStatus=2
|
||||||
KillMode=mixed
|
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)
|
# Hardening (lockerer wegen AD-User & Datei-ACLs)
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pdf_ocr_hotfolder.config import (
|
|||||||
FolderUpload,
|
FolderUpload,
|
||||||
NextcloudUpload,
|
NextcloudUpload,
|
||||||
OcrConfig,
|
OcrConfig,
|
||||||
|
OutputConfig,
|
||||||
Paths,
|
Paths,
|
||||||
SftpUpload,
|
SftpUpload,
|
||||||
VeraPdfConfig,
|
VeraPdfConfig,
|
||||||
@@ -32,6 +33,7 @@ def tmp_config(tmp_path: Path) -> Config:
|
|||||||
return Config(
|
return Config(
|
||||||
paths=paths,
|
paths=paths,
|
||||||
ocr=OcrConfig(max_workers=1),
|
ocr=OcrConfig(max_workers=1),
|
||||||
|
output=OutputConfig(),
|
||||||
verapdf=VeraPdfConfig(enabled=False),
|
verapdf=VeraPdfConfig(enabled=False),
|
||||||
folder=FolderUpload(enabled=False),
|
folder=FolderUpload(enabled=False),
|
||||||
nextcloud=NextcloudUpload(enabled=False),
|
nextcloud=NextcloudUpload(enabled=False),
|
||||||
|
|||||||
@@ -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,211 @@
|
|||||||
|
"""Punkt 4: Ein Archivierungsfehler darf einen Erfolg nicht in einen Fehler kippen.
|
||||||
|
|
||||||
|
Lief der `shutil.move` ins Archiv auf einen OSError (Platte voll, read-only),
|
||||||
|
flog die Exception NACH dem erfolgreichen Move nach outgoing/. `_process()`
|
||||||
|
fing sie im Catch-all, zählte einen Fehler — und `_dispatch_uploads()` lief
|
||||||
|
nie. Das fertige PDF lag da und wurde nie hochgeladen.
|
||||||
|
|
||||||
|
Jetzt: `_dispose_original()` wirft nicht mehr, meldet den Fehler deutlich und
|
||||||
|
reicht ihn als `ProcessResult.warning` durch. Der Durchlauf zählt als Erfolg
|
||||||
|
(das PDF ist fertig und wird ausgeliefert), die Benachrichtigung geht aber als
|
||||||
|
Nicht-Erfolg raus, damit sie auch bei on = "errors" zugestellt wird.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.config import FolderUpload, OcrConfig, OutputConfig, VeraPdfConfig
|
||||||
|
from pdf_ocr_hotfolder.processor import _dispose_original, process_pdf
|
||||||
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
ORIGINAL = b"%PDF-1.4 original\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||||
|
dst.write_bytes(b"%PDF-1.4 OCRed\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_archive(tmp_path: Path) -> str:
|
||||||
|
"""Ein Archivpfad, dessen mkdir garantiert scheitert (Elternteil = Datei).
|
||||||
|
|
||||||
|
Steht stellvertretend für read-only/volle Platte, ohne mocken zu müssen.
|
||||||
|
"""
|
||||||
|
blocker = tmp_path / "blocker"
|
||||||
|
blocker.write_bytes(b"keine Verzeichnis\n")
|
||||||
|
return str(blocker / "archiv")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(tmp_path: Path) -> dict:
|
||||||
|
dirs = {name: tmp_path / name
|
||||||
|
for name in ("incoming", "working", "outgoing", "error")}
|
||||||
|
for d in dirs.values():
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = dirs["incoming"] / "scan.pdf"
|
||||||
|
src.write_bytes(ORIGINAL)
|
||||||
|
return {"src": src, **dirs}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _dispose_original wirft nicht mehr ----------------
|
||||||
|
|
||||||
|
def test_dispose_archive_failure_returns_message(tmp_path: Path) -> None:
|
||||||
|
work_src = tmp_path / "working" / "scan.pdf"
|
||||||
|
work_src.parent.mkdir()
|
||||||
|
work_src.write_bytes(ORIGINAL)
|
||||||
|
|
||||||
|
msg = _dispose_original(work_src, "scan.pdf",
|
||||||
|
OutputConfig(original_on_success="archive",
|
||||||
|
archive_dir=_blocked_archive(tmp_path)))
|
||||||
|
|
||||||
|
assert msg
|
||||||
|
assert "scan.pdf" in msg
|
||||||
|
# Das Original liegt noch da — nichts wurde verloren
|
||||||
|
assert work_src.read_bytes() == ORIGINAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispose_archive_failure_names_working_dir(tmp_path: Path) -> None:
|
||||||
|
"""Die Meldung muss sagen, wo das Original liegen geblieben ist."""
|
||||||
|
work_src = tmp_path / "working" / "scan.pdf"
|
||||||
|
work_src.parent.mkdir()
|
||||||
|
work_src.write_bytes(ORIGINAL)
|
||||||
|
|
||||||
|
msg = _dispose_original(work_src, "scan.pdf",
|
||||||
|
OutputConfig(original_on_success="archive",
|
||||||
|
archive_dir=_blocked_archive(tmp_path)))
|
||||||
|
|
||||||
|
assert str(work_src.parent) in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispose_delete_failure_returns_message(tmp_path: Path) -> None:
|
||||||
|
work_src = tmp_path / "working" / "scan.pdf"
|
||||||
|
work_src.parent.mkdir()
|
||||||
|
work_src.write_bytes(ORIGINAL)
|
||||||
|
|
||||||
|
with patch.object(Path, "unlink", side_effect=OSError("read-only")):
|
||||||
|
msg = _dispose_original(work_src, "scan.pdf",
|
||||||
|
OutputConfig(original_on_success="delete"))
|
||||||
|
|
||||||
|
assert msg
|
||||||
|
assert "gelöscht" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispose_success_returns_empty(tmp_path: Path) -> None:
|
||||||
|
work_src = tmp_path / "working" / "scan.pdf"
|
||||||
|
work_src.parent.mkdir()
|
||||||
|
work_src.write_bytes(ORIGINAL)
|
||||||
|
archive = tmp_path / "archiv"
|
||||||
|
|
||||||
|
assert _dispose_original(work_src, "scan.pdf",
|
||||||
|
OutputConfig(original_on_success="archive",
|
||||||
|
archive_dir=str(archive))) == ""
|
||||||
|
assert (archive / "scan.pdf").read_bytes() == ORIGINAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispose_missing_file_returns_empty(tmp_path: Path) -> None:
|
||||||
|
assert _dispose_original(tmp_path / "gibtsnicht.pdf", "scan.pdf",
|
||||||
|
OutputConfig()) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- process_pdf bleibt erfolgreich ----------------
|
||||||
|
|
||||||
|
def _run(env: dict, out_cfg: OutputConfig):
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||||
|
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=False),
|
||||||
|
output_cfg=out_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_failure_keeps_run_successful(tmp_path: Path) -> None:
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="archive",
|
||||||
|
archive_dir=_blocked_archive(tmp_path)))
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.warning
|
||||||
|
# Das fertige PDF liegt in outgoing/ und wird normal ausgeliefert
|
||||||
|
assert (env["outgoing"] / "OCR_scan.pdf").exists()
|
||||||
|
assert result.output == env["outgoing"] / "OCR_scan.pdf"
|
||||||
|
# Das Original ist nicht verloren, sondern liegt noch in working/
|
||||||
|
assert (env["working"] / "scan.pdf").read_bytes() == ORIGINAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_failure_logs_error(tmp_path: Path, caplog) -> None:
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.processor"):
|
||||||
|
_run(env, OutputConfig(original_on_success="archive",
|
||||||
|
archive_dir=_blocked_archive(tmp_path)))
|
||||||
|
|
||||||
|
assert str(env["working"]) in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_run_has_no_warning(tmp_path: Path) -> None:
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
result = _run(env, OutputConfig(original_on_success="delete"))
|
||||||
|
assert result.success is True
|
||||||
|
assert result.warning == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Service: Upload läuft trotzdem ----------------
|
||||||
|
|
||||||
|
def _run_once(tmp_config, **patches):
|
||||||
|
stack = [
|
||||||
|
patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None),
|
||||||
|
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True),
|
||||||
|
patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr),
|
||||||
|
]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_still_runs_after_archive_failure(tmp_config, tmp_path) -> None:
|
||||||
|
"""Der Kern des Punktes: das fertige PDF muss trotzdem hochgeladen werden."""
|
||||||
|
ziel = tmp_path / "upload-ziel"
|
||||||
|
tmp_config.folder = FolderUpload(enabled=True, target=str(ziel))
|
||||||
|
tmp_config.output = OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="archive",
|
||||||
|
archive_dir=_blocked_archive(tmp_path))
|
||||||
|
(tmp_config.paths.incoming / "scan.pdf").write_bytes(ORIGINAL)
|
||||||
|
|
||||||
|
service = _run_once(tmp_config)
|
||||||
|
|
||||||
|
assert (ziel / "OCR_scan.pdf").exists()
|
||||||
|
# Der Durchlauf zählt als Erfolg: das PDF ist fertig und ausgeliefert.
|
||||||
|
assert service.success_count == 1
|
||||||
|
assert service.error_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_notification_goes_out_as_error(tmp_config, tmp_path) -> None:
|
||||||
|
"""Die Mail muss auch bei on = 'errors' zugestellt werden."""
|
||||||
|
from pdf_ocr_hotfolder.processor import ProcessResult
|
||||||
|
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.notify_email") as mail:
|
||||||
|
service._notify(ProcessResult(
|
||||||
|
tmp_path / "scan.pdf", tmp_path / "OCR_scan.pdf", True,
|
||||||
|
warning="Original konnte nicht archiviert werden",
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
mail.assert_called_once()
|
||||||
|
args = mail.call_args[0]
|
||||||
|
assert "OK mit Warnung" in args[1]
|
||||||
|
assert "Original konnte nicht archiviert werden" in args[2]
|
||||||
|
assert args[3] is False # -> wird auch bei on="errors" verschickt
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Punkt 2: error/ darf nichts mehr still überschreiben.
|
||||||
|
|
||||||
|
Scheiterte dieselbe `scan.pdf` zweimal, ersetzte die zweite die erste in
|
||||||
|
error/ — dieselbe Datenverlust-Klasse, die für outgoing/ bereits geschlossen
|
||||||
|
ist. Betrifft `_move_to_error()` und damit auch `_rescue_to_error()`.
|
||||||
|
"""
|
||||||
|
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 _move_to_error, process_pdf
|
||||||
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
ERSTE = b"%PDF-1.4 erste\n"
|
||||||
|
ZWEITE = b"%PDF-1.4 zweite\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _move_to_error direkt ----------------
|
||||||
|
|
||||||
|
def test_move_to_error_keeps_existing_file(tmp_path: Path) -> None:
|
||||||
|
error_dir = tmp_path / "error"
|
||||||
|
error_dir.mkdir()
|
||||||
|
(error_dir / "scan.pdf").write_bytes(ERSTE)
|
||||||
|
|
||||||
|
zweite = tmp_path / "scan.pdf"
|
||||||
|
zweite.write_bytes(ZWEITE)
|
||||||
|
_move_to_error(zweite, error_dir)
|
||||||
|
|
||||||
|
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
|
||||||
|
ausweich = list(error_dir.glob("scan_*.pdf"))
|
||||||
|
assert len(ausweich) == 1
|
||||||
|
assert ausweich[0].read_bytes() == ZWEITE
|
||||||
|
assert not zweite.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_error_without_collision_keeps_name(tmp_path: Path) -> None:
|
||||||
|
error_dir = tmp_path / "error"
|
||||||
|
src = tmp_path / "scan.pdf"
|
||||||
|
src.write_bytes(ERSTE)
|
||||||
|
|
||||||
|
_move_to_error(src, error_dir)
|
||||||
|
|
||||||
|
assert (error_dir / "scan.pdf").read_bytes() == ERSTE
|
||||||
|
assert list(error_dir.iterdir()) == [error_dir / "scan.pdf"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_error_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
|
||||||
|
error_dir = tmp_path / "error"
|
||||||
|
error_dir.mkdir()
|
||||||
|
(error_dir / "scan.pdf").write_bytes(ERSTE)
|
||||||
|
src = tmp_path / "scan.pdf"
|
||||||
|
src.write_bytes(ZWEITE)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
|
||||||
|
_move_to_error(src, error_dir)
|
||||||
|
|
||||||
|
assert "scan.pdf" in caplog.text
|
||||||
|
assert "überschrieben" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_error_creates_dir(tmp_path: Path) -> None:
|
||||||
|
src = tmp_path / "scan.pdf"
|
||||||
|
src.write_bytes(ERSTE)
|
||||||
|
error_dir = tmp_path / "tief" / "error"
|
||||||
|
_move_to_error(src, error_dir)
|
||||||
|
assert (error_dir / "scan.pdf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_to_error_survives_oserror(tmp_path: Path, caplog) -> None:
|
||||||
|
"""Ein fehlgeschlagener Move darf weiterhin nur geloggt werden."""
|
||||||
|
src = tmp_path / "scan.pdf"
|
||||||
|
src.write_bytes(ERSTE)
|
||||||
|
error_dir = tmp_path / "error"
|
||||||
|
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.shutil.move",
|
||||||
|
side_effect=OSError("read-only")):
|
||||||
|
_move_to_error(src, error_dir) # darf nicht werfen
|
||||||
|
|
||||||
|
assert src.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- über process_pdf: zweimal dieselbe Datei kaputt ----------------
|
||||||
|
|
||||||
|
def _prepare(tmp_path: Path) -> dict:
|
||||||
|
dirs = {name: tmp_path / name
|
||||||
|
for name in ("incoming", "working", "outgoing", "error")}
|
||||||
|
for d in dirs.values():
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def _run_failing_ocr(dirs: dict, src: Path):
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.run_ocr",
|
||||||
|
side_effect=RuntimeError("ocr kaputt")):
|
||||||
|
return process_pdf(
|
||||||
|
src=src,
|
||||||
|
working_dir=dirs["working"],
|
||||||
|
outgoing_dir=dirs["outgoing"],
|
||||||
|
error_dir=dirs["error"],
|
||||||
|
ocr_cfg=OcrConfig(),
|
||||||
|
vera_cfg=VeraPdfConfig(enabled=False),
|
||||||
|
output_cfg=OutputConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_name_failing_twice_keeps_both(tmp_path: Path) -> None:
|
||||||
|
dirs = _prepare(tmp_path)
|
||||||
|
|
||||||
|
for inhalt in (ERSTE, ZWEITE):
|
||||||
|
src = dirs["incoming"] / "scan.pdf"
|
||||||
|
src.write_bytes(inhalt)
|
||||||
|
result = _run_failing_ocr(dirs, src)
|
||||||
|
assert not result.success
|
||||||
|
|
||||||
|
dateien = sorted(p.read_bytes() for p in dirs["error"].iterdir())
|
||||||
|
assert len(dateien) == 2
|
||||||
|
assert sorted([ERSTE, ZWEITE]) == dateien
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _rescue_to_error erbt den Schutz ----------------
|
||||||
|
|
||||||
|
def test_rescue_to_error_keeps_existing_file(tmp_config) -> None:
|
||||||
|
"""Der Rettungspfad nach einer unerwarteten Exception ebenso."""
|
||||||
|
(tmp_config.paths.error / "boom.pdf").write_bytes(ERSTE)
|
||||||
|
src = tmp_config.paths.incoming / "boom.pdf"
|
||||||
|
src.write_bytes(ZWEITE)
|
||||||
|
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
service._rescue_to_error(src)
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
assert (tmp_config.paths.error / "boom.pdf").read_bytes() == ERSTE
|
||||||
|
ausweich = list(tmp_config.paths.error.glob("boom_*.pdf"))
|
||||||
|
assert len(ausweich) == 1
|
||||||
|
assert ausweich[0].read_bytes() == ZWEITE
|
||||||
@@ -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,227 @@
|
|||||||
|
"""Tests für Issue #3: Ghostscript 10.0.0–10.02.0 Bug-Erkennung.
|
||||||
|
|
||||||
|
Seit v0.6.1 bildet der Preflight die reale ocrmypdf-Bedingung ab:
|
||||||
|
|
||||||
|
betroffene GS-Version UND skip_text UND (pdfa_level ODER ocrmypdf < 17)
|
||||||
|
|
||||||
|
Der letzte Teil ist der Fall, der in v0.6.0 durchrutschte: mit ocrmypdf 16.x
|
||||||
|
greift die Ghostscript-Prüfung auch ohne PDF/A, und der Dienst meldete
|
||||||
|
trotzdem "Preflight ok", während jede einzelne PDF in error/ landete.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.service import (
|
||||||
|
PreflightError,
|
||||||
|
check_preflight,
|
||||||
|
is_ghostscript_broken,
|
||||||
|
ocrmypdf_checks_gs_always,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _env(gs_version: str, ocrmypdf_version: str):
|
||||||
|
"""Binaries vorhanden, Ghostscript- und ocrmypdf-Version vorgegeben."""
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
|
||||||
|
return_value=gs_version), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.detect_ocrmypdf_version",
|
||||||
|
return_value=ocrmypdf_version):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version,expected", [
|
||||||
|
("16.13.0", True), # der Pin aus v0.6.0, der den Ausfall ausgeloest hat
|
||||||
|
("16.0.0", True),
|
||||||
|
("15.4.4", True),
|
||||||
|
("17.0.0", False), # ab hier steckt die Pruefung hinter output_type
|
||||||
|
("17.4.1", False), # unser Pin
|
||||||
|
("17.12.1", False),
|
||||||
|
("18.0.0", False),
|
||||||
|
(None, False), # unbekannt -> kein Fehlalarm
|
||||||
|
("", False),
|
||||||
|
("garbage", False),
|
||||||
|
])
|
||||||
|
def test_ocrmypdf_checks_gs_always(version, expected) -> None:
|
||||||
|
assert ocrmypdf_checks_gs_always(version) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Preflight: ocrmypdf 17.x (unser Pin) ----------------
|
||||||
|
|
||||||
|
def test_broken_gs_skip_text_without_pdfa_passes_on_ocrmypdf_17() -> None:
|
||||||
|
"""Der Debian-12-Standardfall: ohne PDF/A fasst ocrmypdf 17 gs nicht an.
|
||||||
|
|
||||||
|
Das ist die Default-Config (skip_text=true, pdfa_level="") auf Debian 12 —
|
||||||
|
sie muss laufen, sonst startet keine einzige Bestandsinstanz mehr.
|
||||||
|
"""
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_gs_with_pdfa_and_skip_text_fails() -> None:
|
||||||
|
"""Mit pdfa_level + skip_text + kaputtem GS → PreflightError."""
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
|
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
|
||||||
|
check_preflight(pdfa_level="2", skip_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_gs_with_pdfa_without_skip_text_passes() -> None:
|
||||||
|
"""Ohne skip_text greift die ocrmypdf-Bedingung nicht — kein Abbruch."""
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
|
check_preflight(pdfa_level="2", skip_text=False) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_gs_with_pdfa_and_skip_text_passes() -> None:
|
||||||
|
"""Nicht betroffene GS-Version → nie ein Abbruch."""
|
||||||
|
with _env("10.02.1", "17.4.1"):
|
||||||
|
check_preflight(pdfa_level="2", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Preflight: ocrmypdf 16.x (der Ausfall aus v0.6.0) ----------------
|
||||||
|
|
||||||
|
def test_broken_gs_skip_text_without_pdfa_fails_on_ocrmypdf_16() -> None:
|
||||||
|
"""DER Fall, der in v0.6.0 durchrutschte.
|
||||||
|
|
||||||
|
ocrmypdf 16.13.0 + Ghostscript 10.0.0 + skip_text=true + pdfa_level="":
|
||||||
|
"Preflight ok", Dienst active — und jede PDF landete in error/.
|
||||||
|
Jetzt muss der Dienst beim START abbrechen.
|
||||||
|
"""
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError) as exc_info:
|
||||||
|
check_preflight(pdfa_level="", skip_text=True)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "10.0.0" in msg
|
||||||
|
assert "16.13.0" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_gs_without_skip_text_passes_on_ocrmypdf_16() -> None:
|
||||||
|
"""skip_text=false → auch 16.x prüft Ghostscript nicht."""
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=False) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_gs_passes_on_ocrmypdf_16() -> None:
|
||||||
|
"""Nicht betroffene GS-Version → auch mit 16.x kein Abbruch."""
|
||||||
|
with _env("10.02.1", "16.13.0"):
|
||||||
|
check_preflight(pdfa_level="", skip_text=True) # darf nicht werfen
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Meldungstext ----------------
|
||||||
|
|
||||||
|
def test_error_message_names_both_remedies() -> None:
|
||||||
|
"""Der Admin muss aus der Meldung heraus handeln können.
|
||||||
|
|
||||||
|
Die Wege sind die **echten**: `pdfa_level = ""` (Default), `skip_text =
|
||||||
|
false` oder eine Distribution mit neuerem Ghostscript (Debian 13: 10.05.1).
|
||||||
|
Bis v0.7.0 stand hier „Ghostscript aus bookworm-backports" als Weg 1 — das
|
||||||
|
konnte nie funktionieren, denn bookworm-backports führt gar kein
|
||||||
|
Ghostscript-Paket (am echten Paketindex verifiziert: 2606 Pakete,
|
||||||
|
`ghostscript` nicht darunter). Der Rat darf nicht zurückkommen.
|
||||||
|
"""
|
||||||
|
# Fall A: ocrmypdf 16.x ohne PDF/A — Weg 1 ist hier, ocrmypdf anzuheben.
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError) as exc_info:
|
||||||
|
check_preflight(pdfa_level="", skip_text=True)
|
||||||
|
msg_no_pdfa = str(exc_info.value)
|
||||||
|
assert "ocrmypdf >= 17" in msg_no_pdfa, "Weg 1: ocrmypdf anheben"
|
||||||
|
assert "skip_text = false" in msg_no_pdfa, "Weg 2: skip_text abschalten"
|
||||||
|
assert "Debian 13" in msg_no_pdfa, "Weg 3: neuere Distribution"
|
||||||
|
|
||||||
|
# Fall B: PDF/A gewünscht — Weg 1 ist hier, PDF/A aufzugeben.
|
||||||
|
with _env("10.0.0", "17.4.1"):
|
||||||
|
with pytest.raises(PreflightError) as exc_info:
|
||||||
|
check_preflight(pdfa_level="2", skip_text=True)
|
||||||
|
msg_pdfa = str(exc_info.value)
|
||||||
|
assert 'pdfa_level = ""' in msg_pdfa, "Weg 1: PDF/A abschalten"
|
||||||
|
assert "skip_text = false" in msg_pdfa, "Weg 2: skip_text abschalten"
|
||||||
|
assert "Debian 13" in msg_pdfa, "Weg 3: neuere Distribution"
|
||||||
|
|
||||||
|
# Und in keiner der beiden Meldungen der widerlegte Backports-Rat.
|
||||||
|
for msg in (msg_no_pdfa, msg_pdfa):
|
||||||
|
assert "apt install -t bookworm-backports" not in msg
|
||||||
|
assert "sources.list.d/bookworm-backports.list" not in msg
|
||||||
|
# Erwähnt werden darf es — aber nur als ausdrückliche Absage.
|
||||||
|
assert "NICHT" in msg and "kein Ghostscript-Paket" in msg
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Der Dienst muss beim START abbrechen ----------------
|
||||||
|
|
||||||
|
def test_run_once_aborts_on_ocrmypdf_16_with_broken_gs(tmp_config) -> None:
|
||||||
|
"""Abbruch beim Start statt Totalausfall bei der ersten Datei."""
|
||||||
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
assert tmp_config.ocr.skip_text is True
|
||||||
|
assert tmp_config.ocr.pdfa_level == ""
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
with pytest.raises(PreflightError):
|
||||||
|
service.run_once()
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_returns_2_on_ocrmypdf_16_with_broken_gs(
|
||||||
|
tmp_path, tmp_config, monkeypatch, capsys) -> None:
|
||||||
|
"""--check-config meldet den Zustand als Fehler (Exit 2) — auch mitten im Update."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
|
||||||
|
|
||||||
|
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]
|
||||||
|
skip_text = true
|
||||||
|
pdfa_level = ""
|
||||||
|
""")
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||||
|
"--check-config"])
|
||||||
|
with _env("10.0.0", "16.13.0"):
|
||||||
|
assert main() == CHECK_ERROR
|
||||||
|
assert "Ghostscript" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Punkt 6: Fremddateien in incoming/ verschwinden nicht mehr lautlos.
|
||||||
|
|
||||||
|
Alles ohne .pdf-Endung wurde kommentarlos ignoriert und sammelte sich an.
|
||||||
|
Jetzt gibt es beim Start-Scan genau EINE Sammelmeldung — kein Spam im
|
||||||
|
laufenden Betrieb, keine Zeile pro Datei.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
LOGGER = "pdf_ocr_hotfolder.service"
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(tmp_config, caplog) -> str:
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING, logger=LOGGER), \
|
||||||
|
patch.object(HotfolderService, "enqueue"):
|
||||||
|
service._scan_existing()
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
return caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_pdf_files_are_reported(tmp_config, caplog) -> None:
|
||||||
|
(tmp_config.paths.incoming / "notizen.txt").write_text("x")
|
||||||
|
(tmp_config.paths.incoming / "bild.jpg").write_bytes(b"x")
|
||||||
|
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
|
||||||
|
|
||||||
|
text = _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
assert "2 Datei(en) ohne .pdf-Endung" in text
|
||||||
|
assert "notizen.txt" in text
|
||||||
|
assert "bild.jpg" in text
|
||||||
|
assert "scan.pdf" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_aggregate_line(tmp_config, caplog) -> None:
|
||||||
|
"""Eine Sammelmeldung, nicht eine pro Datei."""
|
||||||
|
for i in range(7):
|
||||||
|
(tmp_config.paths.incoming / f"datei{i}.txt").write_text("x")
|
||||||
|
|
||||||
|
text = _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
assert text.count("ohne .pdf-Endung") == 1
|
||||||
|
assert "7 Datei(en)" in text
|
||||||
|
# Nur die ersten drei werden namentlich genannt
|
||||||
|
assert "+4 weitere" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_message_without_foreign_files(tmp_config, caplog) -> None:
|
||||||
|
(tmp_config.paths.incoming / "scan.pdf").write_bytes(b"%PDF-1.4\n")
|
||||||
|
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_incoming_is_quiet(tmp_config, caplog) -> None:
|
||||||
|
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directories_are_not_counted(tmp_config, caplog) -> None:
|
||||||
|
"""Ein Unterverzeichnis ist keine liegengebliebene Fremddatei."""
|
||||||
|
(tmp_config.paths.incoming / "unterordner").mkdir()
|
||||||
|
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uppercase_pdf_is_not_foreign(tmp_config, caplog) -> None:
|
||||||
|
"""`_is_pdf()` ist case-insensitiv — SCAN.PDF ist eine PDF."""
|
||||||
|
(tmp_config.paths.incoming / "SCAN.PDF").write_bytes(b"%PDF-1.4\n")
|
||||||
|
assert "ohne .pdf-Endung" not in _scan(tmp_config, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_spam_during_runtime(tmp_config, caplog) -> None:
|
||||||
|
"""Im laufenden Betrieb bleibt enqueue() für Fremddateien stumm."""
|
||||||
|
fremd = tmp_config.paths.incoming / "notizen.txt"
|
||||||
|
fremd.write_text("x")
|
||||||
|
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.DEBUG, logger=LOGGER):
|
||||||
|
for _ in range(5):
|
||||||
|
service.enqueue(fremd)
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
assert caplog.text == ""
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Log-Ziel: der Dienst loggt nach stdout, nicht nach stderr.
|
||||||
|
|
||||||
|
README und docs/INSTALLATION.md versprechen stdout. `logging.basicConfig()`
|
||||||
|
ohne `stream=` nimmt aber stderr. Für journald ist das egal, für den in der
|
||||||
|
Doku beschriebenen Vordergrund-Notbehelf und für jede Weiterleitung der
|
||||||
|
Ausgabe nicht.
|
||||||
|
|
||||||
|
Gleichzeitig muss die Trennung in `--check-config` bleiben: Infos und
|
||||||
|
Warnungen nach stdout, Fehler nach stderr.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.__main__ import _setup_logging, main
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fresh_root_logger():
|
||||||
|
"""Root-Logger wie beim echten Dienststart: ohne Handler.
|
||||||
|
|
||||||
|
`logging.basicConfig()` tut nichts, solange der Root-Logger Handler hat —
|
||||||
|
und pytest hängt seinen Capture-Handler dort ein, nachdem die Fixtures
|
||||||
|
gelaufen sind. Deshalb erst hier, direkt um den Aufruf herum, leeren.
|
||||||
|
"""
|
||||||
|
root = logging.getLogger()
|
||||||
|
saved_handlers, saved_level = root.handlers[:], root.level
|
||||||
|
root.handlers = []
|
||||||
|
try:
|
||||||
|
yield root
|
||||||
|
finally:
|
||||||
|
for h in root.handlers:
|
||||||
|
h.close()
|
||||||
|
root.handlers = saved_handlers
|
||||||
|
root.level = saved_level
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_logging_uses_stdout() -> None:
|
||||||
|
with _fresh_root_logger() as root:
|
||||||
|
_setup_logging("INFO")
|
||||||
|
streams = [h.stream for h in root.handlers
|
||||||
|
if isinstance(h, logging.StreamHandler)]
|
||||||
|
assert streams, "kein StreamHandler konfiguriert"
|
||||||
|
assert all(s is sys.stdout for s in streams)
|
||||||
|
assert not any(s is sys.stderr for s in streams)
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_records_land_on_stdout(monkeypatch) -> None:
|
||||||
|
"""Ein echter Log-Satz muss im stdout-Puffer stehen, nicht im stderr."""
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
monkeypatch.setattr(sys, "stdout", out)
|
||||||
|
monkeypatch.setattr(sys, "stderr", err)
|
||||||
|
|
||||||
|
with _fresh_root_logger():
|
||||||
|
_setup_logging("INFO")
|
||||||
|
logging.getLogger("pdf_ocr_hotfolder.test").warning("Testmeldung 4711")
|
||||||
|
|
||||||
|
assert "Testmeldung 4711" in out.getvalue()
|
||||||
|
assert "Testmeldung 4711" not in err.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_logging_respects_level() -> None:
|
||||||
|
with _fresh_root_logger() as root:
|
||||||
|
_setup_logging("WARNING")
|
||||||
|
assert root.level == logging.WARNING
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_logging_falls_back_on_garbage_level() -> None:
|
||||||
|
"""Ein Tippfehler in [logging].level darf den Start nicht verhindern."""
|
||||||
|
with _fresh_root_logger() as root:
|
||||||
|
_setup_logging("LAUT")
|
||||||
|
assert root.level == logging.INFO
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Trennung in --check-config ----------------
|
||||||
|
|
||||||
|
def _cfg(tmp_path: Path) -> Path:
|
||||||
|
cfg = tmp_path / "cfg.toml"
|
||||||
|
cfg.write_text(f"""
|
||||||
|
[paths]
|
||||||
|
incoming = "{tmp_path / 'in'}"
|
||||||
|
outgoing = "{tmp_path / 'out'}"
|
||||||
|
working = "{tmp_path / 'work'}"
|
||||||
|
error = "{tmp_path / 'err'}"
|
||||||
|
""")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_keeps_info_on_stdout(tmp_path: Path, monkeypatch,
|
||||||
|
capsys) -> None:
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(_cfg(tmp_path)),
|
||||||
|
"--check-config"])
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which",
|
||||||
|
return_value="/usr/bin/fake"):
|
||||||
|
assert main() == 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Config sauber" in captured.out
|
||||||
|
assert captured.err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_keeps_errors_on_stderr(tmp_path: Path, monkeypatch,
|
||||||
|
capsys) -> None:
|
||||||
|
cfg = tmp_path / "cfg.toml"
|
||||||
|
cfg.write_text('[ocr]\nlanguages = "deu"\n')
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg),
|
||||||
|
"--check-config"])
|
||||||
|
assert main() == 2
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "FEHLER" in captured.err
|
||||||
|
assert "FEHLER" not in captured.out
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Punkt 7: Ein toter watchdog-Observer muss auffallen.
|
||||||
|
|
||||||
|
Die Hauptschleife wartete nur auf `_stop` und fragte nie `is_alive()`. Stirbt
|
||||||
|
der Observer im Betrieb (erschöpftes inotify-Watch-Limit, ersetztes oder neu
|
||||||
|
gemountetes Verzeichnis), blieb die Unit `active (running)` und verarbeitete
|
||||||
|
nichts mehr — kein Log, keine Mail, niemand merkt es.
|
||||||
|
|
||||||
|
Jetzt endet der Dienst mit `EXIT_OBSERVER_DEAD` (3), damit systemd ihn per
|
||||||
|
`Restart=on-failure` neu startet. Bewusst nicht 2: die Unit setzt
|
||||||
|
`RestartPreventExitStatus=2` für Config-/Preflight-Fehler.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.service import EXIT_OBSERVER_DEAD, HotfolderService
|
||||||
|
|
||||||
|
|
||||||
|
class _NoSleepEvent(threading.Event):
|
||||||
|
"""Event, dessen wait() nicht schläft — hält die Tests schnell."""
|
||||||
|
|
||||||
|
def wait(self, timeout: float | None = None) -> bool: # noqa: D102
|
||||||
|
return self.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
class _StopAfter(_NoSleepEvent):
|
||||||
|
"""Setzt sich nach n Warteschritten selbst — simuliert ein SIGTERM."""
|
||||||
|
|
||||||
|
def __init__(self, n: int) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._left = n
|
||||||
|
|
||||||
|
def wait(self, timeout: float | None = None) -> bool:
|
||||||
|
self._left -= 1
|
||||||
|
if self._left <= 0:
|
||||||
|
self.set()
|
||||||
|
return self.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def _service(tmp_config, stop: threading.Event, alive) -> HotfolderService:
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
service._stop = stop
|
||||||
|
observer = MagicMock()
|
||||||
|
if isinstance(alive, list):
|
||||||
|
observer.is_alive.side_effect = alive
|
||||||
|
else:
|
||||||
|
observer.is_alive.return_value = alive
|
||||||
|
service._observer = observer
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_loop(service: HotfolderService) -> int:
|
||||||
|
try:
|
||||||
|
return service._wait_loop()
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _wait_loop ----------------
|
||||||
|
|
||||||
|
def test_dead_observer_returns_exit_code(tmp_config) -> None:
|
||||||
|
service = _service(tmp_config, _NoSleepEvent(), alive=False)
|
||||||
|
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
|
||||||
|
|
||||||
|
|
||||||
|
def test_exit_code_is_not_2(tmp_config) -> None:
|
||||||
|
"""2 ist für Config-/Preflight-Fehler reserviert (RestartPreventExitStatus)."""
|
||||||
|
assert EXIT_OBSERVER_DEAD != 2
|
||||||
|
assert EXIT_OBSERVER_DEAD != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_observer_logs_clearly(tmp_config, caplog) -> None:
|
||||||
|
service = _service(tmp_config, _NoSleepEvent(), alive=False)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="pdf_ocr_hotfolder.service"):
|
||||||
|
_wait_loop(service)
|
||||||
|
|
||||||
|
text = caplog.text
|
||||||
|
assert str(tmp_config.paths.incoming) in text
|
||||||
|
assert "KEINE neuen Dateien" in text
|
||||||
|
assert "inotify" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_observer_is_checked_repeatedly(tmp_config) -> None:
|
||||||
|
"""Der Observer wird nicht nur einmal beim Start geprüft."""
|
||||||
|
service = _service(tmp_config, _NoSleepEvent(),
|
||||||
|
alive=[True, True, True, False])
|
||||||
|
assert _wait_loop(service) == EXIT_OBSERVER_DEAD
|
||||||
|
assert service._observer.is_alive.call_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_stop_returns_zero(tmp_config) -> None:
|
||||||
|
"""SIGTERM/SIGINT bei lebendem Observer: kein Fehlalarm."""
|
||||||
|
service = _service(tmp_config, _StopAfter(3), alive=True)
|
||||||
|
assert _wait_loop(service) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_wins_over_dead_observer(tmp_config) -> None:
|
||||||
|
"""Beim planmäßigen Stoppen darf ein gestoppter Observer nichts auslösen.
|
||||||
|
|
||||||
|
`shutdown()` stoppt den Observer — läuft die Schleife danach noch einen
|
||||||
|
Takt, wäre das sonst ein Fehlalarm mit Exit 3 beim normalen Beenden.
|
||||||
|
"""
|
||||||
|
stop = _NoSleepEvent()
|
||||||
|
stop.set()
|
||||||
|
service = _service(tmp_config, stop, alive=False)
|
||||||
|
assert _wait_loop(service) == 0
|
||||||
|
service._observer.is_alive.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_observer_does_not_crash(tmp_config) -> None:
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
service._stop = _StopAfter(2)
|
||||||
|
service._observer = None
|
||||||
|
assert _wait_loop(service) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- run() / main() reichen den Code durch ----------------
|
||||||
|
|
||||||
|
def test_run_returns_exit_code(tmp_config) -> None:
|
||||||
|
observer = MagicMock()
|
||||||
|
observer.is_alive.return_value = False
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
service._stop = _NoSleepEvent()
|
||||||
|
try:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.check_preflight"):
|
||||||
|
assert service.run() == EXIT_OBSERVER_DEAD
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
# Auch im Fehlerfall wird sauber heruntergefahren
|
||||||
|
observer.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_returns_zero_on_regular_stop(tmp_config) -> None:
|
||||||
|
observer = MagicMock()
|
||||||
|
observer.is_alive.return_value = True
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
service._stop = _StopAfter(2)
|
||||||
|
try:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.Observer", return_value=observer), \
|
||||||
|
patch("pdf_ocr_hotfolder.service.check_preflight"):
|
||||||
|
assert service.run() == 0
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_passes_exit_code_through(tmp_path, tmp_config, monkeypatch) -> None:
|
||||||
|
from pdf_ocr_hotfolder.__main__ import main
|
||||||
|
|
||||||
|
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)])
|
||||||
|
with patch.object(HotfolderService, "run", return_value=EXIT_OBSERVER_DEAD):
|
||||||
|
assert main() == EXIT_OBSERVER_DEAD
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_returns_zero_on_regular_stop(tmp_path, tmp_config, monkeypatch) -> None:
|
||||||
|
from pdf_ocr_hotfolder.__main__ import main
|
||||||
|
|
||||||
|
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)])
|
||||||
|
with patch.object(HotfolderService, "run", return_value=0):
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_returns_zero_on_keyboard_interrupt(tmp_path, tmp_config,
|
||||||
|
monkeypatch) -> None:
|
||||||
|
from pdf_ocr_hotfolder.__main__ import main
|
||||||
|
|
||||||
|
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)])
|
||||||
|
with patch.object(HotfolderService, "run", side_effect=KeyboardInterrupt):
|
||||||
|
assert main() == 0
|
||||||
@@ -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
|
||||||
@@ -8,7 +8,7 @@ from pdf_ocr_hotfolder.processor import ProcessResult
|
|||||||
from pdf_ocr_hotfolder.service import HotfolderService
|
from pdf_ocr_hotfolder.service import HotfolderService
|
||||||
|
|
||||||
|
|
||||||
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
|
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||||
out = outgoing_dir / f"OCR_{src.name}"
|
out = outgoing_dir / f"OCR_{src.name}"
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
out.write_bytes(b"%PDF-1.4 ocr\n")
|
out.write_bytes(b"%PDF-1.4 ocr\n")
|
||||||
@@ -16,7 +16,7 @@ def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera
|
|||||||
return ProcessResult(src, out, True)
|
return ProcessResult(src, out, True)
|
||||||
|
|
||||||
|
|
||||||
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, ocr_cfg, vera_cfg):
|
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
|
||||||
error_dir.mkdir(parents=True, exist_ok=True)
|
error_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = error_dir / src.name
|
dest = error_dir / src.name
|
||||||
src.rename(dest)
|
src.rename(dest)
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Namens-Kollision in outgoing/ darf kein Ergebnis mehr überschreiben.
|
||||||
|
|
||||||
|
`process_pdf()` beendete mit `shutil.move(work_out, final_out)`. Lag dort
|
||||||
|
bereits eine Datei desselben Namens (Scanner liefert denselben Dateinamen ein
|
||||||
|
zweites Mal, oder das Vorgängerergebnis wurde noch nicht abgeholt), war das
|
||||||
|
ältere Ergebnis kommentarlos weg. Jetzt gilt derselbe Zeitstempel-Ausweg wie
|
||||||
|
im Archiv.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
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 _collision_free_path, process_pdf
|
||||||
|
|
||||||
|
OLD = b"%PDF-1.4 altes ergebnis\n"
|
||||||
|
ORIGINAL = b"%PDF-1.4 original\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||||
|
dst.write_bytes(b"%PDF-1.4 OCRed\n" + src.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(tmp_path: Path) -> dict:
|
||||||
|
dirs = {name: tmp_path / name
|
||||||
|
for name in ("incoming", "working", "outgoing", "error", "archive")}
|
||||||
|
for d in dirs.values():
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = dirs["incoming"] / "scan.pdf"
|
||||||
|
src.write_bytes(ORIGINAL)
|
||||||
|
return {"src": src, **dirs}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(env: dict, out_cfg: OutputConfig):
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr):
|
||||||
|
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=False),
|
||||||
|
output_cfg=out_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Kollision in outgoing/ ----------------
|
||||||
|
|
||||||
|
def test_existing_result_is_not_overwritten(tmp_path: Path) -> None:
|
||||||
|
"""Beide Dateien müssen hinterher existieren."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
|
||||||
|
|
||||||
|
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
# Altes Ergebnis unverändert
|
||||||
|
assert (env["outgoing"] / "OCR_scan.pdf").read_bytes() == OLD
|
||||||
|
# Neues Ergebnis unter Zeitstempel-Namen daneben
|
||||||
|
neu = [p for p in env["outgoing"].glob("OCR_scan_*.pdf")]
|
||||||
|
assert len(neu) == 1
|
||||||
|
assert neu[0].read_bytes() == b"%PDF-1.4 OCRed\n" + ORIGINAL
|
||||||
|
assert len(list(env["outgoing"].iterdir())) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_output_points_to_written_file(tmp_path: Path) -> None:
|
||||||
|
"""ProcessResult.output muss den TATSÄCHLICH geschriebenen Pfad tragen.
|
||||||
|
|
||||||
|
Sonst melden Uploads und die E-Mail-Benachrichtigung die falsche (nämlich
|
||||||
|
die fremde, ältere) Datei.
|
||||||
|
"""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
|
||||||
|
|
||||||
|
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
assert result.output.exists()
|
||||||
|
assert result.output.name != "OCR_scan.pdf"
|
||||||
|
assert result.output.parent == env["outgoing"]
|
||||||
|
assert result.output.read_bytes() != OLD
|
||||||
|
|
||||||
|
|
||||||
|
def test_collision_logs_warning_with_both_names(tmp_path: Path, caplog) -> None:
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
|
||||||
|
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
text = caplog.text
|
||||||
|
assert "OCR_scan.pdf" in text
|
||||||
|
assert result.output.name in text
|
||||||
|
assert "überschrieben" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_collision_keeps_plain_name(tmp_path: Path, caplog) -> None:
|
||||||
|
"""Ohne Kollision bleibt alles wie bisher — kein Suffix, keine Warnung."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.processor"):
|
||||||
|
result = _run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
assert result.output == env["outgoing"] / "OCR_scan.pdf"
|
||||||
|
assert result.output.exists()
|
||||||
|
assert "überschrieben" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_collision_with_name_mode_none(tmp_path: Path) -> None:
|
||||||
|
"""name_mode='none': Ergebnis heißt wie das Original — Kollision ist dort
|
||||||
|
der Normalfall, nicht die Ausnahme."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
(env["outgoing"] / "scan.pdf").write_bytes(OLD)
|
||||||
|
|
||||||
|
result = _run(env, OutputConfig(name_mode="none", name_tag="",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert (env["outgoing"] / "scan.pdf").read_bytes() == OLD
|
||||||
|
assert result.output.name.startswith("scan_")
|
||||||
|
assert result.output.suffix == ".pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_original_is_still_disposed_after_collision(tmp_path: Path) -> None:
|
||||||
|
"""Der Ausweichname darf die Entsorgung des Originals nicht aushebeln."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
(env["outgoing"] / "OCR_scan.pdf").write_bytes(OLD)
|
||||||
|
|
||||||
|
_run(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="archive",
|
||||||
|
archive_dir=str(env["archive"])))
|
||||||
|
|
||||||
|
assert list(env["working"].iterdir()) == []
|
||||||
|
assert (env["archive"] / "scan.pdf").read_bytes() == ORIGINAL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- _collision_free_path ----------------
|
||||||
|
|
||||||
|
def test_collision_free_path_passes_through_free_name(tmp_path: Path) -> None:
|
||||||
|
dest = tmp_path / "frei.pdf"
|
||||||
|
assert _collision_free_path(dest) == dest
|
||||||
|
|
||||||
|
|
||||||
|
def test_collision_free_path_appends_timestamp(tmp_path: Path) -> None:
|
||||||
|
dest = tmp_path / "belegt.pdf"
|
||||||
|
dest.write_bytes(b"x")
|
||||||
|
out = _collision_free_path(dest)
|
||||||
|
assert out != dest
|
||||||
|
assert out.name.startswith("belegt_")
|
||||||
|
assert out.suffix == ".pdf"
|
||||||
|
assert not out.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collision_free_path_counts_up_within_same_second(tmp_path: Path) -> None:
|
||||||
|
"""Zwei Ergebnisse in derselben Sekunde (mehrere Worker) kollidieren sonst
|
||||||
|
erneut — und der move überschriebe wieder still."""
|
||||||
|
dest = tmp_path / "belegt.pdf"
|
||||||
|
dest.write_bytes(b"x")
|
||||||
|
|
||||||
|
first = _collision_free_path(dest)
|
||||||
|
first.write_bytes(b"y")
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.datetime") as dt:
|
||||||
|
# Zeitstempel einfrieren: erzwingt denselben Namen wie `first`
|
||||||
|
dt.now.return_value.strftime.return_value = first.stem.split("_", 1)[1]
|
||||||
|
second = _collision_free_path(dest)
|
||||||
|
|
||||||
|
assert second != first
|
||||||
|
assert not second.exists()
|
||||||
|
assert second.suffix == ".pdf"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["ohne_extension", "zwei.punkte.pdf"])
|
||||||
|
def test_collision_free_path_keeps_extension(tmp_path: Path, name: str) -> None:
|
||||||
|
dest = tmp_path / name
|
||||||
|
dest.write_bytes(b"x")
|
||||||
|
out = _collision_free_path(dest)
|
||||||
|
assert out.suffix == dest.suffix
|
||||||
|
assert out.name != dest.name
|
||||||
@@ -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,111 @@
|
|||||||
|
"""Punkt 5: Relative Pfade in der Config sind ein Fehler, keine stille Annahme.
|
||||||
|
|
||||||
|
`incoming = "in"` legte das Verzeichnis unter dem WorkingDirectory des
|
||||||
|
Dienstes an (/opt/pdf-ocr-hotfolder/in) statt dort, wo der Scanner ablegt —
|
||||||
|
ohne Warnung. Der Dienst schaute dann dauerhaft ins Leere.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.config import ConfigError, load_config
|
||||||
|
|
||||||
|
_ABS = {
|
||||||
|
"incoming": "/var/lib/pdf-ocr-hotfolder/incoming",
|
||||||
|
"outgoing": "/var/lib/pdf-ocr-hotfolder/outgoing",
|
||||||
|
"working": "/var/lib/pdf-ocr-hotfolder/working",
|
||||||
|
"error": "/var/lib/pdf-ocr-hotfolder/error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, paths: dict[str, str], extra: str = "") -> Path:
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
zeilen = "\n".join(f'{k} = "{v}"' for k, v in paths.items())
|
||||||
|
cfg.write_text(f"[paths]\n{zeilen}\n{extra}")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", list(_ABS))
|
||||||
|
def test_relative_path_key_is_rejected(tmp_path: Path, key: str) -> None:
|
||||||
|
paths = dict(_ABS)
|
||||||
|
paths[key] = "in"
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
load_config(_write(tmp_path, paths))
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert key in msg
|
||||||
|
assert "absolut" in msg.lower()
|
||||||
|
assert "WorkingDirectory" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_dot_relative_path_is_rejected(tmp_path: Path) -> None:
|
||||||
|
"""Auch './scans' und '../scans' sind relativ."""
|
||||||
|
paths = dict(_ABS, incoming="./scans")
|
||||||
|
with pytest.raises(ConfigError, match="incoming"):
|
||||||
|
load_config(_write(tmp_path, paths))
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_paths_load(tmp_path: Path) -> None:
|
||||||
|
cfg = load_config(_write(tmp_path, _ABS))
|
||||||
|
assert cfg.paths.incoming == Path(_ABS["incoming"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_archive_dir_is_rejected(tmp_path: Path) -> None:
|
||||||
|
cfg = _write(tmp_path, _ABS,
|
||||||
|
'\n[output]\noriginal_on_success = "archive"\n'
|
||||||
|
'archive_dir = "archiv"\n')
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
load_config(cfg)
|
||||||
|
assert "archive_dir" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_upload_target_is_rejected(tmp_path: Path) -> None:
|
||||||
|
cfg = _write(tmp_path, _ABS,
|
||||||
|
'\n[upload.folder]\nenabled = true\ntarget = "fertig"\n')
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
load_config(cfg)
|
||||||
|
assert "target" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_archive_dir_and_target_are_fine(tmp_path: Path) -> None:
|
||||||
|
"""Leer heißt 'nicht gesetzt' und bleibt erlaubt (das ist der Default)."""
|
||||||
|
cfg = load_config(_write(tmp_path, _ABS,
|
||||||
|
'\n[output]\narchive_dir = ""\n'
|
||||||
|
'\n[upload.folder]\ntarget = ""\n'))
|
||||||
|
assert cfg.output.archive_dir == ""
|
||||||
|
assert cfg.folder.target == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_archive_dir_and_target_load(tmp_path: Path) -> None:
|
||||||
|
cfg = load_config(_write(tmp_path, _ABS,
|
||||||
|
'\n[output]\narchive_dir = "/srv/archiv"\n'
|
||||||
|
'\n[upload.folder]\ntarget = "/srv/fertig"\n'))
|
||||||
|
assert cfg.output.archive_dir == "/srv/archiv"
|
||||||
|
assert cfg.folder.target == "/srv/fertig"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- CLI ----------------
|
||||||
|
|
||||||
|
def test_main_returns_2_on_relative_path(tmp_path: Path, monkeypatch, capsys) -> None:
|
||||||
|
cfg = _write(tmp_path, dict(_ABS, incoming="in"))
|
||||||
|
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 "incoming" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_returns_2_on_relative_path(tmp_path: Path, monkeypatch,
|
||||||
|
capsys) -> None:
|
||||||
|
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
|
||||||
|
|
||||||
|
cfg = _write(tmp_path, dict(_ABS, outgoing="raus"))
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg),
|
||||||
|
"--check-config"])
|
||||||
|
assert main() == CHECK_ERROR
|
||||||
|
assert "outgoing" in capsys.readouterr().err
|
||||||
@@ -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,110 @@
|
|||||||
|
"""Kaputtes TOML beim NORMALEN Dienststart (nicht nur bei --check-config).
|
||||||
|
|
||||||
|
`--check-config` fing `tomllib.TOMLDecodeError` schon immer ab, der Startpfad
|
||||||
|
in `main()` aber nicht: ein Tippfehler in der Instanz-Config ergab einen
|
||||||
|
nackten Traceback. Zusammen mit `Restart=on-failure` in der Unit lief die
|
||||||
|
Instanz damit in einen Neustart-Loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pdf_ocr_hotfolder.__main__ import main
|
||||||
|
|
||||||
|
# Verschiedene Arten, eine TOML kaputt zu machen
|
||||||
|
BROKEN_TOMLS = [
|
||||||
|
pytest.param('[paths]\nincoming = "/tmp/in\n', id="unbalancierte-quotes"),
|
||||||
|
pytest.param("[paths\nincoming = \n", id="unvollstaendige-sektion"),
|
||||||
|
pytest.param('[paths]\nincoming "/tmp/in"\n', id="fehlendes-gleich"),
|
||||||
|
pytest.param('[paths]\nincoming = "/a"\n[paths]\nincoming = "/b"\n',
|
||||||
|
id="doppelte-sektion"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(monkeypatch, cfg: Path, *extra_args: str) -> int:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg), *extra_args],
|
||||||
|
)
|
||||||
|
return main()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", BROKEN_TOMLS)
|
||||||
|
def test_broken_toml_returns_2_on_normal_start(
|
||||||
|
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
|
||||||
|
"""Dienststart ohne --once: Exit 2, keine Exception nach außen."""
|
||||||
|
cfg = tmp_path / "instanz.toml"
|
||||||
|
cfg.write_text(content)
|
||||||
|
|
||||||
|
assert _run(monkeypatch, cfg) == 2
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "FEHLER" in err
|
||||||
|
assert "TOML" in err
|
||||||
|
assert str(cfg) in err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", BROKEN_TOMLS)
|
||||||
|
def test_broken_toml_returns_2_with_once(
|
||||||
|
tmp_path: Path, monkeypatch, capsys, content: str) -> None:
|
||||||
|
"""Auch --once darf nicht mit Traceback aussteigen."""
|
||||||
|
cfg = tmp_path / "instanz.toml"
|
||||||
|
cfg.write_text(content)
|
||||||
|
|
||||||
|
assert _run(monkeypatch, cfg, "--once") == 2
|
||||||
|
assert "TOML" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_toml_message_names_position(
|
||||||
|
tmp_path: Path, monkeypatch, capsys) -> None:
|
||||||
|
"""Die Meldung muss dem Kunden sagen, WO es klemmt.
|
||||||
|
|
||||||
|
Zeile/Spalte kommen ab Python 3.14 aus den Exception-Attributen, darunter
|
||||||
|
stecken sie im Meldungstext von tomllib. Beide Wege müssen in der Ausgabe
|
||||||
|
landen.
|
||||||
|
"""
|
||||||
|
cfg = tmp_path / "instanz.toml"
|
||||||
|
cfg.write_text('[paths]\nincoming = "/tmp/in"\nworking = "/tmp/w\n')
|
||||||
|
|
||||||
|
assert _run(monkeypatch, cfg) == 2
|
||||||
|
|
||||||
|
err = capsys.readouterr().err.lower()
|
||||||
|
assert "zeile 3" in err or "line 3" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_toml_start_and_check_config_agree(
|
||||||
|
tmp_path: Path, monkeypatch, capsys) -> None:
|
||||||
|
"""Startpfad und --check-config liefern denselben Exit-Code und Text."""
|
||||||
|
cfg = tmp_path / "instanz.toml"
|
||||||
|
cfg.write_text('[paths]\nincoming = "/tmp/in\n')
|
||||||
|
|
||||||
|
assert _run(monkeypatch, cfg) == 2
|
||||||
|
start_err = capsys.readouterr().err
|
||||||
|
|
||||||
|
assert _run(monkeypatch, cfg, "--check-config") == 2
|
||||||
|
check_err = capsys.readouterr().err
|
||||||
|
|
||||||
|
marker = f"{cfg} ist kein gültiges TOML"
|
||||||
|
assert marker in start_err
|
||||||
|
assert marker in check_err
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_config_returns_2(tmp_path: Path, monkeypatch, capsys) -> None:
|
||||||
|
"""Config existiert, ist aber nicht lesbar → Exit 2 statt Traceback."""
|
||||||
|
cfg = tmp_path / "instanz.toml"
|
||||||
|
cfg.write_text('[paths]\nincoming = "/tmp/in"\n')
|
||||||
|
cfg.chmod(0o000)
|
||||||
|
try:
|
||||||
|
# Als root greifen Dateirechte nicht — dann ist der Test gegenstandslos
|
||||||
|
try:
|
||||||
|
cfg.open("rb").close()
|
||||||
|
pytest.skip("Datei trotz chmod 000 lesbar (root?)")
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
assert _run(monkeypatch, cfg) == 2
|
||||||
|
assert "nicht lesbar" in capsys.readouterr().err
|
||||||
|
finally:
|
||||||
|
cfg.chmod(0o644)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Punkt 3: kein stilles Überschreiben im Ziel ----------------
|
||||||
|
|
||||||
|
def test_upload_folder_does_not_overwrite_existing(tmp_path: Path) -> None:
|
||||||
|
"""Gleichnamige Datei im abweichenden target wurde bisher still ersetzt."""
|
||||||
|
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||||
|
src.parent.mkdir()
|
||||||
|
src.write_bytes(b"%PDF-1.4 neu\n")
|
||||||
|
target = tmp_path / "ziel"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
|
||||||
|
|
||||||
|
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 alt\n"
|
||||||
|
neu = list(target.glob("OCR_scan_*.pdf"))
|
||||||
|
assert len(neu) == 1
|
||||||
|
assert neu[0].read_bytes() == b"%PDF-1.4 neu\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_folder_logs_warning_on_collision(tmp_path: Path, caplog) -> None:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||||
|
src.parent.mkdir()
|
||||||
|
src.write_bytes(b"%PDF-1.4 neu\n")
|
||||||
|
target = tmp_path / "ziel"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "OCR_scan.pdf").write_bytes(b"%PDF-1.4 alt\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
|
||||||
|
upload_folder(src, FolderUpload(enabled=True, target=str(target)),
|
||||||
|
tmp_path / "out")
|
||||||
|
|
||||||
|
assert "OCR_scan.pdf" in caplog.text
|
||||||
|
assert "überschrieben" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_folder_without_collision_logs_nothing(tmp_path: Path, caplog) -> None:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
src = tmp_path / "out" / "OCR_scan.pdf"
|
||||||
|
src.parent.mkdir()
|
||||||
|
src.write_bytes(b"%PDF-1.4\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="pdf_ocr_hotfolder.uploaders"):
|
||||||
|
upload_folder(src, FolderUpload(enabled=True, target=str(tmp_path / "ziel")),
|
||||||
|
tmp_path / "out")
|
||||||
|
|
||||||
|
assert "überschrieben" not in caplog.text
|
||||||
|
assert (tmp_path / "ziel" / "OCR_scan.pdf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_folder_self_target_is_not_renamed(tmp_path: Path) -> None:
|
||||||
|
"""Default (leeres target -> outgoing/): der resolve()-Kurzschluss greift.
|
||||||
|
|
||||||
|
Ohne ihn würde die Datei hier gegen sich selbst kollidieren und eine
|
||||||
|
Zeitstempel-Kopie neben sich selbst erzeugen.
|
||||||
|
"""
|
||||||
|
out = tmp_path / "out"
|
||||||
|
out.mkdir()
|
||||||
|
src = out / "OCR_scan.pdf"
|
||||||
|
src.write_bytes(b"%PDF-1.4\n")
|
||||||
|
|
||||||
|
assert upload_folder(src, FolderUpload(enabled=True, target=""), out) is True
|
||||||
|
assert list(out.iterdir()) == [src]
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""Punkt 1: veraPDF-Binary wird geprüft — sonst vernichtet es die Originale.
|
||||||
|
|
||||||
|
Mit `[verapdf].enabled = true` und falschem Pfad lieferte `run_verapdf()` für
|
||||||
|
JEDE Datei False: OCR-Ergebnis nach error/, Original laut
|
||||||
|
`original_on_success = "delete"` gelöscht. Ein Tippfehler im Pfad vernichtete
|
||||||
|
so Scan für Scan die Vorlagen, während die Unit als "läuft" dastand.
|
||||||
|
|
||||||
|
Zwei Absicherungen:
|
||||||
|
1. Der Preflight lässt den Dienst gar nicht erst starten (Exit 2).
|
||||||
|
2. `run_verapdf()` unterscheidet "nicht konform" (False) von "nicht
|
||||||
|
aufrufbar" (`VeraPdfUnavailable`) — Letzteres entsorgt nichts.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
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 (
|
||||||
|
VeraPdfUnavailable,
|
||||||
|
process_pdf,
|
||||||
|
resolve_verapdf_binary,
|
||||||
|
run_verapdf,
|
||||||
|
)
|
||||||
|
from pdf_ocr_hotfolder.service import (
|
||||||
|
HotfolderService,
|
||||||
|
PreflightError,
|
||||||
|
check_preflight,
|
||||||
|
check_verapdf_binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
ORIGINAL = b"%PDF-1.4 original\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _executable(tmp_path: Path, name: str = "verapdf") -> Path:
|
||||||
|
"""Legt eine echte, ausführbare Datei an (wird nie wirklich aufgerufen)."""
|
||||||
|
b = tmp_path / name
|
||||||
|
b.write_text("#!/bin/sh\nexit 0\n")
|
||||||
|
b.chmod(0o755)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- check_verapdf_binary ----------------
|
||||||
|
|
||||||
|
def test_disabled_verapdf_ignores_binary() -> None:
|
||||||
|
"""Solange veraPDF aus ist, darf ein unsinniger Pfad nichts blockieren."""
|
||||||
|
check_verapdf_binary(False, "/gibt/es/nicht/verapdf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_executable_passes(tmp_path: Path) -> None:
|
||||||
|
check_verapdf_binary(True, str(_executable(tmp_path)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_binary_raises(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(PreflightError) as exc:
|
||||||
|
check_verapdf_binary(True, str(tmp_path / "tippfehler"))
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "verapdf" in msg.lower()
|
||||||
|
assert "tippfehler" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_executable_binary_raises(tmp_path: Path) -> None:
|
||||||
|
"""Vorhanden, aber ohne x-Bit: genauso tödlich wie gar nicht vorhanden."""
|
||||||
|
b = tmp_path / "verapdf"
|
||||||
|
b.write_text("#!/bin/sh\n")
|
||||||
|
b.chmod(0o644)
|
||||||
|
with pytest.raises(PreflightError, match="ausführbar"):
|
||||||
|
check_verapdf_binary(True, str(b))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_binary_raises() -> None:
|
||||||
|
with pytest.raises(PreflightError, match=r"\[verapdf\].binary"):
|
||||||
|
check_verapdf_binary(True, "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_finds_binary_in_path(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
"""Ein nackter Name wird im PATH gesucht, nicht nur ein absoluter Pfad."""
|
||||||
|
_executable(tmp_path, "verapdf")
|
||||||
|
monkeypatch.setenv("PATH", str(tmp_path))
|
||||||
|
assert resolve_verapdf_binary("verapdf") == str(tmp_path / "verapdf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_none_for_empty() -> None:
|
||||||
|
assert resolve_verapdf_binary("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- check_preflight reicht die veraPDF-Prüfung durch ----------------
|
||||||
|
|
||||||
|
def test_check_preflight_checks_verapdf(tmp_path: Path) -> None:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||||
|
with pytest.raises(PreflightError, match="verapdf"):
|
||||||
|
check_preflight(verapdf_enabled=True,
|
||||||
|
verapdf_binary=str(tmp_path / "weg"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_preflight_ok_with_verapdf(tmp_path: Path) -> None:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||||
|
check_preflight(verapdf_enabled=True,
|
||||||
|
verapdf_binary=str(_executable(tmp_path)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_aborts_on_broken_verapdf(tmp_config, tmp_path: Path) -> None:
|
||||||
|
"""Der Dienst startet nicht — statt Datei für Datei Originale zu löschen."""
|
||||||
|
tmp_config.verapdf = VeraPdfConfig(enabled=True,
|
||||||
|
binary=str(tmp_path / "gibtsnicht"))
|
||||||
|
service = HotfolderService(tmp_config)
|
||||||
|
try:
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which",
|
||||||
|
return_value="/usr/bin/fake"):
|
||||||
|
with pytest.raises(PreflightError, match="verapdf"):
|
||||||
|
service.run_once()
|
||||||
|
finally:
|
||||||
|
service._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_returns_2_for_broken_verapdf(tmp_path, tmp_config,
|
||||||
|
monkeypatch, capsys) -> None:
|
||||||
|
"""--check-config meldet Exit 2 (der Updater wertet das aus)."""
|
||||||
|
from pdf_ocr_hotfolder.__main__ import CHECK_ERROR, main
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
[verapdf]
|
||||||
|
enabled = true
|
||||||
|
binary = "{tmp_path / 'nicht-da'}"
|
||||||
|
""")
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||||
|
"--check-config"])
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||||
|
assert main() == CHECK_ERROR
|
||||||
|
assert "nicht-da" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_config_ok_with_working_verapdf(tmp_path, tmp_config,
|
||||||
|
monkeypatch) -> None:
|
||||||
|
from pdf_ocr_hotfolder.__main__ import CHECK_OK, main
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
[verapdf]
|
||||||
|
enabled = true
|
||||||
|
binary = "{_executable(tmp_path)}"
|
||||||
|
""")
|
||||||
|
monkeypatch.setattr(sys, "argv",
|
||||||
|
["pdf-ocr-hotfolder", "--config", str(cfg_file),
|
||||||
|
"--check-config"])
|
||||||
|
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
|
||||||
|
assert main() == CHECK_OK
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- run_verapdf: Urteil vs. Nicht-Aufrufbarkeit ----------------
|
||||||
|
|
||||||
|
def _completed(returncode: int, stdout: str = "", stderr: str = ""):
|
||||||
|
return subprocess.CompletedProcess([], returncode, stdout, stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_pass(tmp_path: Path) -> None:
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
|
||||||
|
return_value=_completed(0, "PASS /tmp/x.pdf\n")):
|
||||||
|
assert run_verapdf(tmp_path / "x.pdf", cfg) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_fail_is_a_verdict(tmp_path: Path) -> None:
|
||||||
|
"""Echtes FAIL bleibt False — das ist ein inhaltliches Urteil."""
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
|
||||||
|
return_value=_completed(1, "FAIL /tmp/x.pdf\n")):
|
||||||
|
assert run_verapdf(tmp_path / "x.pdf", cfg) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_missing_binary_raises(tmp_path: Path) -> None:
|
||||||
|
"""Fehlendes Programm ist KEIN FAIL mehr, sondern ein Fehler."""
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(tmp_path / "weg"))
|
||||||
|
with pytest.raises(VeraPdfUnavailable, match="weg"):
|
||||||
|
run_verapdf(tmp_path / "x.pdf", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_timeout_raises(tmp_path: Path) -> None:
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("verapdf", 300)):
|
||||||
|
with pytest.raises(VeraPdfUnavailable, match="nicht geantwortet"):
|
||||||
|
run_verapdf(tmp_path / "x.pdf", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_oserror_raises(tmp_path: Path) -> None:
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
|
||||||
|
side_effect=OSError("Exec format error")):
|
||||||
|
with pytest.raises(VeraPdfUnavailable, match="nicht startbar"):
|
||||||
|
run_verapdf(tmp_path / "x.pdf", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_without_verdict_raises(tmp_path: Path) -> None:
|
||||||
|
"""Startet der Wrapper nicht durch (fehlendes Java), steht kein Urteil da.
|
||||||
|
|
||||||
|
Exit != 0 ohne PASS/FAIL in der Ausgabe darf nicht als "nicht konform"
|
||||||
|
durchgehen — genau so würde der Original-Löschpfad wieder aufgehen.
|
||||||
|
"""
|
||||||
|
cfg = VeraPdfConfig(enabled=True, binary=str(_executable(tmp_path)))
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.subprocess.run",
|
||||||
|
return_value=_completed(127, "", "java: command not found")):
|
||||||
|
with pytest.raises(VeraPdfUnavailable, match="kein Urteil"):
|
||||||
|
run_verapdf(tmp_path / "x.pdf", cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_verapdf_disabled_returns_true(tmp_path: Path) -> None:
|
||||||
|
assert run_verapdf(tmp_path / "x.pdf", VeraPdfConfig(enabled=False)) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- process_pdf: nicht aufrufbares veraPDF entsorgt nichts ----------------
|
||||||
|
|
||||||
|
def _fake_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
|
||||||
|
dst.write_bytes(b"%PDF-1.4 OCRed\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(tmp_path: Path) -> dict:
|
||||||
|
dirs = {name: tmp_path / name
|
||||||
|
for name in ("incoming", "working", "outgoing", "error", "archive")}
|
||||||
|
for d in dirs.values():
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = dirs["incoming"] / "scan.pdf"
|
||||||
|
src.write_bytes(ORIGINAL)
|
||||||
|
return {"src": src, **dirs}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_unavailable(env: dict, out_cfg: OutputConfig):
|
||||||
|
with patch("pdf_ocr_hotfolder.processor.run_ocr", side_effect=_fake_ocr), \
|
||||||
|
patch("pdf_ocr_hotfolder.processor.run_verapdf",
|
||||||
|
side_effect=VeraPdfUnavailable("Binary weg")):
|
||||||
|
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_unavailable_verapdf_keeps_original_despite_delete(tmp_path: Path) -> None:
|
||||||
|
"""Der gefährliche Fall: original_on_success='delete' darf nicht greifen."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
result = _run_unavailable(env, OutputConfig(name_mode="prefix",
|
||||||
|
name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
# Original ist NICHT weg, sondern in error/ gesichert
|
||||||
|
gesichert = env["error"] / "scan.pdf"
|
||||||
|
assert gesichert.exists()
|
||||||
|
assert gesichert.read_bytes() == ORIGINAL
|
||||||
|
assert not (env["working"] / "scan.pdf").exists()
|
||||||
|
# Kein fertiges Ergebnis in outgoing/
|
||||||
|
assert list(env["outgoing"].iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_verapdf_result_is_not_a_fail_verdict(tmp_path: Path) -> None:
|
||||||
|
"""verapdf_passed bleibt None: es gab kein Urteil, nur einen Fehler."""
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
result = _run_unavailable(env, OutputConfig(original_on_success="delete"))
|
||||||
|
assert result.verapdf_passed is None
|
||||||
|
assert "veraPDF" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_verapdf_also_keeps_ocr_result(tmp_path: Path) -> None:
|
||||||
|
env = _prepare(tmp_path)
|
||||||
|
_run_unavailable(env, OutputConfig(name_mode="prefix", name_tag="OCR_",
|
||||||
|
original_on_success="delete"))
|
||||||
|
assert (env["error"] / "__ocr_OCR_scan.pdf").exists()
|
||||||
|
assert list(env["working"].iterdir()) == []
|
||||||
Reference in New Issue
Block a user