From dd37622bb6d412e5050920b4f890bf5afae06f7e Mon Sep 17 00:00:00 2001 From: flfeders Date: Thu, 17 Sep 2026 18:22:43 +0200 Subject: [PATCH] feat(scan): support iX1600 duplex scanning on Linux --- agents/fedeo-device-agent/.dockerignore | 2 + agents/fedeo-device-agent/.env.example | 1 + agents/fedeo-device-agent/.gitignore | 1 + agents/fedeo-device-agent/Dockerfile | 8 +- agents/fedeo-device-agent/README.md | 62 +- .../docker-compose.example.yml | 2 - agents/fedeo-device-agent/package-lock.json | 572 ++++++++++++++++++ agents/fedeo-device-agent/package.json | 2 + .../scripts/scan_document.py | 74 +++ agents/fedeo-device-agent/src/api.ts | 10 +- agents/fedeo-device-agent/src/commands.ts | 29 +- agents/fedeo-device-agent/src/config.ts | 1 + agents/fedeo-device-agent/src/main.ts | 51 +- .../src/scan/postprocess.ts | 79 +-- agents/fedeo-device-agent/src/scan/sane.ts | 201 +++--- agents/fedeo-device-agent/src/types.ts | 3 + agents/fedeo-device-agent/tests/scan.test.ts | 154 +++++ .../tests/scan_document_test.py | 93 +++ agents/fedeo-device-agent/tsconfig.json | 1 - backend/src/routes/instanceAgentGateway.ts | 11 +- backend/tests/instanceAgentGateway.test.ts | 89 +++ frontend/components/FileScanModal.vue | 83 ++- frontend/pages/administration/scanners.vue | 25 + frontend/tests/file-scan.test.cjs | 72 +++ 24 files changed, 1411 insertions(+), 215 deletions(-) create mode 100644 agents/fedeo-device-agent/package-lock.json create mode 100644 agents/fedeo-device-agent/scripts/scan_document.py create mode 100644 agents/fedeo-device-agent/tests/scan.test.ts create mode 100644 agents/fedeo-device-agent/tests/scan_document_test.py create mode 100644 backend/tests/instanceAgentGateway.test.ts create mode 100644 frontend/tests/file-scan.test.cjs diff --git a/agents/fedeo-device-agent/.dockerignore b/agents/fedeo-device-agent/.dockerignore index a9ed404d..fd23636b 100644 --- a/agents/fedeo-device-agent/.dockerignore +++ b/agents/fedeo-device-agent/.dockerignore @@ -4,3 +4,5 @@ dist .env *.log *.tmp +__pycache__ +tests diff --git a/agents/fedeo-device-agent/.env.example b/agents/fedeo-device-agent/.env.example index e37ec6f2..cb905369 100644 --- a/agents/fedeo-device-agent/.env.example +++ b/agents/fedeo-device-agent/.env.example @@ -8,6 +8,7 @@ FEDEO_SCAN_FORMAT=pdf FEDEO_SCAN_RESOLUTION=300 FEDEO_SCAN_MODE=Color FEDEO_SCAN_SOURCE= +FEDEO_SCAN_TIMEOUT_SECONDS=900 FEDEO_SCAN_POSTPROCESS=false FEDEO_SCAN_POSTPROCESS_PROFILE=document FEDEO_SCAN_POSTPROCESS_PYTHON= diff --git a/agents/fedeo-device-agent/.gitignore b/agents/fedeo-device-agent/.gitignore index 0cd0b090..ffd73b16 100644 --- a/agents/fedeo-device-agent/.gitignore +++ b/agents/fedeo-device-agent/.gitignore @@ -4,3 +4,4 @@ node_modules .env *.log *.tmp +__pycache__/ diff --git a/agents/fedeo-device-agent/Dockerfile b/agents/fedeo-device-agent/Dockerfile index b6037853..d86e8402 100644 --- a/agents/fedeo-device-agent/Dockerfile +++ b/agents/fedeo-device-agent/Dockerfile @@ -2,8 +2,8 @@ FROM node:20-bookworm-slim AS build WORKDIR /app -COPY package.json tsconfig.json ./ -RUN npm install +COPY package.json package-lock.json tsconfig.json ./ +RUN npm ci COPY src ./src RUN npm run build @@ -12,8 +12,8 @@ FROM node:20-bookworm-slim AS runtime ENV NODE_ENV=production ENV FEDEO_WORK_DIR=/work -ENV FEDEO_SCAN_POSTPROCESS=true -ENV FEDEO_SCAN_POSTPROCESS_PROFILE=receipt +ENV FEDEO_SCAN_POSTPROCESS=false +ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python ENV FEDEO_SCAN_POSTPROCESS_STRICT=false diff --git a/agents/fedeo-device-agent/README.md b/agents/fedeo-device-agent/README.md index 555c7c95..4440347e 100644 --- a/agents/fedeo-device-agent/README.md +++ b/agents/fedeo-device-agent/README.md @@ -13,6 +13,8 @@ brew install node sane-backends scanimage -L ``` +Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). + Drucken nutzt später das macOS-Drucksystem/CUPS: ```bash @@ -23,11 +25,52 @@ lpstat -p ```bash sudo apt update -sudo apt install -y nodejs npm sane-utils cups +sudo apt install -y nodejs npm sane-utils python3-pil scanimage -L -lpstat -p ``` +Node.js muss mindestens Version 20 haben (`node --version`). CUPS ist nur für die optionale Druckererkennung erforderlich. + +## ScanSnap iX1600 unter Linux per USB + +Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist `04c5:1632`; die WLAN-Schnittstelle wird von diesem Backend nicht unterstützt. Quellen: [SANE-Geräteliste](https://sane-project.gitlab.io/website/sane-mfgs.html), [Fujitsu-Backend](https://sane-project.gitlab.io/website/man/sane-fujitsu.5.html). + +1. Scanner einschalten und per USB anschließen. `lsusb -d 04c5:1632` und `scanimage -L` müssen ihn anzeigen. Den vollständigen Gerätenamen übernehmen, beispielsweise `fujitsu:ScanSnap iX1600:123456`. +2. In FEDEO unter **Administration → Scanner** einen Agenten anlegen und den einmalig angezeigten Token sichern. +3. Im Verzeichnis `agents/fedeo-device-agent` die Abhängigkeiten installieren und konfigurieren: + + ```bash + npm ci + cp .env.example .env + chmod 600 .env + nano .env + ``` + + ```env + FEDEO_URL=https://deine-fedeo-instanz + FEDEO_AGENT_TOKEN=fedeo_agent_DEIN_TOKEN + FEDEO_SCANNER_NAME="fujitsu:ScanSnap iX1600:DEINE_SERIENNUMMER" + FEDEO_SCAN_SOURCE="ADF Duplex" + FEDEO_SCAN_FORMAT=pdf + FEDEO_SCAN_RESOLUTION=300 + FEDEO_SCAN_MODE=Color + FEDEO_SCAN_POSTPROCESS=false + FEDEO_SCAN_TIMEOUT_SECONDS=900 + ``` + + `FEDEO_URL` ist die Basis-URL, unter der `/instance-agent/heartbeat` erreichbar ist, ohne zusätzliches `/api`. +4. `npm run build` und `npm start` ausführen. In FEDEO muss der Agent als online erscheinen. +5. In der Scannerverwaltung das erkannte Gerät wählen, **ScanSnap iX1600: A4, Duplex, PDF** anklicken und speichern. +6. Papier einlegen. Unter **Dateien → Scan** den Agenten wählen und **Scannen** anklicken. Der gesamte Stapel landet als ein PDF im aktuellen Ordner. Für den ersten Test zwei Blätter mit markierten Vorder- und Rückseiten verwenden: Das PDF muss vier Seiten in dieser Reihenfolge enthalten. + +Der Agent liest im SANE-Batch-Modus bis zum leeren Einzug. Vorder- und Rückseiten werden in der vom Scanner gelieferten Reihenfolge gespeichert, einschließlich leerer Rückseiten. Es gibt keine automatische Leerseitenentfernung. „ADF Front“ scannt einen Stapel einseitig, „Flatbed“ eine einzelne Seite. Einzug-Scans benötigen PDF; PNG und TIFF sind für Einzelseiten verfügbar. Die Optionen entsprechen dem [scanimage-Handbuch](https://sane-project.gitlab.io/website/man/scanimage.1.html). + +**Papierformat:** Das iX1600-Profil verwendet A4 mit 210 × 297 mm, einschließlich der Fujitsu-Einstellungen für die Papiergröße. Für andere Größen die Einstellung anpassen; „Scanner-Vorgabe“ übernimmt den am Treiber eingestellten Scanbereich. Die SANE-Quellen und Optionen lassen sich ohne Scan mit `scanimage -d 'DEIN_GERAETENAME' --source 'ADF Duplex' --help` prüfen. + +**USB-Zugriff:** Wenn `lsusb` das Gerät sieht, `scanimage -L` jedoch nicht, zuerst Kabel, Stromversorgung, SANE-Version und Zugriffsrechte prüfen. Debian/Ubuntu/Mint liefern normalerweise passende udev-Regeln mit `libsane-common`. Andere Scanprogramme schließen. Einen dauerhaft laufenden Agenten unter einem Benutzer betreiben, der ebenfalls mit `scanimage -L` auf das Gerät zugreifen kann. + +**Lange Stapel und Fehler:** Das Scan-Zeitlimit beträgt standardmäßig 15 Minuten; die Dateierstellung hat ein eigenes gleich langes Limit. Der Agent hält seinen Online-Status währenddessen aktuell. Der Dialog kann im Hintergrund weiterlaufen; Schließen stoppt den Scan nicht. Verbindungsfehler beim Abfragen des Status lösen keinen neuen Scan aus. Bei Papierstau, unvollständigem Duplex-Scan oder Uploadfehler wird kein automatischer erneuter Papiereinzug gestartet. Die Rohseiten bzw. die fertige Datei bleiben im gemeldeten Arbeitsverzeichnis zur Wiederherstellung erhalten. Nach erfolgreichem Upload werden sie gelöscht. Das Upload-Limit beträgt 100 MiB pro PDF; gegebenenfalls den Stapel teilen oder die Auflösung reduzieren. + ## Konfiguration ```bash @@ -49,7 +92,7 @@ Wenn `FEDEO_SCANNER_NAME` leer bleibt, verwendet `scanimage` den Standard-Scanne ## Entwicklung ```bash -npm install +npm ci npm run dev ``` @@ -70,7 +113,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python FEDEO_SCAN_POSTPROCESS_STRICT=false ``` -Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, lädt der Agent den Rohscan hoch, statt den Auftrag komplett fehlschlagen zu lassen. +Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt. Profile: @@ -109,6 +152,17 @@ npm run build npm start ``` +## Tests + +```bash +npm test +npm run test:python +``` + +Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich. + +Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet. + ## FEDEO-Endpunkte Der Agent nutzt: diff --git a/agents/fedeo-device-agent/docker-compose.example.yml b/agents/fedeo-device-agent/docker-compose.example.yml index a582c876..fff4b686 100644 --- a/agents/fedeo-device-agent/docker-compose.example.yml +++ b/agents/fedeo-device-agent/docker-compose.example.yml @@ -9,8 +9,6 @@ services: - .env environment: FEDEO_WORK_DIR: /work - FEDEO_SCAN_POSTPROCESS: "true" - FEDEO_SCAN_POSTPROCESS_PROFILE: receipt FEDEO_SCAN_POSTPROCESS_PYTHON: /opt/fedeo-device-agent/.venv-opencv/bin/python FEDEO_SCAN_POSTPROCESS_STRICT: "false" volumes: diff --git a/agents/fedeo-device-agent/package-lock.json b/agents/fedeo-device-agent/package-lock.json new file mode 100644 index 00000000..9aefcfbf --- /dev/null +++ b/agents/fedeo-device-agent/package-lock.json @@ -0,0 +1,572 @@ +{ + "name": "@fedeo/device-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@fedeo/device-agent", + "version": "0.1.0", + "bin": { + "fedeo-device-agent": "dist/main.js" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/agents/fedeo-device-agent/package.json b/agents/fedeo-device-agent/package.json index fe8b56e3..3bc3abe5 100644 --- a/agents/fedeo-device-agent/package.json +++ b/agents/fedeo-device-agent/package.json @@ -12,6 +12,8 @@ "build": "tsc -p tsconfig.json", "dev": "tsx src/main.ts", "start": "node dist/main.js", + "test": "node --import tsx --test tests/*.test.ts", + "test:python": "python3 -m unittest discover -s tests -p '*_test.py'", "setup:opencv": "sh scripts/setup-opencv.sh" }, "dependencies": {}, diff --git a/agents/fedeo-device-agent/scripts/scan_document.py b/agents/fedeo-device-agent/scripts/scan_document.py new file mode 100644 index 00000000..a6759984 --- /dev/null +++ b/agents/fedeo-device-agent/scripts/scan_document.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not.""" +import argparse +import json +from pathlib import Path + +from PIL import Image + + +def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False): + if not inputs: + raise ValueError("Keine Seiten vorhanden") + output = Path(output) + file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower()) + if not file_format: + raise ValueError("Unbekanntes Ausgabeformat") + if file_format != "PDF" and len(inputs) != 1: + raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat") + + warnings = [] + processor = None + if use_postprocess: + try: + from opencv_postprocess import postprocess + processor = postprocess + except ImportError: + if strict: + raise + warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.") + + partial = output.with_name(output.name + ".partial") + try: + for index, input_path in enumerate(inputs): + input_path = Path(input_path) + image_path = input_path + corrected = input_path.with_name(input_path.stem + ".corrected.png") + if processor: + try: + processor(input_path, corrected, profile) + image_path = corrected + except Exception: + if strict: + raise + warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.") + + # Only one decoded page is kept in memory, even for large duplex stacks. + with Image.open(image_path) as image: + with image.convert("RGB") as rgb: + if file_format == "PDF": + rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0) + else: + rgb.save(partial, file_format, dpi=(resolution, resolution)) + corrected.unlink(missing_ok=True) + partial.replace(output) + finally: + partial.unlink(missing_ok=True) + + return {"pageCount": len(inputs), "warnings": warnings} + + +def main(): + parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen") + parser.add_argument("--inputs", nargs="+", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--resolution", type=float, required=True) + parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document") + parser.add_argument("--postprocess", action="store_true") + parser.add_argument("--strict", action="store_true") + args = parser.parse_args() + print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict))) + + +if __name__ == "__main__": + main() diff --git a/agents/fedeo-device-agent/src/api.ts b/agents/fedeo-device-agent/src/api.ts index 4af73a9c..e4c9831e 100644 --- a/agents/fedeo-device-agent/src/api.ts +++ b/agents/fedeo-device-agent/src/api.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises" +import { readFile, stat } from "node:fs/promises" import { basename } from "node:path" import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js" @@ -16,10 +16,11 @@ export class FedeoApi { } } - private async request(path: string, init: RequestInit = {}): Promise { + private async request(path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise { const response = await fetch(this.url(path), { ...init, headers: this.headers(init.headers), + signal: AbortSignal.timeout(timeoutMs), }) if (!response.ok) { @@ -51,6 +52,9 @@ export class FedeoApi { } async uploadScan(jobId: string, result: ScanResult) { + if ((await stat(result.path)).size > 100 * 1024 * 1024) { + throw new Error("Die Scan-Datei überschreitet das Upload-Limit von 100 MiB. Bitte den Stapel aufteilen oder die Auflösung reduzieren.") + } const form = new FormData() const fileBuffer = await readFile(result.path) const file = new File([fileBuffer], result.filename || basename(result.path), { @@ -62,6 +66,6 @@ export class FedeoApi { return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, { method: "POST", body: form, - }) + }, 120_000) } } diff --git a/agents/fedeo-device-agent/src/commands.ts b/agents/fedeo-device-agent/src/commands.ts index 66fbf23e..299955ea 100644 --- a/agents/fedeo-device-agent/src/commands.ts +++ b/agents/fedeo-device-agent/src/commands.ts @@ -8,7 +8,7 @@ export type CommandResult = { export const commandExists = (command: string) => new Promise((resolve) => { - const child = spawn("sh", ["-lc", `command -v ${command}`]) + const child = spawn("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "--", command]) child.on("error", () => resolve(false)) child.on("close", (code) => resolve(code === 0)) }) @@ -16,33 +16,50 @@ export const commandExists = (command: string) => export const runCommand = ( command: string, args: string[], - options: { timeoutMs?: number } = {} + options: { timeoutMs?: number; cwd?: string } = {} ) => new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], + cwd: options.cwd, }) const stdout: Buffer[] = [] const stderr: Buffer[] = [] + let timedOut = false + let killTimeout: ReturnType | undefined const timeout = options.timeoutMs ? setTimeout(() => { + timedOut = true child.kill("SIGTERM") - reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`)) + killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000) }, options.timeoutMs) : null child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))) child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk))) - child.on("error", reject) - child.on("close", (code) => { + child.on("error", (error) => { if (timeout) clearTimeout(timeout) + if (killTimeout) clearTimeout(killTimeout) + reject(error) + }) + child.on("close", (code, signal) => { + if (timeout) clearTimeout(timeout) + if (killTimeout) clearTimeout(killTimeout) + if (timedOut) { + reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`)) + return + } + if (signal) { + reject(new Error(`${command} wurde durch ${signal} beendet`)) + return + } resolve({ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), - code: code ?? 0, + code: code ?? 1, }) }) }) diff --git a/agents/fedeo-device-agent/src/config.ts b/agents/fedeo-device-agent/src/config.ts index 7a6d815f..dd8ad075 100644 --- a/agents/fedeo-device-agent/src/config.ts +++ b/agents/fedeo-device-agent/src/config.ts @@ -60,6 +60,7 @@ export const loadConfig = (): AgentConfig => { scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300), scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color", scanSource: optional(process.env.FEDEO_SCAN_SOURCE), + scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900), scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false), postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE), postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(), diff --git a/agents/fedeo-device-agent/src/main.ts b/agents/fedeo-device-agent/src/main.ts index f7a44fe7..0d0b7eeb 100644 --- a/agents/fedeo-device-agent/src/main.ts +++ b/agents/fedeo-device-agent/src/main.ts @@ -1,10 +1,13 @@ #!/usr/bin/env node import os from "node:os" +import path from "node:path" +import { rm } from "node:fs/promises" import { FedeoApi } from "./api.js" import { loadConfig } from "./config.js" import { log } from "./logger.js" import { listPrinters } from "./print/cups.js" import { hasSane, listScanners, runScan } from "./scan/sane.js" +import { AgentHeartbeat } from "./types.js" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -25,11 +28,14 @@ const main = async () => { while (true) { try { - const scannerNames = await listScanners() - const printerNames = await listPrinters() + const scannerNames = await listScanners().catch((error) => { + log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error)) + return [] + }) + const printerNames = await listPrinters().catch(() => []) const scanAvailable = await hasSane() - const heartbeat = await api.heartbeat({ + const heartbeatPayload: AgentHeartbeat = { capabilities: { scan: scanAvailable, print: printerNames.length > 0, @@ -44,7 +50,8 @@ const main = async () => { node: process.version, uptimeSeconds: Math.round(os.uptime()), }, - }) + } + const heartbeat = await api.heartbeat(heartbeatPayload) if (heartbeat.pendingScanJobs > 0) { log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`) @@ -62,14 +69,44 @@ const main = async () => { scannerName: next.job.scannerName || config.scannerName || "default", }) + // Keep online status fresh during long scans without probing an occupied USB scanner. + let heartbeatRunning = false + const heartbeatTimer = setInterval(async () => { + if (heartbeatRunning) return + heartbeatRunning = true + try { + await api.heartbeat(heartbeatPayload) + } catch (error) { + log.warn("Heartbeat während Scan fehlgeschlagen", stringifyError(error)) + } finally { + heartbeatRunning = false + } + }, 30_000) + try { await api.updateScanJobStatus(next.job.id, "running") - const scanResult = await runScan(config, next.job) - await api.uploadScan(next.job.id, scanResult) + const jobId = next.job.id + const reportProgress = async (message: string) => { + await api.updateScanJobStatus(jobId, "running", message).catch((error) => { + log.warn("Scan-Status konnte nicht übertragen werden", stringifyError(error)) + }) + } + const scanResult = await runScan(config, next.job, reportProgress) + await reportProgress(`${scanResult.pageCount} Seiten gescannt. ${scanResult.warnings.join(" ")}`.trim()) + try { + await api.uploadScan(jobId, scanResult) + } catch (error) { + throw new Error(`${stringifyError(error)} Scan-Datei bleibt zur Wiederherstellung unter ${scanResult.path}.`) + } + + await rm(path.dirname(scanResult.path), { recursive: true, force: true }).catch((error) => { + log.warn("Scan gespeichert; temporäre Dateien konnten nicht entfernt werden", stringifyError(error)) + }) log.info("Scan-Auftrag abgeschlossen", { jobId: next.job.id, file: scanResult.filename, + pages: scanResult.pageCount, }) } catch (error) { const message = stringifyError(error) @@ -79,6 +116,8 @@ const main = async () => { }) await api.updateScanJobStatus(next.job.id, "failed", message) + } finally { + clearInterval(heartbeatTimer) } } catch (error) { log.error("Agent-Schleife fehlgeschlagen", stringifyError(error)) diff --git a/agents/fedeo-device-agent/src/scan/postprocess.ts b/agents/fedeo-device-agent/src/scan/postprocess.ts index 9b99df2d..b70d2214 100644 --- a/agents/fedeo-device-agent/src/scan/postprocess.ts +++ b/agents/fedeo-device-agent/src/scan/postprocess.ts @@ -1,66 +1,45 @@ import path from "node:path" import { fileURLToPath } from "node:url" import { AgentConfig, ScanResult } from "../types.js" -import { commandExists, runCommand } from "../commands.js" +import { runCommand } from "../commands.js" -const currentFile = fileURLToPath(import.meta.url) -const agentRoot = path.resolve(path.dirname(currentFile), "../..") -const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py") +const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const documentScript = path.join(agentRoot, "scripts/scan_document.py") -const extensionMimeTypes: Record = { - ".pdf": "application/pdf", - ".png": "image/png", - ".tif": "image/tiff", - ".tiff": "image/tiff", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", +export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => { + const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL" + try { + const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 }) + if (result.code === 0) return + } catch { + // Report missing runtimes before the scanner consumes any paper. + } + throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.") } -const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => { - const ext = path.extname(filename) - if (ext) return filename - return `${filename}.${format}` -} - -export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => { - if (!await commandExists(config.postprocessPython)) return false - - const result = await runCommand(config.postprocessPython, [ - "-c", - "import cv2, PIL, numpy", - ], { timeoutMs: 10_000 }) - - return result.code === 0 -} - -export const postprocessScan = async ( +export const createScanDocument = async ( config: AgentConfig, - inputPath: string, - outputFilename: string, - outputFormat: AgentConfig["scanFormat"], + pages: string[], + outputPath: string, + resolution: number, + postprocess: boolean, profile: AgentConfig["postprocessProfile"] ): Promise => { - const filename = ensureOutputExtension(outputFilename, outputFormat) - const outputPath = path.join(config.workDir, filename) + const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile] + if (postprocess) args.push("--postprocess") + if (config.postprocessStrict) args.push("--strict") + args.push("--inputs", ...pages) - const result = await runCommand(config.postprocessPython, [ - postprocessScript, - "--input", - inputPath, - "--output", - outputPath, - "--profile", - profile, - ], { timeoutMs: 5 * 60 * 1000 }) + const result = await runCommand(config.postprocessPython, args, { + timeoutMs: config.scanTimeoutSeconds * 1000, + }) + if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden") - if (result.code !== 0) { - throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`) - } - - const extension = path.extname(outputPath).toLowerCase() + const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] } return { path: outputPath, - filename, - mimeType: extensionMimeTypes[extension] || "application/octet-stream", + filename: path.basename(outputPath), + mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff", + ...details, } } diff --git a/agents/fedeo-device-agent/src/scan/sane.ts b/agents/fedeo-device-agent/src/scan/sane.ts index f3debea4..c65f5f73 100644 --- a/agents/fedeo-device-agent/src/scan/sane.ts +++ b/agents/fedeo-device-agent/src/scan/sane.ts @@ -1,149 +1,118 @@ -import { mkdirSync } from "node:fs" +import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises" import path from "node:path" import { AgentConfig, ScanJob, ScanResult } from "../types.js" import { commandExists, runCommand } from "../commands.js" -import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js" -import { log } from "../logger.js" +import { checkScanRuntime, createScanDocument } from "./postprocess.js" -const mimeTypes = { - pdf: "application/pdf", - png: "image/png", - tiff: "image/tiff", -} - -const stringSetting = (settings: Record | undefined, key: string) => { - const value = settings?.[key] +const stringSetting = (settings: Record, key: string) => { + const value = settings[key] return typeof value === "string" && value.trim() ? value.trim() : undefined } -const numberSetting = (settings: Record | undefined, key: string) => { - const value = settings?.[key] - if (typeof value === "number" && Number.isFinite(value)) return value - if (typeof value === "string" && value.trim()) { - const parsed = Number(value) - if (Number.isFinite(parsed)) return parsed - } - - return undefined -} - -const booleanSetting = (settings: Record | undefined, key: string, fallback: boolean) => { - const value = settings?.[key] +const booleanSetting = (settings: Record, key: string, fallback: boolean) => { + const value = settings[key] if (typeof value === "boolean") return value if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase()) return fallback } -const profileSetting = ( - settings: Record | undefined, - fallback: AgentConfig["postprocessProfile"] -): AgentConfig["postprocessProfile"] => { - const value = settings?.postprocessProfile - if (value === "document" || value === "receipt" || value === "raw") return value - return fallback -} - -const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => { - const ext = path.extname(filename) - if (!ext) return `${filename}.${format}` - - const expectedExt = `.${format}` - if (ext.toLowerCase() === expectedExt) return filename - return `${filename.slice(0, -ext.length)}${expectedExt}` -} - -const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({ - path: scanOutputPath, - filename: `${jobId}.raw.png`, - mimeType: "image/png", -}) - export const hasSane = () => commandExists("scanimage") export const listScanners = async () => { if (!await hasSane()) return [] - const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 }) if (result.code !== 0) return [] - - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.startsWith("device `")) - .map((line) => line.match(/device `([^']+)'/)?.[1]) + return result.stdout.split(/\r?\n/) + .map((line) => line.trim().match(/^device `([^']+)'/)?.[1]) .filter((device): device is string => Boolean(device)) } -export const runScan = async (config: AgentConfig, job: ScanJob): Promise => { - if (!await hasSane()) { - throw new Error("scanimage ist nicht installiert oder nicht im PATH") - } - - mkdirSync(config.workDir, { recursive: true }) +export const runScan = async ( + config: AgentConfig, + job: ScanJob, + reportProgress: (message: string) => Promise = async () => {} +): Promise => { + if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH") const settings = job.settings || {} - const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat - const resolution = numberSetting(settings, "resolution") || config.scanResolution + const format = stringSetting(settings, "format") || config.scanFormat + if (!["pdf", "png", "tiff"].includes(format)) throw new Error("Scan-Format muss PDF, PNG oder TIFF sein") + const resolution = Number(settings.resolution ?? config.scanResolution) + if (!Number.isFinite(resolution) || resolution < 75 || resolution > 1200) { + throw new Error("Die Scan-Auflösung muss zwischen 75 und 1200 dpi liegen") + } const mode = stringSetting(settings, "mode") || config.scanMode const source = stringSetting(settings, "source") || config.scanSource const scannerName = job.scannerName || config.scannerName - const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format) - const outputPath = path.join(config.workDir, filename) - const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) - const postprocessProfile = profileSetting(settings, config.postprocessProfile) - const scanFormat = shouldPostprocess ? "png" : format - const scanOutputPath = shouldPostprocess - ? path.join(config.workDir, `${job.id}.raw.png`) - : outputPath + const paperSize = stringSetting(settings, "paperSize") || "auto" + if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat") + const batch = Boolean(source && /adf|document feeder/i.test(source)) + if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen") + const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) + const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile + if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil") + await checkScanRuntime(config, postprocess) - const args = [ - "--format", - scanFormat, - "--resolution", - String(resolution), - "--mode", - mode, - "--output-file", - scanOutputPath, - ] + // Each physical scan has its own directory. A filename cannot escape it or overwrite another job. + await mkdir(config.workDir, { recursive: true, mode: 0o700 }) + const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-")) + const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/")) + const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan" + const outputPath = path.join(jobDir, `${stem}.${format}`) - if (source) args.push("--source", source) - if (scannerName) args.push("--device-name", scannerName) + try { + // Device/source come first: changing source can change the remaining SANE options. + const args: string[] = [] + if (scannerName) args.push("--device-name", scannerName) + if (source) args.push("--source", source) + args.push("--mode", mode, "--resolution", String(resolution), "--format=png") + if (paperSize !== "auto") { + const [width, height] = paperSize === "A4" ? [210, 297] : [215.9, 279.4] + if (scannerName?.startsWith("fujitsu:")) { + args.push("--page-width", String(width), "--page-height", String(height)) + } + args.push("-l", "0", "-t", "0", "-x", String(width), "-y", String(height)) + } + if (batch) { + // Relative pattern avoids interpreting '%' in a user-configured work directory. + args.push("--batch=page-%06d.png", "--batch-start=1", "--batch-increment=1") + } else { + args.push("--output-file", "page-000001.png") + } + await reportProgress(batch ? "Dokumente werden eingezogen und gescannt..." : "Seite wird gescannt...") + const result = await runCommand("scanimage", args, { + cwd: jobDir, + timeoutMs: config.scanTimeoutSeconds * 1000, + }) - const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 }) - - if (result.code !== 0) { - throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`) - } - - if (shouldPostprocess) { - if (!await hasOpenCvPostprocessRuntime(config)) { - const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar" - if (config.postprocessStrict) throw new Error(message) - - log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, { - jobId: job.id, - python: config.postprocessPython, - }) - return fallbackRawResult(scanOutputPath, job.id) + // SANE_STATUS_NO_DOCS (7) is the expected end of an unlimited ADF batch. + // Never turn paper jams, I/O errors or canceled scans into a successful partial upload. + if (result.code !== 0 && !(batch && result.code === 7)) { + throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`) + } + const names = (await readdir(jobDir)) + .filter((name) => /^page-\d+\.png$/.test(name)) + .sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0])) + const pages: string[] = [] + for (const [index, name] of names.entries()) { + const page = path.join(jobDir, name) + // Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt. + if ((await stat(page)).size === 0) { + if (batch && result.code === 7 && index === names.length - 1) continue + throw new Error(`Unvollständige Scan-Seite: ${name}`) + } + if (Number(name.match(/\d+/)![0]) !== index + 1) throw new Error("Im Scan fehlen Seiten") + pages.push(page) + } + if (!pages.length) throw new Error("Keine Seiten gescannt. Bitte Papier in den Einzug legen.") + if (source?.toLowerCase().includes("duplex") && pages.length % 2 !== 0) { + throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.") } - try { - return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile) - } catch (error) { - if (config.postprocessStrict) throw error - - log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", { - jobId: job.id, - error: error instanceof Error ? error.message : String(error), - }) - return fallbackRawResult(scanOutputPath, job.id) - } - } - - return { - path: outputPath, - filename, - mimeType: mimeTypes[format] || "application/octet-stream", + await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`) + return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`) } } diff --git a/agents/fedeo-device-agent/src/types.ts b/agents/fedeo-device-agent/src/types.ts index 45f2f172..a30dd125 100644 --- a/agents/fedeo-device-agent/src/types.ts +++ b/agents/fedeo-device-agent/src/types.ts @@ -9,6 +9,7 @@ export type AgentConfig = { scanResolution: number scanMode: string scanSource?: string + scanTimeoutSeconds: number scanPostprocess: boolean postprocessProfile: "document" | "receipt" | "raw" postprocessPython: string @@ -45,4 +46,6 @@ export type ScanResult = { path: string filename: string mimeType: string + pageCount: number + warnings: string[] } diff --git a/agents/fedeo-device-agent/tests/scan.test.ts b/agents/fedeo-device-agent/tests/scan.test.ts new file mode 100644 index 00000000..d2fd8182 --- /dev/null +++ b/agents/fedeo-device-agent/tests/scan.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict" +import test, { TestContext } from "node:test" +import os from "node:os" +import path from "node:path" +import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" +import { runScan } from "../src/scan/sane.js" +import { commandExists, runCommand } from "../src/commands.js" +import { AgentConfig, ScanJob } from "../src/types.js" + +const fakeScanner = `#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +from PIL import Image +args = sys.argv[1:] +Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args)) +batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None) +single = args[args.index('--output-file') + 1] if '--output-file' in args else None +count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1 +for index in range(1, count + 1): + target = batch % index if batch else single + Image.new('RGB', (100 + index, 200 + index), (index * 10, 20, 30)).save(target) +if batch and os.environ.get('SCAN_TEST_EMPTY_FINAL') == '1': + Path(batch % (count + 1)).touch() +if os.environ.get('SCAN_TEST_CORRUPT') == '1': + Path(batch % 2 if batch else single).write_bytes(b'not a PNG') +if os.environ.get('SCAN_TEST_EMPTY_MIDDLE') == '1': + Path(batch % 2).write_bytes(b'') +sys.stderr.write('simulated scanner status') +sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '7' if batch else '0'))) +` + +const setup = async (t: TestContext) => { + const dir = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-test-")) + const oldEnv = { ...process.env } + t.after(async () => { + for (const key of Object.keys(process.env)) if (!(key in oldEnv)) delete process.env[key] + Object.assign(process.env, oldEnv) + await rm(dir, { recursive: true, force: true }) + }) + await writeFile(path.join(dir, "scanimage"), fakeScanner) + await chmod(path.join(dir, "scanimage"), 0o755) + process.env.PATH = `${dir}:${process.env.PATH}` + process.env.SCAN_TEST_CALL = path.join(dir, "call.json") + const config: AgentConfig = { + fedeoUrl: "https://example.invalid", agentToken: "test", pollSeconds: 5, + workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test", + scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex", + scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document", + postprocessPython: "python3", postprocessStrict: false, + } + const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } } + return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] } +} + +test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => { + const { config, job, args } = await setup(t) + process.env.SCAN_TEST_EMPTY_FINAL = "1" + const progress: string[] = [] + const result = await runScan(config, job, async (message) => { progress.push(message) }) + assert.equal(result.pageCount, 4) + assert.equal(result.mimeType, "application/pdf") + assert.equal((await readFile(result.path)).subarray(0, 5).toString(), "%PDF-") + assert.deepEqual(result.warnings, []) + const call = await args() + assert.ok(call.includes("--batch=page-%06d.png")) + assert.ok(!call.includes("--output-file")) + assert.ok(!call.includes("--batch-double")) + assert.equal(call[call.indexOf("--source") + 1], "ADF Duplex") + assert.equal(call[call.indexOf("--page-height") + 1], "297") + assert.equal(call[call.indexOf("-y") + 1], "297") + assert.ok(progress.at(-1)?.startsWith("4 Seiten")) +}) + +test("empty ADF reports missing paper instead of creating an empty PDF", async (t) => { + const { config, job } = await setup(t) + process.env.SCAN_TEST_PAGES = "0" + process.env.SCAN_TEST_EMPTY_FINAL = "1" + await assert.rejects(runScan(config, job), /Keine Seiten gescannt/) +}) + +test("paper jam after partial capture fails and keeps raw pages for recovery", async (t) => { + const { config, job } = await setup(t) + process.env.SCAN_TEST_EXIT = "6" + await assert.rejects(runScan(config, job), /simulated scanner status.*Rohscans/) + const [jobDir] = await readdir(config.workDir) + assert.equal((await readdir(path.join(config.workDir, jobDir))).filter((name) => name.endsWith(".png")).length, 4) +}) + +test("odd duplex page count cannot silently lose a back side", async (t) => { + const { config, job } = await setup(t) + process.env.SCAN_TEST_PAGES = "3" + await assert.rejects(runScan(config, job), /Rückseite/) +}) + +test("ADF simplex keeps odd page counts and scanimage success is accepted", async (t) => { + const { config, job } = await setup(t) + process.env.SCAN_TEST_PAGES = "3" + process.env.SCAN_TEST_EXIT = "0" + job.settings!.source = "ADF Front" + assert.equal((await runScan(config, job)).pageCount, 3) +}) + +test("corrupt or empty interior pages fail instead of disappearing from a successful PDF", async (t) => { + const { config, job } = await setup(t) + process.env.SCAN_TEST_CORRUPT = "1" + await assert.rejects(runScan(config, job), /cannot identify image file/) + delete process.env.SCAN_TEST_CORRUPT + process.env.SCAN_TEST_EMPTY_MIDDLE = "1" + await assert.rejects(runScan(config, job), /Unvollständige Scan-Seite/) +}) + +test("non-PDF feeder output and invalid resolution are rejected before feeding paper", async (t) => { + const { config, job, args } = await setup(t) + job.settings!.format = "png" + await assert.rejects(runScan(config, job), /PDF/) + delete job.settings!.format + job.settings!.resolution = -300 + await assert.rejects(runScan(config, job), /Auflösung/) + await assert.rejects(args(), /ENOENT/) +}) + +test("missing image runtime is detected before feeding paper", async (t) => { + const { config, job, args } = await setup(t) + config.postprocessPython = "/not-installed/python" + await assert.rejects(runScan(config, job), /Python\/Pillow/) + await assert.rejects(args(), /ENOENT/) +}) + +test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => { + const { config, job, args } = await setup(t) + job.settings = { source: "Flatbed", format: "png" } + job.requestedFilename = "../../outside.pdf" + const first = await runScan(config, job) + const second = await runScan(config, job) + assert.equal(first.filename, "outside.png") + assert.equal(first.pageCount, 1) + assert.equal(first.mimeType, "image/png") + assert.ok(first.path.startsWith(`${config.workDir}${path.sep}`)) + assert.notEqual(path.dirname(first.path), path.dirname(second.path)) + assert.ok((await args()).includes("--output-file")) + assert.ok(!(await args()).some((arg) => arg.startsWith("--batch"))) +}) + +test("a process terminated by a signal is never reported as a successful scan", async () => { + await assert.rejects(runCommand(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"]), /SIGTERM/) +}) + +test("a scan timeout terminates the child and reports failure", async () => { + await assert.rejects(runCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 50 }), /beendet/) +}) + +test("command discovery treats executable paths as data", async () => { + assert.equal(await commandExists("python3; exit 0"), false) +}) diff --git a/agents/fedeo-device-agent/tests/scan_document_test.py b/agents/fedeo-device-agent/tests/scan_document_test.py new file mode 100644 index 00000000..9cff5669 --- /dev/null +++ b/agents/fedeo-device-agent/tests/scan_document_test.py @@ -0,0 +1,93 @@ +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from PIL import Image, PdfParser + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from scan_document import create_document + + +class ScanDocumentTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def pages(self, count=4): + paths = [] + for index in range(1, count + 1): + target = self.root / f"page-{index:06}.png" + # Distinct geometry identifies every front/back page and its order. + with Image.new("RGB", (100 + index, 200 + index), "white") as image: + image.save(target) + paths.append(target) + return paths + + def assert_pdf(self, output, count, resolution=300): + with PdfParser.PdfParser(filename=str(output)) as pdf: + self.assertEqual(len(pdf.pages), count) + for index, reference in enumerate(pdf.pages, 1): + page = pdf.read_indirect(reference) + box = page[b"MediaBox"] + self.assertAlmostEqual(box[2], (100 + index) * 72 / resolution, places=4) + self.assertAlmostEqual(box[3], (200 + index) * 72 / resolution, places=4) + + def test_twelve_pages_keep_front_back_order_blank_pages_and_scan_resolution(self): + output = self.root / "scan.pdf" + result = create_document(self.pages(12), output, 150) + self.assertEqual(result, {"pageCount": 12, "warnings": []}) + self.assert_pdf(output, 12, 150) + + def test_missing_opencv_still_creates_complete_pdf(self): + output = self.root / "scan.pdf" + with patch.dict(sys.modules, {"opencv_postprocess": None}): + result = create_document(self.pages(), output, 300, use_postprocess=True) + self.assertEqual(len(result["warnings"]), 1) + self.assert_pdf(output, 4) + + def test_failed_correction_falls_back_per_page_without_losing_sides(self): + def correct(source, target, profile): + if source.name.endswith("000002.png"): + raise RuntimeError("correction failed") + target.write_bytes(source.read_bytes()) + + output = self.root / "scan.pdf" + with patch.dict(sys.modules, {"opencv_postprocess": SimpleNamespace(postprocess=correct)}): + result = create_document(self.pages(), output, 300, use_postprocess=True) + self.assertEqual(len(result["warnings"]), 1) + self.assertIn("Seite 2", result["warnings"][0]) + self.assert_pdf(output, 4) + + def test_strict_mode_does_not_silently_skip_missing_correction(self): + output = self.root / "scan.pdf" + with patch.dict(sys.modules, {"opencv_postprocess": None}): + with self.assertRaises(ImportError): + create_document(self.pages(), output, 300, use_postprocess=True, strict=True) + self.assertFalse(output.exists()) + + def test_corrupt_page_never_publishes_partial_pdf(self): + pages = self.pages() + pages[2].write_bytes(b"corrupt") + output = self.root / "scan.pdf" + with self.assertRaises(OSError): + create_document(pages, output, 300) + self.assertFalse(output.exists()) + self.assertFalse((self.root / "scan.pdf.partial").exists()) + self.assertTrue(all(page.exists() for page in pages)) + + def test_single_page_png_and_tiff_remain_supported(self): + pages = self.pages(1) + for extension in ["png", "tiff"]: + output = self.root / f"scan.{extension}" + create_document(pages, output, 150) + with Image.open(output) as image: + self.assertEqual(image.size, (101, 201)) + self.assertAlmostEqual(image.info["dpi"][0], 150, delta=0.1) + + +if __name__ == "__main__": + unittest.main() diff --git a/agents/fedeo-device-agent/tsconfig.json b/agents/fedeo-device-agent/tsconfig.json index 0956a814..fa0c25a4 100644 --- a/agents/fedeo-device-agent/tsconfig.json +++ b/agents/fedeo-device-agent/tsconfig.json @@ -5,7 +5,6 @@ "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], "types": ["node"], - "typeRoots": ["../../backend/node_modules/@types"], "rootDir": "src", "outDir": "dist", "strict": true, diff --git a/backend/src/routes/instanceAgentGateway.ts b/backend/src/routes/instanceAgentGateway.ts index 95e2259f..d452c0d0 100644 --- a/backend/src/routes/instanceAgentGateway.ts +++ b/backend/src/routes/instanceAgentGateway.ts @@ -1,7 +1,8 @@ import { FastifyInstance, FastifyRequest } from "fastify" import multipart from "@fastify/multipart" import { createHash } from "node:crypto" -import { and, asc, eq, sql } from "drizzle-orm" +import { basename } from "node:path" +import { and, asc, eq, inArray, sql } from "drizzle-orm" import { instanceAgentScanJobs, instanceAgents } from "../../db/schema" import { saveFile } from "../utils/files" @@ -178,11 +179,12 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance }) .where(and( eq(instanceAgentScanJobs.id, req.params.id), - eq(instanceAgentScanJobs.agentId, agent.id) + eq(instanceAgentScanJobs.agentId, agent.id), + inArray(instanceAgentScanJobs.status, ["pending", "running"]) )) .returning() - if (!job) return reply.code(404).send({ error: "Scan job not found" }) + if (!job) return reply.code(409).send({ error: "Scan job is no longer active or does not exist" }) return { job } }) @@ -209,7 +211,8 @@ export default async function instanceAgentGatewayRoutes(server: FastifyInstance if (!data?.file) return reply.code(400).send({ error: "No file uploaded" }) const fileBuffer = await data.toBuffer() - const filename = job.requestedFilename || data.filename || `${job.id}.pdf` + // The agent normalizes the extension to the actual output format. + const filename = basename(data.filename || job.requestedFilename || `${job.id}.pdf`) const createdFile = await saveFile( server, diff --git a/backend/tests/instanceAgentGateway.test.ts b/backend/tests/instanceAgentGateway.test.ts new file mode 100644 index 00000000..1823db57 --- /dev/null +++ b/backend/tests/instanceAgentGateway.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import vm from "node:vm" +import ts from "typescript" +import Fastify from "fastify" +import { getTableColumns, getTableName } from "drizzle-orm" +import { PgDialect } from "drizzle-orm/pg-core" + +// Load the real route with only file storage replaced; tests never contact S3 or a live database. +const routePath = path.resolve(__dirname, "../src/routes/instanceAgentGateway.ts") +const routeRequire = createRequire(routePath) +const compiled = ts.transpileModule(readFileSync(routePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, +}).outputText + +const fixture = async (status = "running") => { + const token = "test-agent-token" + const agent = { id: "agent", tokenHash: createHash("sha256").update(token).digest("hex"), active: true } + const job: any = { id: "job", agentId: "agent", tenantId: 7, requestedBy: "user", requestedFilename: "scan.png", target: { folder: "folder" }, status } + const saved: any[] = [] + const rows: Record = { instance_agents: [agent], instance_agent_scan_jobs: [job] } + const dialect = new PgDialect() + const matching = (table: any, condition: any) => { + const query = dialect.sqlToQuery(condition) + const columns = getTableColumns(table) as Record + const clauses = [...query.sql.matchAll(/"\w+"\."(\w+)" (?:= \$(\d+)|in \(([^)]+)\))/g)] + assert.ok(clauses.length, query.sql) + return rows[getTableName(table)].filter(row => clauses.every(match => { + const key = Object.keys(columns).find(key => columns[key].name === match[1])! + if (match[2]) return row[key] === query.params[Number(match[2]) - 1] + return [...match[3].matchAll(/\$(\d+)/g)].some(parameter => row[key] === query.params[Number(parameter[1]) - 1]) + })) + } + const db = { + select: () => ({ from: (table: any) => ({ where: (where: any) => ({ limit: async () => matching(table, where) }) }) }), + update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({ returning: async () => matching(table, where).map(row => Object.assign(row, data)) }) }) }), + } + const exports: any = {} + vm.runInNewContext(compiled, { + exports, + require: (name: string) => name === "../utils/files" + ? { saveFile: async (...args: any[]) => { saved.push(args); return { id: "saved-file" } } } + : routeRequire(name), + }) + const app = Fastify() + app.decorate("db", db) + await app.register(exports.default) + return { app, job, saved, headers: { "x-agent-token": token } } +} + +test("late failure status cannot overwrite an already completed scan", async (t) => { + const f = await fixture("completed") + t.after(() => f.app.close()) + const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status: "failed", message: "upload timeout" } }) + assert.equal(response.statusCode, 409) + assert.equal(f.job.status, "completed") +}) + +test("running jobs accept progress and scanner failures", async (t) => { + const f = await fixture() + t.after(() => f.app.close()) + for (const status of ["running", "failed"]) { + const response = await f.app.inject({ method: "POST", url: "/scan-jobs/job/status", headers: f.headers, payload: { status, message: "4 Seiten" } }) + assert.equal(response.statusCode, 200) + assert.equal(f.job.status, status) + } +}) + +test("upload preserves the actual PDF filename, tenant and target folder", async (t) => { + const f = await fixture() + t.after(() => f.app.close()) + const boundary = "fedeo-test-boundary" + const response = await f.app.inject({ + method: "POST", url: "/scan-jobs/job/upload", + headers: { ...f.headers, "content-type": `multipart/form-data; boundary=${boundary}` }, + payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="scan.pdf"\r\nContent-Type: application/pdf\r\n\r\n%PDF-test\r\n--${boundary}--\r\n`, + }) + assert.equal(response.statusCode, 200, response.body) + assert.equal(f.saved[0][1], 7) + assert.equal(f.saved[0][3].filename, "scan.pdf") + assert.equal(f.saved[0][3].contentType, "application/pdf") + assert.equal(f.saved[0][4], "folder") + assert.equal(f.job.status, "completed") + assert.equal(f.job.fileId, "saved-file") +}) diff --git a/frontend/components/FileScanModal.vue b/frontend/components/FileScanModal.vue index 0a35bb89..d98b30bb 100644 --- a/frontend/components/FileScanModal.vue +++ b/frontend/components/FileScanModal.vue @@ -22,6 +22,8 @@ const agents = ref([]) const selectedAgentId = ref(null) const currentJob = ref(null) const statusMessage = ref("") +let disposed = false +onBeforeUnmount(() => { disposed = true }) const scanForm = reactive({ filename: `scan-${new Date().toISOString().slice(0, 10)}.pdf`, @@ -30,10 +32,27 @@ const scanForm = reactive({ resolution: 300, mode: "Color", source: "ADF Duplex", - postprocess: true, - postprocessProfile: "receipt" + paperSize: "A4", + postprocess: false, + postprocessProfile: "document" }) +const isFeederScan = computed(() => /adf|document feeder/i.test(scanForm.source || "")) +const formatOptions = computed(() => isFeederScan.value + ? [{ label: "PDF (alle Seiten)", value: "pdf" }] + : [{ label: "PDF", value: "pdf" }, { label: "PNG", value: "png" }, { label: "TIFF", value: "tiff" }]) +watch(isFeederScan, (feeder) => { if (feeder) scanForm.format = "pdf" }) +watch(() => scanForm.format, (format) => { + scanForm.filename = `${scanForm.filename.replace(/\.(pdf|png|tiff?)$/i, "")}.${format}` +}) + +const applyIx1600Preset = () => { + Object.assign(scanForm, { + format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex", + paperSize: "A4", postprocess: false, postprocessProfile: "document" + }) +} + const activeAgents = computed(() => agents.value.filter((agent) => agent.active && agent.capabilities?.scan) ) @@ -62,8 +81,10 @@ const applyAgentDefaults = (agent) => { scanForm.resolution = Number(agent.scanDefaults?.resolution || 300) scanForm.mode = agent.scanDefaults?.mode || "Color" scanForm.source = agent.scanDefaults?.source || "ADF Duplex" - scanForm.postprocess = agent.scanDefaults?.postprocess !== false - scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "receipt" + scanForm.paperSize = agent.scanDefaults?.paperSize || "A4" + scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess) + scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document" + if (isFeederScan.value) scanForm.format = "pdf" if (!scanForm.filename || scanForm.filename.startsWith("scan-")) { scanForm.filename = `scan-${new Date().toISOString().slice(0, 10)}.${scanForm.format || "pdf"}` @@ -94,10 +115,19 @@ const loadAgents = async () => { } const waitForJob = async (jobId) => { - for (let i = 0; i < 90; i++) { + while (!disposed) { await new Promise((resolve) => setTimeout(resolve, 2000)) + if (disposed) return null - const response = await $api(`/api/scan-jobs/${jobId}`) + let response + try { + response = await $api(`/api/scan-jobs/${jobId}`, { timeout: 15000, retry: 0 }) + } catch (err) { + if ([401, 403, 404].includes(err?.statusCode || err?.response?.status)) throw err + statusMessage.value = "Verbindung unterbrochen. Der Scan läuft auf dem Rechner weiter; Status wird erneut abgefragt..." + continue + } + if (disposed) return null const job = response?.job currentJob.value = job @@ -106,16 +136,15 @@ const waitForJob = async (jobId) => { throw new Error(job.agentMessage || "Scan-Auftrag wurde abgebrochen.") } - statusMessage.value = job?.status === "running" + statusMessage.value = job?.agentMessage || (job?.status === "running" ? "Scanner arbeitet..." - : "Warte auf den Agenten..." + : "Warte auf den Agenten...") } - - throw new Error("Der Scan-Auftrag läuft länger als erwartet.") + return null } const startScan = async () => { - if (!selectedAgentId.value) return + if (!selectedAgentId.value || scanInProgress.value) return scanInProgress.value = true statusMessage.value = "Scan-Auftrag wird erstellt..." @@ -133,6 +162,7 @@ const startScan = async () => { resolution: Number(scanForm.resolution || 300), mode: scanForm.mode || "Color", source: scanForm.source || null, + paperSize: scanForm.paperSize, postprocess: scanForm.postprocess, postprocessProfile: scanForm.postprocessProfile }, @@ -150,17 +180,19 @@ const startScan = async () => { statusMessage.value = "Warte auf den Agenten..." - await waitForJob(response.job.id) + const finishedJob = await waitForJob(response.job.id) + if (!finishedJob || disposed) return toast.add({ title: "Scan gespeichert", - description: props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt.", + description: finishedJob.agentMessage || (props.scanData.folder ? "Die Datei wurde im aktuellen Ordner abgelegt." : "Die Datei wurde in Dateien abgelegt."), color: "success" }) emit("scanFinished") modal.close() } catch (err) { + if (disposed) return toast.add({ title: "Scan fehlgeschlagen", description: err?.data?.error || err?.message || "Unbekannter Fehler", @@ -246,6 +278,10 @@ loadAgents() + + ScanSnap iX1600: A4, Duplex, PDF + +
@@ -255,6 +291,17 @@ loadAgents()
+ + + + +

