5 Commits

Author SHA1 Message Date
techadmin cbdc9d6664 Fix Issues #4, #5, #6: LXC-Kompatibilität, WorkingDirectory, GS-Backports
- #4: LXC/Container Drop-in (lxc-compat.conf) deaktiviert systemd-Hardening;
  Installer erkennt Container automatisch und bietet Drop-in an
- #5: WorkingDirectory=/opt/pdf-ocr-hotfolder in Template-Unit ergänzt
- #6: Installer bietet auf Debian 12 bei betroffenen GS-Versionen
  automatisch bookworm-backports Upgrade an (statt nur Warnung)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:41:54 +02:00
techadmin a23a3968ef feat: konfigurierbarer Dateiname + Archiv-Modus für Original (v0.3.0)
Neue [output]-Section:
- name_mode: prefix | suffix | none (suffix wird vor Extension eingefügt)
- name_tag: verbatim einfügbarer String
- original_on_success: delete | archive
- archive_dir mit Kollisions-Schutz (Timestamp-Suffix)

20 neue Tests (50 insgesamt, alle grün).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:32:41 +02:00
techadmin 9cdc9ae443 fix: Ghostscript 10.0.0-10.02.0 PDF/A-Bug abfangen (v0.2.2)
- config.example.toml: pdfa_level="" als sicherer Default
- check_preflight(pdfa_level) erkennt betroffene GS-Versionen und bricht ab
- install.sh warnt bei betroffenen GS-Versionen
- 19 neue Tests (parametrisiert über Versions-Matrix)

Closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 07:29:18 +02:00
techadmin 6f7cadfc63 fix: Preflight-Check und Exit-Code in --once Modus (v0.2.1)
- #1: check_preflight() prüft beim Start tesseract + gs, wirft
  PreflightError. CLI endet mit Exit 2 statt grün zu bleiben.
- #2: run_once() gibt Anzahl fehlgeschlagener PDFs zurück, CLI
  endet mit Exit 1 wenn mindestens eine Datei scheiterte.
- pytest-Suite mit 11 Tests für beide Szenarien
- ocrmypdf-Import lazy in processor.py (Tests ohne ocrmypdf möglich)

