feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)
Datenverlust behoben: - Nach hartem Stopp blieb das Original in working/ liegen und wurde nie wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht. - TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf. Config-Drift sichtbar gemacht: - Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst. - Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes pdfa_level, beim Dienststart wie im Check. - Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt. Updater feldtauglich: - venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian- Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback. - apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh). - Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed und NRestarts statt sleep 1 + is-active. - Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf 0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab. - ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch. - lxc-compat.conf wird beim Update nachgezogen. - requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13). Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/). 135 Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""PDF OCR Hotfolder — Scanner-PDFs automatisch durchsuchbar machen."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -4,11 +4,24 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .config import ConfigError, load_config
|
||||
from .service import HotfolderService, PreflightError
|
||||
from .config import Config, ConfigError, config_warnings, load_config
|
||||
from .service import (
|
||||
HotfolderService,
|
||||
PreflightError,
|
||||
check_output_config,
|
||||
check_preflight,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Exit-Codes von --check-config (werden vom Updater ausgewertet)
|
||||
CHECK_OK = 0
|
||||
CHECK_WARN = 1
|
||||
CHECK_ERROR = 2
|
||||
|
||||
|
||||
def _setup_logging(level: str) -> None:
|
||||
@@ -19,6 +32,81 @@ def _setup_logging(level: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _log_config_warnings(cfg: Config) -> None:
|
||||
"""Schreibt Legacy- und Unbekannt-Warnungen beim Dienststart ins Log."""
|
||||
for warning in config_warnings(cfg):
|
||||
log.warning("Config-Warnung: %s", warning)
|
||||
|
||||
|
||||
def check_config(cfg_path: Path) -> int:
|
||||
"""Lädt und prüft die Config, ohne irgendetwas zu verarbeiten.
|
||||
|
||||
Returns:
|
||||
0 = alles sauber, 1 = nur Warnungen, 2 = Fehler (Config unbrauchbar
|
||||
oder Preflight scheitert).
|
||||
"""
|
||||
print(f"Prüfe Konfiguration: {cfg_path}")
|
||||
|
||||
try:
|
||||
cfg = load_config(cfg_path)
|
||||
except ConfigError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
print(f"FEHLER: {cfg_path} ist kein gültiges TOML: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
except OSError as e:
|
||||
print(f"FEHLER: {cfg_path} nicht lesbar: {e}", file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
|
||||
print(" Config gelesen.")
|
||||
for label, path in (("incoming", cfg.paths.incoming),
|
||||
("outgoing", cfg.paths.outgoing),
|
||||
("working ", cfg.paths.working),
|
||||
("error ", cfg.paths.error)):
|
||||
hint = "" if path.is_dir() else " (existiert noch nicht, "\
|
||||
"wird beim Start angelegt)"
|
||||
print(f" {label} = {path}{hint}")
|
||||
print(f" OCR-Sprachen = {cfg.ocr.languages}")
|
||||
print(f" Seiten-Timeout= {cfg.ocr.timeout} s")
|
||||
print(f" PDF/A-Level = {cfg.ocr.pdfa_level or '(aus)'}")
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
check_preflight(cfg.ocr.pdfa_level)
|
||||
print(" Preflight ok (tesseract, gs vorhanden).")
|
||||
except PreflightError as e:
|
||||
errors.append(str(e))
|
||||
try:
|
||||
check_output_config(cfg.output.original_on_success,
|
||||
cfg.output.archive_dir,
|
||||
cfg.output.name_mode)
|
||||
print(" [output]-Sektion ok.")
|
||||
except PreflightError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
warnings = config_warnings(cfg)
|
||||
if warnings:
|
||||
print(f"\n{len(warnings)} Warnung(en):")
|
||||
for w in warnings:
|
||||
print(f" WARNUNG: {w}")
|
||||
|
||||
if errors:
|
||||
print(f"\n{len(errors)} Fehler:", file=sys.stderr)
|
||||
for e in errors:
|
||||
print(f" FEHLER: {e}", file=sys.stderr)
|
||||
print("\nErgebnis: Config unbrauchbar — der Dienst würde nicht starten.",
|
||||
file=sys.stderr)
|
||||
return CHECK_ERROR
|
||||
|
||||
if warnings:
|
||||
print("\nErgebnis: Config nutzbar, aber mit Warnungen.")
|
||||
return CHECK_WARN
|
||||
|
||||
print("\nErgebnis: Config sauber.")
|
||||
return CHECK_OK
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pdf-ocr-hotfolder",
|
||||
@@ -29,6 +117,9 @@ def main() -> int:
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="Nur bestehende Dateien verarbeiten und beenden")
|
||||
parser.add_argument("--check-config", action="store_true", dest="check_config",
|
||||
help="Config nur prüfen, nichts verarbeiten "
|
||||
"(Exit 0 = sauber, 1 = Warnungen, 2 = Fehler)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
@@ -36,12 +127,17 @@ def main() -> int:
|
||||
print(f"Config nicht gefunden: {cfg_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.check_config:
|
||||
# Hat Vorrang vor --once: es wird nichts verarbeitet.
|
||||
return check_config(cfg_path)
|
||||
|
||||
try:
|
||||
cfg = load_config(cfg_path)
|
||||
except ConfigError as e:
|
||||
print(f"FEHLER: {e}", file=sys.stderr)
|
||||
return 2
|
||||
_setup_logging(cfg.log_level)
|
||||
_log_config_warnings(cfg)
|
||||
|
||||
service = HotfolderService(cfg)
|
||||
|
||||
|
||||
@@ -105,6 +105,18 @@ class Config:
|
||||
sftp: SftpUpload
|
||||
email: EmailNotify
|
||||
log_level: str = "INFO"
|
||||
# Einträge der TOML, die zu keiner Dataclass gehören (Tippfehler oder
|
||||
# Optionen aus älteren Versionen). load_config() sammelt sie hier ein,
|
||||
# statt sie stumm zu verwerfen — geloggt wird erst weiter oben, damit
|
||||
# load_config() ohne konfiguriertes Logging benutzbar bleibt.
|
||||
unknown_keys: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Bekannte Sektionen — alles andere landet in Config.unknown_keys
|
||||
_KNOWN_SECTIONS = ("paths", "ocr", "output", "verapdf", "upload", "notify", "logging")
|
||||
_KNOWN_UPLOAD_TARGETS = ("folder", "nextcloud", "sftp")
|
||||
_KNOWN_NOTIFY_TARGETS = ("email",)
|
||||
_KNOWN_LOGGING_KEYS = ("level",)
|
||||
|
||||
|
||||
def _section(data: dict[str, Any], *keys: str) -> dict[str, Any]:
|
||||
@@ -130,6 +142,42 @@ def _require_path(p: dict[str, Any], key: str, cfg_path: Path) -> Path:
|
||||
return Path(str(value))
|
||||
|
||||
|
||||
def _unknown_in(data: dict[str, Any], keys: tuple[str, ...],
|
||||
known: tuple[str, ...]) -> list[str]:
|
||||
"""Listet alle Keys einer Sektion auf, die nicht in `known` stehen."""
|
||||
label = ".".join(keys)
|
||||
return [f"[{label}].{k}" for k in _section(data, *keys) if k not in known]
|
||||
|
||||
|
||||
def _collect_unknown_keys(data: dict[str, Any]) -> list[str]:
|
||||
"""Sammelt alle TOML-Einträge, die nirgends ausgewertet werden.
|
||||
|
||||
Dazu zählen Tippfehler (`[ocr].langauges`), Optionen aus älteren
|
||||
Versionen und komplett unbekannte Sektionen. Die Reihenfolge entspricht
|
||||
der Datei, damit die Meldung reproduzierbar bleibt.
|
||||
"""
|
||||
unknown: list[str] = []
|
||||
for name, value in data.items():
|
||||
if name not in _KNOWN_SECTIONS:
|
||||
unknown.append(f"[{name}]" if isinstance(value, dict) else name)
|
||||
unknown += _unknown_in(data, ("paths",), tuple(Paths.__annotations__))
|
||||
unknown += _unknown_in(data, ("ocr",), tuple(OcrConfig.__annotations__))
|
||||
unknown += _unknown_in(data, ("output",), tuple(OutputConfig.__annotations__))
|
||||
unknown += _unknown_in(data, ("verapdf",), tuple(VeraPdfConfig.__annotations__))
|
||||
for sub in _section(data, "upload"):
|
||||
if sub not in _KNOWN_UPLOAD_TARGETS:
|
||||
unknown.append(f"[upload.{sub}]")
|
||||
unknown += _unknown_in(data, ("upload", "folder"), tuple(FolderUpload.__annotations__))
|
||||
unknown += _unknown_in(data, ("upload", "nextcloud"), tuple(NextcloudUpload.__annotations__))
|
||||
unknown += _unknown_in(data, ("upload", "sftp"), tuple(SftpUpload.__annotations__))
|
||||
for sub in _section(data, "notify"):
|
||||
if sub not in _KNOWN_NOTIFY_TARGETS:
|
||||
unknown.append(f"[notify.{sub}]")
|
||||
unknown += _unknown_in(data, ("notify", "email"), tuple(EmailNotify.__annotations__))
|
||||
unknown += _unknown_in(data, ("logging",), _KNOWN_LOGGING_KEYS)
|
||||
return unknown
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> Config:
|
||||
path = Path(path)
|
||||
with path.open("rb") as f:
|
||||
@@ -171,4 +219,54 @@ def load_config(path: str | Path) -> Config:
|
||||
paths=paths, ocr=ocr, output=output, verapdf=verapdf,
|
||||
folder=folder, nextcloud=nextcloud, sftp=sftp, email=email,
|
||||
log_level=log_level,
|
||||
unknown_keys=_collect_unknown_keys(data),
|
||||
)
|
||||
|
||||
|
||||
# ---- Legacy- und Plausibilitätswarnungen ----
|
||||
|
||||
# [ocr].timeout war vor 0.4.0 ein (wirkungsloses) Gesamt-Timeout mit Default
|
||||
# 1800. Ab diesem Wert gehen wir von einem Altwert aus.
|
||||
LEGACY_TIMEOUT_THRESHOLD = 900
|
||||
# Richtwert für das Seiten-Timeout seit 0.4.0
|
||||
RECOMMENDED_PAGE_TIMEOUT = 300
|
||||
|
||||
|
||||
def legacy_warnings(cfg: Config) -> list[str]:
|
||||
"""Warnt vor Einträgen, deren Bedeutung sich geändert hat.
|
||||
|
||||
Die Meldungen werden sowohl beim Dienststart ins Log geschrieben als auch
|
||||
von `--check-config` ausgegeben — deshalb steht der Text nur hier.
|
||||
"""
|
||||
out: list[str] = []
|
||||
if cfg.ocr.timeout >= LEGACY_TIMEOUT_THRESHOLD:
|
||||
out.append(
|
||||
f"[ocr].timeout = {cfg.ocr.timeout}: Seit Version 0.4.0 sind das "
|
||||
"Sekunden PRO SEITE (vorher ein wirkungsloses Gesamt-Timeout mit "
|
||||
f"Default 1800). Ein Wert >= {LEGACY_TIMEOUT_THRESHOLD} stammt fast "
|
||||
"sicher aus einer alten Config und lässt eine einzelne Seite "
|
||||
f"unnötig lange laufen. Richtwert: {RECOMMENDED_PAGE_TIMEOUT}."
|
||||
)
|
||||
if cfg.ocr.pdfa_level:
|
||||
out.append(
|
||||
f"[ocr].pdfa_level = {cfg.ocr.pdfa_level!r}: PDF/A-Ausgabe ist "
|
||||
"aktiv. Ghostscript 10.0.0-10.02.0 (Debian-12-Default) hat einen "
|
||||
"Bug, der zusammen mit skip_text das OCR blockiert (Issue #3). Der "
|
||||
"Preflight bricht ab, falls die installierte Ghostscript-Version "
|
||||
"betroffen ist; ab 10.02.1 ist alles in Ordnung."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def unknown_key_warnings(cfg: Config) -> list[str]:
|
||||
"""Macht die beim Laden verworfenen Einträge sichtbar."""
|
||||
return [
|
||||
f"Unbekannter Config-Eintrag {key} — wird ignoriert (Tippfehler oder "
|
||||
"Option aus einer älteren Version?)"
|
||||
for key in cfg.unknown_keys
|
||||
]
|
||||
|
||||
|
||||
def config_warnings(cfg: Config) -> list[str]:
|
||||
"""Alle Warnungen zu einer geladenen Config (Legacy + unbekannte Keys)."""
|
||||
return legacy_warnings(cfg) + unknown_key_warnings(cfg)
|
||||
|
||||
@@ -14,6 +14,10 @@ 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.
|
||||
@@ -114,13 +118,29 @@ def process_pdf(
|
||||
"""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_{out_name}" # Temp-Name, damit er != src.name ist
|
||||
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
|
||||
final_out = outgoing_dir / out_name
|
||||
|
||||
try:
|
||||
shutil.move(str(src), str(work_src))
|
||||
except OSError as e:
|
||||
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
|
||||
if _is_same_file(src, work_src):
|
||||
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
|
||||
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
|
||||
# die Datei bestenfalls auf sich selbst schieben.
|
||||
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
|
||||
working_dir, src.name)
|
||||
elif work_src.exists():
|
||||
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
|
||||
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
|
||||
return ProcessResult(
|
||||
src, final_out, False,
|
||||
f"in {working_dir} liegt bereits eine andere Datei namens "
|
||||
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
|
||||
"beim nächsten Lauf erneut versucht",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
shutil.move(str(src), str(work_src))
|
||||
except OSError as e:
|
||||
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
|
||||
|
||||
try:
|
||||
run_ocr(work_src, work_out, ocr_cfg)
|
||||
@@ -153,6 +173,14 @@ def process_pdf(
|
||||
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
|
||||
|
||||
|
||||
def _is_same_file(a: Path, b: Path) -> bool:
|
||||
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
|
||||
try:
|
||||
return a.resolve() == b.resolve()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
|
||||
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.
|
||||
|
||||
|
||||
@@ -9,13 +9,20 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from .config import Config
|
||||
from .processor import VALID_NAME_MODES, ProcessResult, _move_to_error, process_pdf
|
||||
from .processor import (
|
||||
OCR_TEMP_PREFIX,
|
||||
VALID_NAME_MODES,
|
||||
ProcessResult,
|
||||
_move_to_error,
|
||||
process_pdf,
|
||||
)
|
||||
from .uploaders import notify_email, upload_folder, upload_nextcloud, upload_sftp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -216,7 +223,7 @@ class HotfolderService:
|
||||
self.shutdown()
|
||||
|
||||
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:
|
||||
Anzahl fehlgeschlagener PDFs (0 = alles ok).
|
||||
@@ -243,11 +250,84 @@ class HotfolderService:
|
||||
# ---- Queue ----
|
||||
|
||||
def _scan_existing(self) -> None:
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen."""
|
||||
for p in self.cfg.paths.incoming.iterdir():
|
||||
"""Beim Start: bereits liegende PDFs aufgreifen.
|
||||
|
||||
Zuerst working/ (abgebrochene Läufe, siehe `_scan_working`), danach
|
||||
incoming/. Die Reihenfolge ist wichtig, damit eine Namenskollision
|
||||
zwischen beiden Verzeichnissen aufgelöst ist, bevor die
|
||||
incoming-Datei nach working/ will.
|
||||
"""
|
||||
self._scan_working()
|
||||
for p in sorted(self.cfg.paths.incoming.iterdir()):
|
||||
if _is_pdf(p):
|
||||
self.enqueue(p)
|
||||
|
||||
def _scan_working(self) -> None:
|
||||
"""Greift Dateien auf, die ein harter Stopp in working/ liegen ließ.
|
||||
|
||||
`process_pdf()` verschiebt das Original vor dem OCR nach working/.
|
||||
Wird der Dienst dort abgeschossen (SIGKILL nach TimeoutStopSec),
|
||||
bleibt es liegen und wurde bisher nie wieder angefasst — stiller
|
||||
Datenverlust. Die Datei wird deshalb an Ort und Stelle
|
||||
wiederaufgenommen; `process_pdf()` erkennt das und verschiebt sie
|
||||
nicht erneut.
|
||||
|
||||
Die Zwischendateien des abgebrochenen OCR-Laufs (Präfix `__ocr_`)
|
||||
sind unvollständige Fragmente: als Eingabe unbrauchbar und als
|
||||
Ergebnis wertlos. Sie werden gelöscht, damit sie niemand für ein
|
||||
fertiges PDF hält und damit der neue Lauf sauber startet.
|
||||
"""
|
||||
working = self.cfg.paths.working
|
||||
if not working.is_dir():
|
||||
return
|
||||
for p in sorted(working.iterdir()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name.startswith(OCR_TEMP_PREFIX):
|
||||
log.warning(
|
||||
"Unvollständiges OCR-Fragment aus abgebrochenem Lauf "
|
||||
"gefunden und gelöscht: %s", p,
|
||||
)
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
log.exception("Konnte OCR-Fragment %s nicht löschen", p)
|
||||
continue
|
||||
if not _is_pdf(p):
|
||||
continue
|
||||
target = self._free_resume_name(p)
|
||||
log.warning(
|
||||
"Abgebrochener Lauf wird fortgesetzt: %s lag noch in %s "
|
||||
"(Dienst wurde vermutlich hart gestoppt) — OCR startet neu",
|
||||
target.name, working,
|
||||
)
|
||||
self.enqueue(target)
|
||||
|
||||
def _free_resume_name(self, p: Path) -> Path:
|
||||
"""Entschärft eine Namenskollision zwischen working/ und incoming/.
|
||||
|
||||
Liegt in incoming/ eine gleichnamige (aber andere) Datei, würden beide
|
||||
dieselbe working- und dieselbe outgoing-Datei beanspruchen. Die
|
||||
wiederaufgenommene Datei bekommt deshalb einen Zeitstempel angehängt —
|
||||
dann laufen beide durch, statt dass eine überschrieben wird.
|
||||
"""
|
||||
if not (self.cfg.paths.incoming / p.name).exists():
|
||||
return p
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
renamed = p.with_name(f"{p.stem}_{ts}{p.suffix}")
|
||||
try:
|
||||
p.rename(renamed)
|
||||
except OSError:
|
||||
log.exception("Konnte %s nicht umbenennen — Wiederaufnahme unter "
|
||||
"Originalnamen", p)
|
||||
return p
|
||||
log.warning(
|
||||
"In %s liegt eine gleichnamige Datei %s — die wiederaufgenommene "
|
||||
"Datei wurde nach %s umbenannt, damit sich beide nicht "
|
||||
"überschreiben", self.cfg.paths.incoming, p.name, renamed.name,
|
||||
)
|
||||
return renamed
|
||||
|
||||
def enqueue(self, path: Path) -> None:
|
||||
if not _is_pdf(path):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user