+ Der gesamte Stapel wird als ein PDF gespeichert. Bei Duplex bleiben Vorder- und Rückseiten einschließlich Leerseiten erhalten. +

+
@@ -262,11 +309,7 @@ loadAgents()
- Abbrechen + + {{ scanInProgress ? 'Im Hintergrund weiterlaufen lassen' : 'Schließen' }} + { editState.resolution = Number(agent.scanDefaults?.resolution || 300) editState.mode = agent.scanDefaults?.mode || "Color" editState.source = agent.scanDefaults?.source || "" + editState.paperSize = agent.scanDefaults?.paperSize || "A4" editState.postprocess = Boolean(agent.scanDefaults?.postprocess) editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document" } +const applyIx1600Preset = () => { + Object.assign(editState, { + format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex", + paperSize: "A4", postprocess: false, postprocessProfile: "document" + }) +} + watch(selectedAgent, (agent) => applyAgentToForm(agent), { immediate: true }) const loadAgents = async () => { @@ -130,6 +139,10 @@ const createAgent = async () => { const saveAgent = async () => { if (!selectedAgent.value) return + if (/adf|document feeder/i.test(editState.source) && editState.format !== "pdf") { + toast.add({ title: "Für Einzug- und Duplex-Scans bitte PDF wählen", color: "error" }) + return + } saving.value = true @@ -144,6 +157,7 @@ const saveAgent = async () => { resolution: Number(editState.resolution || 300), mode: editState.mode, source: editState.source || null, + paperSize: editState.paperSize, postprocess: editState.postprocess, postprocessProfile: editState.postprocessProfile, }, @@ -315,6 +329,10 @@ onMounted(async () => { /> + + ScanSnap iX1600: A4, Duplex, PDF + +
{
+ + + +
diff --git a/frontend/tests/file-scan.test.cjs b/frontend/tests/file-scan.test.cjs new file mode 100644 index 00000000..e3db11f0 --- /dev/null +++ b/frontend/tests/file-scan.test.cjs @@ -0,0 +1,72 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const vue = require('vue') + +async function setup(getJob) { + const notices = [] + const requests = [] + let unmount + const context = vm.createContext({ + ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, + onBeforeUnmount: handler => { unmount = handler }, + defineProps: () => ({ scanData: { folder: 'folder-1', type: 'document' } }), + defineEmits: () => () => {}, + useModal: () => ({ close() {} }), + useToast: () => ({ add: notice => notices.push(notice) }), + setTimeout: callback => queueMicrotask(callback), + useNuxtApp: () => ({ $api: async (url, options) => { + requests.push({ url, options }) + if (url === '/api/instance-agents') return { agents: [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }] } + if (url === '/api/scan-jobs') return { job: { id: 'job' } } + return getJob() + } }), + }) + const source = fs.readFileSync(`${__dirname}/../components/FileScanModal.vue`, 'utf8').split('')[0] + vm.runInContext(source, context) + await new Promise(setImmediate) + return { run: code => vm.runInContext(code, context), notices, requests, close: () => unmount() } +} + +test('duplex scanning continues past the former three-minute polling limit', async () => { + let polls = 0 + const ui = await setup(() => ({ job: { status: ++polls > 95 ? 'completed' : 'running', agentMessage: '4 Seiten gescannt.' } })) + await ui.run('startScan()') + assert.equal(polls, 96) + assert.equal(ui.notices[0].title, 'Scan gespeichert') + const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body + assert.equal(body.settings.source, 'ADF Duplex') + assert.equal(body.settings.paperSize, 'A4') + assert.equal(body.target.folder, 'folder-1') +}) + +test('temporary status connection failures do not create duplicate scans', async () => { + let polls = 0 + const ui = await setup(() => { + if (++polls < 3) throw Error('offline') + return { job: { status: 'completed' } } + }) + await ui.run('startScan()') + assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 1) + assert.equal(ui.notices[0].title, 'Scan gespeichert') +}) + +test('closing the modal stops polling without reporting that a background scan failed', async () => { + const ui = await setup(() => { throw Error('must not poll') }) + ui.close() + assert.equal(await ui.run('waitForJob("job")'), null) + assert.equal(ui.notices.length, 0) +}) + +test('iX1600 preset resets image settings and normalizes the output filename to PDF', async () => { + const ui = await setup(() => ({})) + ui.run('scanForm.format="png"; scanForm.source="Flatbed"; scanForm.filename="document.png"; scanForm.postprocess=true') + await new Promise(setImmediate) + ui.run('applyIx1600Preset()') + await new Promise(setImmediate) + assert.equal(ui.run('scanForm.format'), 'pdf') + assert.equal(ui.run('scanForm.filename'), 'document.pdf') + assert.equal(ui.run('scanForm.postprocess'), false) + assert.equal(ui.run('scanForm.resolution'), 300) +})