Closes #1, #2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 07:24:00 +02:00
techadmin 985a33d3f9 feat: Multi-Instanz-Support via systemd Template-Unit (v0.2.0)
- pdf-ocr-hotfolder@<name>.service mit Config pro Instanz
- install.sh als Instanz-Manager: erkennt bestehende, fragt nach weiteren
- Optional eigener Service-User pro Instanz (systemd drop-in)
- update.sh stoppt/startet alle aktiven Instanzen automatisch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 14:31:58 +02:00
21 changed files with 1239 additions and 186 deletions
+1
View File
@@ -4,6 +4,7 @@ __pycache__/
venv/
env/
.venv/
.pytest_cache/
*.egg-info/
build/
dist/
+47 -14
View File
@@ -1,8 +1,8 @@
# AI Agent Briefing — PDF OCR Hotfolder
**Zuletzt aktualisiert:** 2026-04-08
**Version:** 0.1.0
**Status:** Initiale Implementation, nicht produktiv getestet
**Version:** 0.2.0
**Status:** Multi-Instanz-Support, nicht produktiv getestet
## 🎯 Projektziel
@@ -20,7 +20,7 @@ pdf-ocr-hotfolder/
│ ├── processor.py # ocrmypdf + veraPDF
│ └── uploaders.py # folder, nextcloud (WebDAV), sftp, email
├── systemd/
│ └── pdf-ocr-hotfolder.service # Template (Platzhalter __SERVICE_USER__/__SERVICE_GROUP__)
│ └── pdf-ocr-hotfolder@.service # systemd Template-Unit (Instanz = %i)
├── config.example.toml
├── install.sh # Interaktiver Installer
├── update.sh # Update aus Repo
@@ -43,25 +43,58 @@ pdf-ocr-hotfolder/
| Email | `smtplib` (stdlib) |
| Service | systemd |
## 🖥️ Installations-Layout
## 🖥️ Installations-Layout (Multi-Instanz)
| Pfad | Inhalt |
|------|--------|
| `/opt/pdf-ocr-hotfolder/` | Code + venv (`venv/bin/python`) |
| `/etc/pdf-ocr-hotfolder/config.toml` | Konfiguration (mode 640, root:<service-group>) |
| `/var/lib/pdf-ocr-hotfolder/{incoming,working,outgoing,error}/` | Datenverzeichnisse |
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
| `/etc/systemd/system/pdf-ocr-hotfolder.service` | systemd-Unit |
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/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@<instanz>.service.d/user.conf` | Drop-in für abweichenden User (optional) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/{incoming,working,outgoing,error}/` | Daten pro Instanz |
| `/var/log/pdf-ocr-hotfolder/` | Logs |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
## 👤 Service-User
Der Installer fragt interaktiv:
1. Username (default `pdfocr`)
2. Falls User existiert (lokal oder AD via SSSD/Winbind): wird übernommen, primäre Gruppe automatisch erkannt
3. Falls nicht: Frage nach lokaler Anlage als System-User
- Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden)
- Beim Anlegen einer Instanz fragt der Installer nach dem Service-User (default `pdfocr`)
- Wird ein **abweichender** User gewählt, wird ein systemd-Drop-in erstellt (`pdf-ocr-hotfolder@<instanz>.service.d/user.conf`) mit `User=/Group=` Override
- 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
**Wichtig:** Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — funktioniert transparent.
## 🗂️ Instanz-Management
`install.sh` ist gleichzeitig **Installer und Instanz-Manager**:
- Erster Lauf: Basis-Install + erste Instanz anlegen (Pflicht)
- Folgender Lauf: Basis-Install wird übersprungen, bestehende Instanzen werden gelistet, weitere Instanzen können ergänzt werden
- Eingaben pro Instanz: Name (`[a-z0-9-]+`), Basis-Pfad (default `/var/lib/pdf-ocr-hotfolder/<name>`), Service-User
- `config.toml` wird aus `config.example.toml` mit sed-substituierten Pfaden generiert
- Instanz wird sofort `enable --now` gestartet
Manuelles Löschen einer Instanz:
```bash
systemctl disable --now pdf-ocr-hotfolder@<name>
rm /etc/pdf-ocr-hotfolder/<name>.toml
rm -rf /etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d
systemctl daemon-reload
# Datenverzeichnis /var/lib/pdf-ocr-hotfolder/<name> manuell aufräumen
```
## 🔄 Update-Verhalten
`update.sh`:
1. Ermittelt alle aktiven `pdf-ocr-hotfolder@*.service` Units
2. Stoppt diese
3. Backup nach `/var/backups/pdf-ocr-hotfolder/`
4. Kopiert Code + requirements + VERSION + config.example aus dem Repo
5. `pip install --upgrade` im venv
6. Aktualisiert Template-Unit + `daemon-reload`
7. Startet alle zuvor aktiven Instanzen wieder
8. Exit 1 wenn eine Instanz nicht mehr hochkommt
Config-Dateien werden **nie** überschrieben.
## 🔄 Verarbeitungs-Flow
+65
View File
@@ -1,5 +1,70 @@
# Changelog
## [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.010.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.010.02.0 (Debian 12 default) zerschießen OCR mit PDF/A + `skip_text=true`.
- `config.example.toml`: `pdfa_level = ""` als sicherer Default
- Runtime-Preflight: Prüft `gs --version` wenn `pdfa_level` gesetzt ist, bricht mit klarer Fehlermeldung ab
- `install.sh`: warnt bei betroffenen GS-Versionen mit Upgrade-Hinweis auf bookworm-backports
### Added
- `is_ghostscript_broken()` / `detect_ghostscript_version()` in `pdf_ocr_hotfolder.service`
- 19 weitere pytest-Tests für GS-Versions-Detection (parametrisiert) und Preflight-Kombinationen
## [0.2.1] - 2026-04-09
### Fixed
- **Issue #1**: Preflight-Check beim Start prüft jetzt `tesseract` und `gs` (Ghostscript). Fehlt eine Abhängigkeit, beendet sich der Service sofort mit Exit-Code 2 und klarer Fehlermeldung statt erst bei der ersten Datei.
- **Issue #2**: `--once`-Modus liefert jetzt Exit-Code `1`, sobald **mindestens ein** PDF fehlgeschlagen ist. Exit-Code `0` nur bei vollständigem Erfolg (inkl. "keine Dateien vorhanden"). Exit-Code `2` bei Preflight-Fehler.
### Added
- Public API: `HotfolderService.run_once()`, `.success_count`, `.error_count`, `.ensure_dirs()`
- `check_preflight()` / `PreflightError` in `pdf_ocr_hotfolder.service`
- pytest-Test-Suite (`tests/`) mit 11 Tests — deckt alle Szenarien aus Issue #1 und #2 ab
- `ocrmypdf`-Import in `processor.py` ist jetzt lazy (Tests ohne ocrmypdf-Installation möglich)
## [0.2.0] - 2026-04-08
### Added
- **Multi-Instanz-Support** via systemd Template-Unit `pdf-ocr-hotfolder@<name>.service`
- Pro Instanz: eigene Config (`/etc/pdf-ocr-hotfolder/<name>.toml`), eigene Datenverzeichnisse (`/var/lib/pdf-ocr-hotfolder/<name>/…`), optional eigener Service-User via Drop-in
- **Instanz-Manager in `install.sh`**: erkennt bestehende Instanzen bei Re-Run, fragt nach weiteren, listet Namen + Status
- `update.sh` stoppt/startet automatisch **alle** laufenden Instanzen
### Changed
- Single-Unit `pdf-ocr-hotfolder.service` durch Template-Unit `pdf-ocr-hotfolder@.service` ersetzt
- Installer fragt nicht mehr einmalig nach Service-User, sondern **pro Instanz**
### Removed
- Alte Single-Config unter `/etc/pdf-ocr-hotfolder/config.toml` — wird nicht mehr erzeugt
## [0.1.0] - 2026-04-08
### Added
+89 -22
View File
@@ -23,35 +23,56 @@ cd pdf-ocr-hotfolder
sudo ./install.sh
```
Der Installer fragt nach dem Service-User. Standardmäßig wird ein lokaler System-User `pdfocr` angelegt. Wenn der User bereits existiert (z.B. AD via SSSD), wird er einfach übernommen.
Der Installer:
1. Installiert einmalig Code + venv + systemd-Template-Unit
2. Fragt nach Instanz-Name, Basis-Pfad, Service-User
3. Legt so viele Hotfolder-Instanzen an, wie du willst (`Weitere Instanz anlegen? [j/N]`)
Danach Konfiguration anpassen:
```bash
sudo nano /etc/pdf-ocr-hotfolder/config.toml
sudo systemctl restart pdf-ocr-hotfolder
```
Bei jedem erneuten Aufruf erkennt der Installer bestehende Instanzen und fragt nur nach neuen.
Test:
```bash
cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder/incoming/
journalctl -u pdf-ocr-hotfolder -f
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 unter `/var/lib/pdf-ocr-hotfolder/outgoing/OCR_irgendein-scan.pdf`.
Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/`-Ordner der Instanz.
## Multi-Instanz-Betrieb
Das Tool arbeitet komplett **instanzbasiert** über eine systemd Template-Unit `pdf-ocr-hotfolder@<name>.service`. Jede Instanz hat:
- eigene Config-Datei: `/etc/pdf-ocr-hotfolder/<name>.toml`
- eigene Datenverzeichnisse: `/var/lib/pdf-ocr-hotfolder/<name>/{incoming,working,outgoing,error}/`
- eigene systemd-Unit: `pdf-ocr-hotfolder@<name>.service`
- optional eigenen Service-User (via Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@<name>.service.d/user.conf`)
Beispiel für 3 Hotfolder:
```bash
sudo ./install.sh
# → legt z.B. kunde-a, kunde-b, buchhaltung an
systemctl status 'pdf-ocr-hotfolder@*'
journalctl -u pdf-ocr-hotfolder@kunde-a -f
```
Manuell eine weitere Instanz anlegen geht auch — einfach `install.sh` erneut starten, er fragt wieder nach.
## Verzeichnisse
| Pfad | Zweck |
|------|-------|
| `/etc/pdf-ocr-hotfolder/config.toml` | Konfiguration |
| `/var/lib/pdf-ocr-hotfolder/incoming` | Eingang (Scanner schreibt hier rein) |
| `/var/lib/pdf-ocr-hotfolder/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/error` | PDFs, die nicht verarbeitet werden konnten |
| `/opt/pdf-ocr-hotfolder/` | Code + venv |
| `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) |
| `/etc/pdf-ocr-hotfolder/<instanz>.toml` | Config pro Instanz |
| `/etc/systemd/system/pdf-ocr-hotfolder@.service` | systemd Template-Unit |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/incoming` | Eingang (Scanner schreibt hier rein) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/working` | Arbeitsverzeichnis während OCR |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/outgoing` | Ausgang (fertige PDFs) |
| `/var/lib/pdf-ocr-hotfolder/<instanz>/error` | Fehlgeschlagene PDFs |
| `/var/log/pdf-ocr-hotfolder/` | Logs (zusätzlich zu journald) |
| `/var/backups/pdf-ocr-hotfolder/` | Update-Backups |
## Konfiguration
@@ -68,6 +89,22 @@ max_workers = 2 # parallele PDFs
timeout = 1800
```
### `[output]`
```toml
# Dateiname im outgoing/:
# "prefix" → OCR_scan.pdf
# "suffix" → scan_OCR.pdf (vor der Extension)
# "none" → scan.pdf (unverändert)
name_mode = "prefix"
name_tag = "OCR_"
# Nach erfolgreichem OCR mit dem Original:
# "delete" → löschen
# "archive" → in archive_dir verschieben
original_on_success = "delete"
archive_dir = "" # absoluter Pfad, Pflicht bei "archive"
```
### `[upload.nextcloud]`
```toml
enabled = true
@@ -101,9 +138,14 @@ on = "errors" # always | errors | never
## Service-Verwaltung
```bash
sudo systemctl status pdf-ocr-hotfolder
sudo systemctl restart pdf-ocr-hotfolder
journalctl -u pdf-ocr-hotfolder -f
# Eine bestimmte Instanz
sudo systemctl status pdf-ocr-hotfolder@kunde-a
sudo systemctl restart pdf-ocr-hotfolder@kunde-a
journalctl -u pdf-ocr-hotfolder@kunde-a -f
# Alle Instanzen
sudo systemctl status 'pdf-ocr-hotfolder@*'
sudo systemctl restart 'pdf-ocr-hotfolder@*'
```
## Update
@@ -114,15 +156,22 @@ 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 im Eingang einmalig verarbeiten und beenden:
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/config.toml --once
--config /etc/pdf-ocr-hotfolder/kunde-a.toml --once
```
## Troubleshooting
@@ -141,6 +190,24 @@ Service-User braucht **rw** auf alle vier Verzeichnisse unter `/var/lib/pdf-ocr-
sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder
```
### LXC/Container: Error 226/NAMESPACE
In LXC-Containern schlagen systemd-Hardening-Optionen fehl. Der Installer erkennt Container automatisch und bietet ein Drop-in 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@*'
```
### Ghostscript PDF/A-Bug auf Debian 12
GS 10.00.010.02.0 (Debian 12 Default) zerstört OCR bei `pdfa_level` + `skip_text=true`. Der Installer bietet automatisch bookworm-backports an. Manuell:
```bash
echo 'deb http://deb.debian.org/debian bookworm-backports main' | \
sudo tee /etc/apt/sources.list.d/bookworm-backports.list
sudo apt update && sudo apt install -t bookworm-backports ghostscript
```
### veraPDF-Validierung schlägt immer fehl
veraPDF binary prüfen (`[verapdf].binary`). Wenn nicht zwingend gebraucht: `enabled = false`.
@@ -172,5 +239,5 @@ MIT — © Sonith UG
---
**Version:** 0.1.0
**Version:** 0.3.1
**Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.3.1
+20 -1
View File
@@ -21,7 +21,10 @@ skip_text = true
# Auflösung für gerasterte Seiten
oversample = 300
# PDF/A-Konformitätsstufe ("1", "2", "3" oder leer für keinen PDF/A-Output)
pdfa_level = "2"
# ACHTUNG: Ghostscript 10.0.0 bis 10.02.0 (Debian 12 default!) haben einen Bug,
# der mit pdfa_level + skip_text=true ocrmypdf komplett blockiert.
# Sicherer Default ist "" — nur auf "1"/"2"/"3" setzen, wenn gs >= 10.02.1 installiert ist.
pdfa_level = ""
# Schiefe Scans automatisch begradigen
deskew = true
# Hintergrund säubern
@@ -31,6 +34,22 @@ max_workers = 2
# Timeout pro PDF in Sekunden
timeout = 1800
[output]
# Wie soll die Ziel-Datei im outgoing/-Ordner benannt werden?
# "prefix" : name_tag wird vor den Dateinamen gestellt (OCR_scan.pdf)
# "suffix" : name_tag wird vor die Extension gestellt (scan_OCR.pdf)
# "none" : Dateiname bleibt wie das Original
name_mode = "prefix"
# Verbatim einzufügender String. Leerer String = kein Tag (wie mode="none").
# Beispiele: "OCR_", "[OCR]_", "_OCR", "_searchable"
name_tag = "OCR_"
# Was passiert mit dem Original, wenn OCR erfolgreich war?
# "delete" : Original wird gelöscht (alter Standard)
# "archive" : Original wird in archive_dir verschoben
original_on_success = "delete"
# Absoluter Pfad; nur relevant wenn original_on_success = "archive"
archive_dir = ""
[verapdf]
# PDF/A-Validierung (optional)
enabled = false
+239 -99
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
#
# PDF OCR Hotfolder — Installer für Debian 12/13
# PDF OCR Hotfolder — Installer / Instanz-Manager für Debian 12/13
#
# Fragt interaktiv nach dem Service-User. Unterstützt:
# - Lokal anlegen (neuer System-User)
# - Bereits existierender lokaler User
# - AD-User mit lokaler UID (z.B. via SSSD/Winbind)
# Basis-Installation erfolgt einmalig (Code, venv, systemd-Template-Unit).
# Danach werden Hotfolder-Instanzen verwaltet:
# - Beim Erstlauf: mindestens eine Instanz wird angelegt
# - Beim Folgelauf: bestehende Instanzen werden erkannt; neue können ergänzt werden
#
# Unterstützt lokale System-User und AD-User mit lokaler UID (SSSD/Winbind).
#
set -euo pipefail
@@ -14,7 +16,7 @@ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC
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 "${BLUE}==>${NC} $*"; }
log_step() { echo -e "\n${BLUE}==>${NC} $*"; }
if [ "${EUID}" -ne 0 ]; then
log_error "Bitte als root ausführen: sudo ./install.sh"
@@ -23,9 +25,10 @@ fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder"
CONFIG_DIR="/etc/pdf-ocr-hotfolder"
DATA_DIR="/var/lib/pdf-ocr-hotfolder"
DATA_ROOT="/var/lib/pdf-ocr-hotfolder"
LOG_DIR="/var/log/pdf-ocr-hotfolder"
SERVICE_NAME="pdf-ocr-hotfolder"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
DEFAULT_USER="pdfocr"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$SCRIPT_DIR"
@@ -35,123 +38,260 @@ if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
exit 1
fi
echo
echo "=========================================="
echo " PDF OCR Hotfolder — Installation"
echo "=========================================="
echo
# ============================================================
# Basis-Installation (idempotent)
# ============================================================
# ============ 1. System-Dependencies ============
log_step "Installiere System-Pakete"
apt-get update -qq
apt-get install -y --no-install-recommends \
install_base() {
log_step "System-Pakete installieren"
apt-get update -qq
apt-get install -y --no-install-recommends \
python3 python3-venv python3-pip \
tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
ghostscript qpdf unpaper pngquant \
icc-profiles-free \
ca-certificates curl
icc-profiles-free ca-certificates curl
log_info "System-Pakete ok ✓"
log_info "System-Pakete installiert ✓"
# Ghostscript-Versions-Check (Issue #3 + Issue #6)
if command -v gs >/dev/null 2>&1; then
GS_VER="$(gs --version 2>/dev/null || echo 0.0)"
log_info "Ghostscript: $GS_VER"
case "$GS_VER" in
10.0.0|10.00.0|10.01.*|10.02.0)
echo
log_warn "═══════════════════════════════════════════════════════════════"
log_warn "Ghostscript $GS_VER ist vom PDF/A-Bug betroffen (10.0.010.02.0)."
log_warn "Mit pdfa_level + skip_text=true kann ocrmypdf KEINE PDFs verarbeiten."
log_warn "═══════════════════════════════════════════════════════════════"
echo
# Prüfe ob Debian bookworm (12) — Backports anbieten
if grep -q 'bookworm' /etc/os-release 2>/dev/null; then
read -r -p "Ghostscript via bookworm-backports upgraden? [J/n]: " UPGRADE_GS
UPGRADE_GS="${UPGRADE_GS:-J}"
if [[ "$UPGRADE_GS" =~ ^[JjYy]$ ]]; then
log_info "Aktiviere bookworm-backports..."
if ! grep -q 'bookworm-backports' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
echo 'deb http://deb.debian.org/debian bookworm-backports main' \
> /etc/apt/sources.list.d/bookworm-backports.list
apt-get update -qq
fi
apt-get install -y -t bookworm-backports ghostscript
GS_VER_NEW="$(gs --version 2>/dev/null || echo '?')"
log_info "Ghostscript aktualisiert: $GS_VER$GS_VER_NEW"
else
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
else
log_warn "Kein Debian bookworm erkannt — manuelles Upgrade nötig."
log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)"
fi
echo
;;
esac
fi
# ============ 2. Service-User ============
log_step "Service-User konfigurieren"
# LXC/Container-Erkennung (Issue #4)
if systemd-detect-virt --container -q 2>/dev/null; then
VIRT_TYPE="$(systemd-detect-virt --container 2>/dev/null || echo 'container')"
log_warn "Container-Umgebung erkannt ($VIRT_TYPE)."
log_warn "systemd-Hardening kann in Containern fehlschlagen (Error 226/NAMESPACE)."
read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX
LXC_FIX="${LXC_FIX:-J}"
if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then
local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d"
mkdir -p "$LXC_DROPIN_DIR"
cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf"
systemctl daemon-reload
log_info "LXC-Kompatibilitäts-Drop-in installiert ✓"
fi
fi
read -r -p "Service-User-Name [pdfocr]: " SERVICE_USER
SERVICE_USER="${SERVICE_USER:-pdfocr}"
log_step "Default-User '$DEFAULT_USER' prüfen"
if id "$DEFAULT_USER" &>/dev/null; then
log_info "'$DEFAULT_USER' existiert bereits"
else
adduser --system --group --home "$DATA_ROOT" --shell /usr/sbin/nologin "$DEFAULT_USER"
log_info "System-User '$DEFAULT_USER' angelegt ✓"
fi
if id "$SERVICE_USER" &>/dev/null; then
log_info "User '$SERVICE_USER' existiert bereits (lokal oder via AD)."
SERVICE_GROUP="$(id -gn "$SERVICE_USER")"
log_info "Verwende bestehende primäre Gruppe: $SERVICE_GROUP"
else
log_warn "User '$SERVICE_USER' existiert nicht."
log_step "Verzeichnisse anlegen"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" "$LOG_DIR"
chown root:"$DEFAULT_USER" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
log_step "Code kopieren"
rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_step "Python venv"
if [ ! -d "$INSTALL_DIR/venv" ]; then
python3 -m venv "$INSTALL_DIR/venv"
fi
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q
log_info "venv ok ✓"
log_step "systemd Template-Unit installieren"
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
systemctl daemon-reload
log_info "Template-Unit installiert ✓"
chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" "$LOG_DIR"
}
# ============================================================
# Instanz-Verwaltung
# ============================================================
list_instances() {
find "$CONFIG_DIR" -maxdepth 1 -name '*.toml' -type f 2>/dev/null \
| sed 's|.*/||; s|\.toml$||' \
| sort
}
show_existing_instances() {
local instances
mapfile -t instances < <(list_instances)
if [ "${#instances[@]}" -eq 0 ]; then
log_info "Keine bestehenden Instanzen gefunden."
return
fi
echo
log_info "Bestehende Instanzen:"
for name in "${instances[@]}"; do
local active
active=$(systemctl is-active "pdf-ocr-hotfolder@${name}.service" 2>/dev/null || echo inactive)
printf " • %-30s [%s]\n" "$name" "$active"
done
echo
}
create_instance() {
echo
read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST
if [[ ! "$INST" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
log_error "Ungültiger Name. Abbruch."
return 1
fi
if [ -f "$CONFIG_DIR/$INST.toml" ]; then
log_error "Instanz '$INST' existiert bereits. Abbruch."
return 1
fi
local default_base="$DATA_ROOT/$INST"
read -r -p "Basis-Pfad für Daten [$default_base]: " BASE
BASE="${BASE:-$default_base}"
read -r -p "Service-User [$DEFAULT_USER]: " SVC_USER
SVC_USER="${SVC_USER:-$DEFAULT_USER}"
local SVC_GROUP
if id "$SVC_USER" &>/dev/null; then
SVC_GROUP="$(id -gn "$SVC_USER")"
log_info "User '$SVC_USER' existiert (Gruppe: $SVC_GROUP)"
else
log_warn "User '$SVC_USER' existiert nicht."
read -r -p "Lokal als System-User anlegen? [J/n]: " CREATE_USER
CREATE_USER="${CREATE_USER:-J}"
if [[ "$CREATE_USER" =~ ^[JjYy]$ ]]; then
adduser --system --group --home "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
SERVICE_GROUP="$SERVICE_USER"
log_info "Lokaler System-User '$SERVICE_USER' angelegt ✓"
adduser --system --group --home "$BASE" --shell /usr/sbin/nologin "$SVC_USER"
SVC_GROUP="$SVC_USER"
log_info "User '$SVC_USER' angelegt ✓"
else
log_error "User '$SERVICE_USER' muss vor der Installation existieren (z.B. via AD/SSSD)."
log_error "Lege ihn an oder wähle einen existierenden Namen."
exit 1
log_error "User muss existieren (z.B. via AD/SSSD). Abbruch."
return 1
fi
fi
fi
# ============ 3. Verzeichnisse ============
log_step "Verzeichnisse erstellen"
log_info "Lege Datenverzeichnisse unter $BASE an..."
mkdir -p "$BASE"/{incoming,outgoing,working,error}
chown -R "$SVC_USER":"$SVC_GROUP" "$BASE"
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR"
mkdir -p "$DATA_DIR"/{incoming,outgoing,working,error}
log_info "Erstelle Config $CONFIG_DIR/$INST.toml..."
sed \
-e "s|/var/lib/pdf-ocr-hotfolder/incoming|$BASE/incoming|" \
-e "s|/var/lib/pdf-ocr-hotfolder/outgoing|$BASE/outgoing|" \
-e "s|/var/lib/pdf-ocr-hotfolder/working|$BASE/working|" \
-e "s|/var/lib/pdf-ocr-hotfolder/error|$BASE/error|" \
"$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml"
chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml"
chmod 640 "$CONFIG_DIR/$INST.toml"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
# Drop-in für abweichenden Service-User
if [ "$SVC_USER" != "$DEFAULT_USER" ]; then
local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d"
mkdir -p "$DROPIN_DIR"
cat > "$DROPIN_DIR/user.conf" <<EOF
[Service]
User=$SVC_USER
Group=$SVC_GROUP
EOF
log_info "Drop-in für User '$SVC_USER' erstellt"
fi
if [ ! -f "$CONFIG_DIR/config.toml" ]; then
cp "$REPO_DIR/config.example.toml" "$CONFIG_DIR/config.toml"
log_info "Beispiel-Konfig nach $CONFIG_DIR/config.toml kopiert"
systemctl daemon-reload
systemctl enable --now "pdf-ocr-hotfolder@${INST}.service"
sleep 1
if systemctl is-active --quiet "pdf-ocr-hotfolder@${INST}.service"; then
log_info "✅ Instanz '$INST' läuft"
else
log_warn "Instanz '$INST' läuft nicht. Logs: journalctl -u pdf-ocr-hotfolder@${INST}"
fi
echo
echo " Config: $CONFIG_DIR/$INST.toml"
echo " Eingang: $BASE/incoming"
echo " Ausgang: $BASE/outgoing"
echo " User: $SVC_USER ($SVC_GROUP)"
echo
}
# ============================================================
# Main
# ============================================================
echo
echo "=========================================="
echo " PDF OCR Hotfolder — Installer"
echo "=========================================="
if [ ! -d "$INSTALL_DIR/venv" ] || [ ! -f "/etc/systemd/system/$SERVICE_TEMPLATE" ]; then
log_step "Basis-Installation"
install_base
else
log_info "Bestehende Konfig $CONFIG_DIR/config.toml bleibt unverändert"
log_info "Basis-Installation bereits vorhanden ($INSTALL_DIR)"
log_info "Überspringe Basis-Setup (nutze update.sh für Code-Updates)"
fi
log_info "Verzeichnisse erstellt ✓"
show_existing_instances
# ============ 4. Python venv ============
log_step "Python venv anlegen"
if [ ! -d "$INSTALL_DIR/venv" ]; then
python3 -m venv "$INSTALL_DIR/venv"
fi
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q
log_info "venv bereit ✓"
# ============ 5. Berechtigungen ============
log_step "Berechtigungen setzen"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR" "$DATA_DIR" "$LOG_DIR"
chown root:"$SERVICE_GROUP" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR"
if [ -f "$CONFIG_DIR/config.toml" ]; then
chown root:"$SERVICE_GROUP" "$CONFIG_DIR/config.toml"
chmod 640 "$CONFIG_DIR/config.toml"
# Erste Instanz ist Pflicht, wenn noch keine vorhanden
mapfile -t existing < <(list_instances)
if [ "${#existing[@]}" -eq 0 ]; then
log_info "Lege erste Hotfolder-Instanz an."
create_instance || true
fi
log_info "Berechtigungen gesetzt ✓"
# ============ 6. systemd-Unit ============
log_step "systemd-Unit installieren"
sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__SERVICE_GROUP__|$SERVICE_GROUP|g" \
"$REPO_DIR/systemd/pdf-ocr-hotfolder.service" \
> "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
log_info "systemd-Unit installiert & enabled ✓"
# ============ 7. Start ============
log_step "Service starten"
systemctl restart "${SERVICE_NAME}.service"
sleep 2
systemctl --no-pager --lines=10 status "${SERVICE_NAME}.service" || true
while true; do
read -r -p "Weitere Instanz anlegen? [j/N]: " MORE
MORE="${MORE:-N}"
if [[ "$MORE" =~ ^[JjYy]$ ]]; then
create_instance || true
else
break
fi
done
echo
echo "=========================================="
echo " Installation abgeschlossen"
echo " Fertig"
echo "=========================================="
echo
echo " Konfiguration: $CONFIG_DIR/config.toml"
echo " Eingang: $DATA_DIR/incoming"
echo " Ausgang: $DATA_DIR/outgoing"
echo " Service-User: $SERVICE_USER ($SERVICE_GROUP)"
echo
echo " Logs: journalctl -u $SERVICE_NAME -f"
show_existing_instances
echo " Logs: journalctl -u pdf-ocr-hotfolder@<instanz> -f"
echo " Neustart: systemctl restart pdf-ocr-hotfolder@<instanz>"
echo " Update: sudo ./update.sh"
echo
+1 -1
View File
@@ -1,3 +1,3 @@
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
__version__ = "0.1.0"
__version__ = "0.3.1"
+11 -5
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from . import __version__
from .config import load_config
from .service import HotfolderService
from .service import HotfolderService, PreflightError
def _setup_logging(level: str) -> None:
@@ -40,14 +40,20 @@ def main() -> int:
_setup_logging(cfg.log_level)
service = HotfolderService(cfg)
if args.once:
service._ensure_dirs() # noqa: SLF001
service._scan_existing() # noqa: SLF001
service._executor.shutdown(wait=True) # noqa: SLF001
return 0
try:
errors = service.run_once()
except PreflightError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
return 1 if errors > 0 else 0
try:
service.run()
except PreflightError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 2
except KeyboardInterrupt:
pass
return 0
+16 -1
View File
@@ -28,6 +28,18 @@ class OcrConfig:
timeout: int = 1800
@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
class VeraPdfConfig:
enabled: bool = False
@@ -79,6 +91,7 @@ class EmailNotify:
class Config:
paths: Paths
ocr: OcrConfig
output: OutputConfig
verapdf: VeraPdfConfig
folder: FolderUpload
nextcloud: NextcloudUpload
@@ -109,6 +122,8 @@ def load_config(path: str | Path) -> Config:
ocr = OcrConfig(**{k: v for k, v in _section(data, "ocr").items()
if k in OcrConfig.__annotations__})
output = OutputConfig(**{k: v for k, v in _section(data, "output").items()
if k in OutputConfig.__annotations__})
verapdf = VeraPdfConfig(**{k: v for k, v in _section(data, "verapdf").items()
if k in VeraPdfConfig.__annotations__})
folder = FolderUpload(**{k: v for k, v in _section(data, "upload", "folder").items()
@@ -123,7 +138,7 @@ def load_config(path: str | Path) -> Config:
log_level = _section(data, "logging").get("level", "INFO")
return Config(
paths=paths, ocr=ocr, verapdf=verapdf,
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
log_level=log_level,
)
+62 -6
View File
@@ -7,13 +7,37 @@ import subprocess
from dataclasses import dataclass
from pathlib import Path
import ocrmypdf
from .config import OcrConfig, VeraPdfConfig
from .config import OcrConfig, OutputConfig, VeraPdfConfig
log = logging.getLogger(__name__)
def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
Args:
src_name: Original-Dateiname (z.B. "scan.pdf")
mode: "prefix" | "suffix" | "none"
tag: Einzufügender String (verbatim, leer = kein Tag)
Beispiele:
prefix "OCR_": "scan.pdf" -> "OCR_scan.pdf"
suffix "_OCR": "scan.pdf" -> "scan_OCR.pdf"
suffix "_OCR": "scan.tar.gz.pdf" -> "scan.tar.gz_OCR.pdf"
none: "scan.pdf" -> "scan.pdf"
"""
if mode == "none" or not tag:
return src_name
if mode == "prefix":
return f"{tag}{src_name}"
if mode == "suffix":
# Nur die letzte Extension abspalten, sonst "foo.bar.pdf" kaputt gemacht
p = Path(src_name)
stem, ext = p.stem, p.suffix
return f"{stem}{tag}{ext}"
raise ValueError(f"Unbekannter name_mode: {mode!r}")
@dataclass
class ProcessResult:
source: Path
@@ -25,6 +49,8 @@ class ProcessResult:
def run_ocr(src: Path, dst: Path, cfg: OcrConfig) -> None:
"""Führt ocrmypdf als Library-Call aus (kein Subprozess-Overhead)."""
import ocrmypdf # lazy, damit Tests ohne ocrmypdf laufen
kwargs: dict = {
"language": cfg.languages,
"jobs": cfg.jobs,
@@ -71,11 +97,13 @@ def process_pdf(
error_dir: Path,
ocr_cfg: OcrConfig,
vera_cfg: VeraPdfConfig,
output_cfg: OutputConfig,
) -> ProcessResult:
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name
work_out = working_dir / f"OCR_{src.name}"
final_out = outgoing_dir / f"OCR_{src.name}"
work_out = working_dir / f"__ocr_{out_name}" # Temp-Name, damit er != src.name ist
final_out = outgoing_dir / out_name
try:
shutil.move(str(src), str(work_src))
@@ -100,10 +128,38 @@ def process_pdf(
outgoing_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(work_out), str(final_out))
work_src.unlink(missing_ok=True)
_dispose_original(work_src, src.name, output_cfg)
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original nach erfolgreichem OCR — löschen oder archivieren."""
if not work_src.exists():
return
mode = cfg.original_on_success
if mode == "delete":
work_src.unlink(missing_ok=True)
return
if mode == "archive":
if not cfg.archive_dir:
log.error("original_on_success=archive aber archive_dir ist leer — lösche stattdessen")
work_src.unlink(missing_ok=True)
return
archive = Path(cfg.archive_dir)
archive.mkdir(parents=True, exist_ok=True)
dest = archive / original_name
# Bei Namens-Kollision mit Timestamp umbenennen
if dest.exists():
from datetime import datetime
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = archive / f"{dest.stem}_{ts}{dest.suffix}"
shutil.move(str(work_src), str(dest))
log.info("Original archiviert: %s", dest)
return
log.warning("Unbekannter original_on_success=%r — lösche stattdessen", mode)
work_src.unlink(missing_ok=True)
def _move_to_error(p: Path, error_dir: Path) -> None:
error_dir.mkdir(parents=True, exist_ok=True)
try:
+133 -2
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
import logging
import re
import shutil
import signal
import subprocess
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
@@ -18,6 +21,98 @@ from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sft
log = logging.getLogger(__name__)
class PreflightError(RuntimeError):
"""Erforderliche externe Binaries fehlen."""
# Pflicht-Binaries für ocrmypdf
_REQUIRED_BINARIES = ("tesseract", "gs")
# Ghostscript-Versionen mit bekanntem PDF/A+skip_text Bug (Issue #3):
# 10.0.0 .. 10.02.0 (inklusive). Ab 10.02.1 wieder nutzbar.
_GS_BROKEN_MIN = (10, 0, 0)
_GS_BROKEN_MAX = (10, 2, 0)
def _parse_version(text: str) -> tuple[int, ...] | None:
"""Extrahiert die erste X.Y[.Z] Version aus einem String."""
m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
if not m:
return None
return tuple(int(x) if x is not None else 0 for x in m.groups())
def is_ghostscript_broken(version: str | None) -> bool:
"""Prüft, ob eine Ghostscript-Version vom PDF/A+skip_text Bug betroffen ist.
Betrifft 10.0.0 bis einschließlich 10.02.0. Ab 10.02.1 wieder sicher.
"""
if not version:
return False
parsed = _parse_version(version)
if parsed is None:
return False
# Auf 3-Tupel normalisieren
while len(parsed) < 3:
parsed = parsed + (0,)
parsed = parsed[:3]
return _GS_BROKEN_MIN <= parsed <= _GS_BROKEN_MAX
def detect_ghostscript_version() -> str | None:
"""Ruft `gs --version` auf und gibt den Versionsstring zurück (oder None)."""
gs = shutil.which("gs")
if gs is None:
return None
try:
result = subprocess.run([gs, "--version"], capture_output=True,
text=True, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return None
return result.stdout.strip() or None
def check_output_config(mode: str, archive_dir: str) -> 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"
)
def check_preflight(pdfa_level: str = "") -> None:
"""Prüft externe Abhängigkeiten.
- Tesseract und Ghostscript müssen im PATH sein
- Bei gesetztem pdfa_level wird die Ghostscript-Version gegen den
bekannten 10.0.010.02.0 Bug geprüft
Wirft PreflightError bei fehlenden Binaries oder unsicherem Ghostscript.
"""
missing = [b for b in _REQUIRED_BINARIES if shutil.which(b) is None]
if missing:
raise PreflightError(
"Fehlende Abhängigkeiten: " + ", ".join(missing)
+ ". Bitte installieren: sudo apt install tesseract-ocr ghostscript"
)
if pdfa_level:
gs_version = detect_ghostscript_version()
if is_ghostscript_broken(gs_version):
raise PreflightError(
f"Ghostscript {gs_version} ist mit pdfa_level='{pdfa_level}' nicht "
"kompatibel (bekannter Bug in 10.0.010.02.0). "
"Entweder ghostscript auf >=10.02.1 upgraden (z.B. via bookworm-backports) "
"oder in der Config [ocr].pdfa_level = \"\" setzen."
)
def _is_pdf(path: Path) -> bool:
return path.suffix.lower() == ".pdf" and path.is_file()
@@ -70,10 +165,20 @@ class HotfolderService:
self._stop = threading.Event()
self._inflight: set[str] = set()
self._lock = threading.Lock()
self._success_count = 0
self._error_count = 0
@property
def success_count(self) -> int:
return self._success_count
@property
def error_count(self) -> int:
return self._error_count
# ---- Setup ----
def _ensure_dirs(self) -> None:
def ensure_dirs(self) -> None:
for p in (self.cfg.paths.incoming, self.cfg.paths.outgoing,
self.cfg.paths.working, self.cfg.paths.error):
p.mkdir(parents=True, exist_ok=True)
@@ -81,7 +186,10 @@ class HotfolderService:
# ---- Lifecycle ----
def run(self) -> None:
self._ensure_dirs()
check_preflight(self.cfg.ocr.pdfa_level)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir)
self.ensure_dirs()
self._scan_existing()
self._observer = Observer()
@@ -98,6 +206,22 @@ class HotfolderService:
finally:
self.shutdown()
def run_once(self) -> int:
"""Verarbeitet alle bereits im incoming-Ordner liegenden PDFs und beendet sich.
Returns:
Anzahl fehlgeschlagener PDFs (0 = alles ok).
"""
check_preflight(self.cfg.ocr.pdfa_level)
check_output_config(self.cfg.output.original_on_success,
self.cfg.output.archive_dir)
self.ensure_dirs()
self._scan_existing()
self._executor.shutdown(wait=True)
log.info("One-shot fertig: %d ok, %d Fehler",
self._success_count, self._error_count)
return self._error_count
def shutdown(self) -> None:
log.info("Shutdown läuft...")
if self._observer:
@@ -148,8 +272,15 @@ class HotfolderService:
error_dir=self.cfg.paths.error,
ocr_cfg=self.cfg.ocr,
vera_cfg=self.cfg.verapdf,
output_cfg=self.cfg.output,
)
with self._lock:
if result.success:
self._success_count += 1
else:
self._error_count += 1
if result.success:
self._dispatch_uploads(result.output)
self._notify(result)
+10
View File
@@ -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
@@ -1,13 +1,14 @@
[Unit]
Description=PDF OCR Hotfolder
Description=PDF OCR Hotfolder (Instance: %i)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_GROUP__
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/config.toml
User=pdfocr
Group=pdfocr
WorkingDirectory=/opt/pdf-ocr-hotfolder
ExecStart=/opt/pdf-ocr-hotfolder/venv/bin/python -m pdf_ocr_hotfolder --config /etc/pdf-ocr-hotfolder/%i.toml
Restart=on-failure
RestartSec=5
KillMode=mixed
View File
+54
View File
@@ -0,0 +1,54 @@
"""Gemeinsame pytest-Fixtures."""
from __future__ import annotations
from pathlib import Path
import pytest
from pdf_ocr_hotfolder.config import (
Config,
EmailNotify,
FolderUpload,
NextcloudUpload,
OcrConfig,
OutputConfig,
Paths,
SftpUpload,
VeraPdfConfig,
)
@pytest.fixture
def tmp_config(tmp_path: Path) -> Config:
"""Minimal-Config mit tmp_path-Verzeichnissen, alle Uploads deaktiviert."""
paths = Paths(
incoming=tmp_path / "incoming",
outgoing=tmp_path / "outgoing",
working=tmp_path / "working",
error=tmp_path / "error",
)
for p in (paths.incoming, paths.outgoing, paths.working, paths.error):
p.mkdir(parents=True, exist_ok=True)
return Config(
paths=paths,
ocr=OcrConfig(max_workers=1),
output=OutputConfig(),
verapdf=VeraPdfConfig(enabled=False),
folder=FolderUpload(enabled=False),
nextcloud=NextcloudUpload(enabled=False),
sftp=SftpUpload(enabled=False),
email=EmailNotify(enabled=False),
log_level="DEBUG",
)
@pytest.fixture
def dummy_pdf(tmp_config: Config) -> Path:
"""Legt eine Datei mit .pdf-Extension im incoming-Ordner ab.
Achtung: kein echtes PDF. Für Tests wird `process_pdf` gemockt.
"""
pdf = tmp_config.paths.incoming / "test.pdf"
pdf.write_bytes(b"%PDF-1.4 fake\n")
return pdf
+72
View File
@@ -0,0 +1,72 @@
"""Tests für Issue #3: Ghostscript 10.0.010.02.0 PDF/A-Bug-Erkennung."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.service import (
PreflightError,
check_preflight,
is_ghostscript_broken,
)
@pytest.mark.parametrize("version,expected", [
# Betroffene Versionen
("10.0.0", True),
("10.00.0", True),
("10.01.0", True),
("10.01.1", True),
("10.01.2", True),
("10.02.0", True),
# Sichere Versionen
("10.02.1", False),
("10.03.0", False),
("10.04.0", False),
("11.0.0", False),
("9.56.1", False), # Debian 11 / Ubuntu 22.04
("9.55.0", False),
# Edge cases
("", False),
(None, False),
("garbage", False),
])
def test_is_ghostscript_broken(version, expected) -> None:
assert is_ghostscript_broken(version) is expected
def test_check_preflight_without_pdfa_passes_with_broken_gs() -> None:
"""Ohne pdfa_level darf der betroffene GS verwendet werden."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
check_preflight(pdfa_level="") # darf nicht werfen
def test_check_preflight_with_pdfa_fails_on_broken_gs() -> None:
"""Mit pdfa_level + kaputtem GS → PreflightError mit hilfreicher Meldung."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.0.0"):
with pytest.raises(PreflightError, match="Ghostscript 10.0.0"):
check_preflight(pdfa_level="2")
def test_check_preflight_with_pdfa_passes_on_fixed_gs() -> None:
"""Mit pdfa_level + gefixtem GS → ok."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"), \
patch("pdf_ocr_hotfolder.service.detect_ghostscript_version",
return_value="10.02.1"):
check_preflight(pdfa_level="2") # darf nicht werfen
def test_default_config_pdfa_level_is_empty() -> None:
"""Default-Config der Beispiel-Datei soll pdfa_level='' enthalten (Issue #3)."""
from pathlib import Path
import tomllib
cfg_path = Path(__file__).parent.parent / "config.example.toml"
with cfg_path.open("rb") as f:
data = tomllib.load(f)
assert data["ocr"]["pdfa_level"] == "", \
"config.example.toml muss pdfa_level='' als sicheren Default haben"
+96
View File
@@ -0,0 +1,96 @@
"""Tests für Issue #2: --once Modus muss Exit-Code != 0 bei Fehlern liefern."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from pdf_ocr_hotfolder.processor import ProcessResult
from pdf_ocr_hotfolder.service import HotfolderService
def _fake_success(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
out = outgoing_dir / f"OCR_{src.name}"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"%PDF-1.4 ocr\n")
src.unlink(missing_ok=True)
return ProcessResult(src, out, True)
def _fake_failure(src: Path, working_dir, outgoing_dir, error_dir, **kwargs):
error_dir.mkdir(parents=True, exist_ok=True)
dest = error_dir / src.name
src.rename(dest)
return ProcessResult(src, outgoing_dir / f"OCR_{src.name}", False,
error="fake ocr failure")
def _run(tmp_config, fake_process):
"""Helper: führt run_once() mit gemocktem process_pdf und preflight aus."""
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=fake_process), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
service = HotfolderService(tmp_config)
try:
return service.run_once()
finally:
service._executor.shutdown(wait=False)
def test_once_exit_0_when_no_files(tmp_config) -> None:
"""Szenario: Keine PDFs vorhanden → Exit 0."""
errors = _run(tmp_config, _fake_success)
assert errors == 0
def test_once_exit_0_when_all_success(tmp_config) -> None:
"""Szenario: Alle PDFs erfolgreich → Exit 0."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
errors = _run(tmp_config, _fake_success)
assert errors == 0
def test_once_exit_nonzero_when_all_fail(tmp_config) -> None:
"""Szenario: Alle PDFs fehlgeschlagen → Exit != 0 (Issue #2)."""
(tmp_config.paths.incoming / "a.pdf").write_bytes(b"%PDF-1.4\n")
(tmp_config.paths.incoming / "b.pdf").write_bytes(b"%PDF-1.4\n")
errors = _run(tmp_config, _fake_failure)
assert errors == 2
def test_once_exit_nonzero_when_some_fail(tmp_config) -> None:
"""Szenario: Teilweise fehlgeschlagen → Exit != 0."""
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
def mixed(src, *args, **kwargs):
if "bad" in src.name:
return _fake_failure(src, *args, **kwargs)
return _fake_success(src, *args, **kwargs)
errors = _run(tmp_config, mixed)
assert errors == 1
def test_counters_track_success_and_failure(tmp_config) -> None:
"""success_count und error_count sollen korrekt mitzählen."""
(tmp_config.paths.incoming / "ok.pdf").write_bytes(b"%PDF-1.4\n")
(tmp_config.paths.incoming / "bad.pdf").write_bytes(b"%PDF-1.4\n")
def mixed(src, *args, **kwargs):
if "bad" in src.name:
return _fake_failure(src, *args, **kwargs)
return _fake_success(src, *args, **kwargs)
with patch("pdf_ocr_hotfolder.service.check_preflight", return_value=None), \
patch("pdf_ocr_hotfolder.service.process_pdf", side_effect=mixed), \
patch("pdf_ocr_hotfolder.service._wait_until_stable", return_value=True):
service = HotfolderService(tmp_config)
try:
service.run_once()
assert service.success_count == 1
assert service.error_count == 1
finally:
service._executor.shutdown(wait=False)
+190
View File
@@ -0,0 +1,190 @@
"""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", "")
# ---------------- 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"
+75
View File
@@ -0,0 +1,75 @@
"""Tests für Issue #1: Preflight-Check bei fehlendem Tesseract."""
from __future__ import annotations
import sys
from unittest.mock import patch
import pytest
from pdf_ocr_hotfolder.service import (
HotfolderService,
PreflightError,
check_preflight,
)
def test_preflight_passes_when_all_binaries_present() -> None:
"""Wenn tesseract + gs im PATH sind, darf kein Fehler fliegen."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value="/usr/bin/fake"):
check_preflight() # darf nicht werfen
def test_preflight_fails_when_tesseract_missing() -> None:
"""Fehlendes tesseract → PreflightError mit passender Meldung."""
def fake_which(name: str) -> str | None:
return None if name == "tesseract" else "/usr/bin/fake"
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
with pytest.raises(PreflightError, match="tesseract"):
check_preflight()
def test_preflight_fails_when_ghostscript_missing() -> None:
def fake_which(name: str) -> str | None:
return None if name == "gs" else "/usr/bin/fake"
with patch("pdf_ocr_hotfolder.service.shutil.which", side_effect=fake_which):
with pytest.raises(PreflightError, match="gs"):
check_preflight()
def test_preflight_lists_all_missing_binaries() -> None:
"""Bei mehreren fehlenden Binaries werden alle genannt."""
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
with pytest.raises(PreflightError) as exc_info:
check_preflight()
msg = str(exc_info.value)
assert "tesseract" in msg
assert "gs" in msg
def test_run_once_raises_preflight_error(tmp_config) -> None:
"""HotfolderService.run_once() wirft PreflightError, wenn tesseract fehlt."""
service = HotfolderService(tmp_config)
try:
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
with pytest.raises(PreflightError):
service.run_once()
finally:
service._executor.shutdown(wait=False)
def test_main_returns_2_on_preflight_error(tmp_config, tmp_path, monkeypatch) -> None:
"""CLI liefert Exit-Code 2 bei Preflight-Fehler (Issue #1 Szenario)."""
cfg_file = tmp_path / "cfg.toml"
cfg_file.write_text(f"""
[paths]
incoming = "{tmp_config.paths.incoming}"
outgoing = "{tmp_config.paths.outgoing}"
working = "{tmp_config.paths.working}"
error = "{tmp_config.paths.error}"
""")
monkeypatch.setattr(sys, "argv", ["pdf-ocr-hotfolder", "--config", str(cfg_file), "--once"])
with patch("pdf_ocr_hotfolder.service.shutil.which", return_value=None):
from pdf_ocr_hotfolder.__main__ import main
assert main() == 2
+40 -18
View File
@@ -2,6 +2,10 @@
#
# PDF OCR Hotfolder — Update-Script
#
# Aktualisiert Code und venv unter /opt/pdf-ocr-hotfolder/ sowie die
# systemd Template-Unit. Danach werden alle laufenden Instanzen neu gestartet.
# Config-Dateien unter /etc/pdf-ocr-hotfolder/ bleiben unverändert.
#
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
@@ -15,7 +19,8 @@ if [ "${EUID}" -ne 0 ]; then
fi
INSTALL_DIR="/opt/pdf-ocr-hotfolder"
SERVICE_NAME="pdf-ocr-hotfolder"
CONFIG_DIR="/etc/pdf-ocr-hotfolder"
SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/pdf_ocr_hotfolder/__init__.py" ]; then
@@ -42,12 +47,18 @@ log_info "Install: $INSTALL_DIR"
log_info "Version: $OLD_VERSION$NEW_VERSION"
echo
# Service-User aus systemd-Unit lesen
SERVICE_USER="$(awk -F= '/^User=/{print $2}' /etc/systemd/system/${SERVICE_NAME}.service 2>/dev/null || echo pdfocr)"
SERVICE_GROUP="$(awk -F= '/^Group=/{print $2}' /etc/systemd/system/${SERVICE_NAME}.service 2>/dev/null || echo pdfocr)"
# Laufende Instanzen ermitteln
mapfile -t RUNNING < <(systemctl list-units --no-legend --state=active 'pdf-ocr-hotfolder@*.service' 2>/dev/null | awk '{print $1}')
if [ "${#RUNNING[@]}" -gt 0 ]; then
log_info "Laufende Instanzen: ${RUNNING[*]}"
else
log_info "Keine laufenden Instanzen."
fi
log_info "Stoppe Service..."
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
log_info "Stoppe laufende Instanzen..."
for unit in "${RUNNING[@]}"; do
systemctl stop "$unit" || true
done
log_info "Backup erstellen..."
BACKUP_DIR="/var/backups/pdf-ocr-hotfolder"
@@ -60,29 +71,40 @@ rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder"
cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/"
cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
cp "$REPO_DIR/VERSION" "$INSTALL_DIR/"
cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/"
echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path"
log_info "Dependencies aktualisieren..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q
"$INSTALL_DIR/venv/bin/pip" install --upgrade -r "$INSTALL_DIR/requirements.txt" -q
log_info "systemd-Unit aktualisieren..."
sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__SERVICE_GROUP__|$SERVICE_GROUP|g" \
"$REPO_DIR/systemd/pdf-ocr-hotfolder.service" \
> "/etc/systemd/system/${SERVICE_NAME}.service"
log_info "systemd Template-Unit aktualisieren..."
cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE"
systemctl daemon-reload
log_info "Berechtigungen setzen..."
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
# Eigentümer des Codes bleibt der primäre User (pdfocr); Instanzen laufen
# ggf. als anderer User, lesen aber nur den Code.
PRIMARY_USER="$(stat -c '%U' "$INSTALL_DIR/venv" 2>/dev/null || echo pdfocr)"
chown -R "$PRIMARY_USER":"$PRIMARY_USER" "$INSTALL_DIR"
log_info "Service starten..."
systemctl start "${SERVICE_NAME}.service"
sleep 2
log_info "Starte Instanzen wieder..."
FAIL=0
for unit in "${RUNNING[@]}"; do
systemctl start "$unit" || true
sleep 1
if systemctl is-active --quiet "$unit"; then
log_info "$unit"
else
log_error "$unit — journalctl -u $unit -n 30"
FAIL=1
fi
done
if systemctl is-active --quiet "${SERVICE_NAME}.service"; then
log_info "✅ Service läuft (Version $NEW_VERSION)"
echo
if [ "$FAIL" -eq 0 ]; then
log_info "Update auf $NEW_VERSION abgeschlossen ✓"
else
log_error "Service läuft nicht. journalctl -u $SERVICE_NAME -n 30"
log_warn "Update abgeschlossen, aber mindestens eine Instanz läuft nicht."
exit 1
fi