feat(scan): support iX1600 duplex scanning on Linux
This commit is contained in:
@@ -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()
|
||||
<UInput v-model="scanForm.filename" :disabled="scanInProgress" />
|
||||
</UFormField>
|
||||
|
||||
<UButton color="neutral" variant="soft" :disabled="scanInProgress" @click="applyIx1600Preset">
|
||||
ScanSnap iX1600: A4, Duplex, PDF
|
||||
</UButton>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Quelle">
|
||||
<UInput v-model="scanForm.source" placeholder="ADF Duplex" :disabled="scanInProgress" />
|
||||
@@ -255,6 +291,17 @@ loadAgents()
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Papierformat">
|
||||
<USelectMenu v-model="scanForm.paperSize" :items="[
|
||||
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
|
||||
{ label: 'Scanner-Vorgabe', value: 'auto' }
|
||||
]" value-key="value" label-key="label" :disabled="scanInProgress" />
|
||||
</UFormField>
|
||||
|
||||
<p v-if="isFeederScan" class="text-xs text-muted">
|
||||
Der gesamte Stapel wird als ein PDF gespeichert. Bei Duplex bleiben Vorder- und Rückseiten einschließlich Leerseiten erhalten.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Modus">
|
||||
<UInput v-model="scanForm.mode" placeholder="Color" :disabled="scanInProgress" />
|
||||
@@ -262,11 +309,7 @@ loadAgents()
|
||||
<UFormField label="Format">
|
||||
<USelectMenu
|
||||
v-model="scanForm.format"
|
||||
:items="[
|
||||
{ label: 'PDF', value: 'pdf' },
|
||||
{ label: 'PNG', value: 'png' },
|
||||
{ label: 'TIFF', value: 'tiff' }
|
||||
]"
|
||||
:items="formatOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
:disabled="scanInProgress"
|
||||
@@ -307,7 +350,9 @@ loadAgents()
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" :disabled="scanInProgress" @click="modal.close()">Abbrechen</UButton>
|
||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||
{{ scanInProgress ? 'Im Hintergrund weiterlaufen lassen' : 'Schließen' }}
|
||||
</UButton>
|
||||
<UButton
|
||||
icon="i-heroicons-document-magnifying-glass"
|
||||
:loading="scanInProgress"
|
||||
|
||||
@@ -27,6 +27,7 @@ const editState = reactive({
|
||||
resolution: 300,
|
||||
mode: "Color",
|
||||
source: "",
|
||||
paperSize: "A4",
|
||||
postprocess: false,
|
||||
postprocessProfile: "document",
|
||||
})
|
||||
@@ -64,10 +65,18 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
|
||||
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 () => {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton color="neutral" variant="soft" @click="applyIx1600Preset">
|
||||
ScanSnap iX1600: A4, Duplex, PDF
|
||||
</UButton>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Format">
|
||||
<USelectMenu
|
||||
@@ -342,6 +360,13 @@ onMounted(async () => {
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Papierformat">
|
||||
<USelectMenu v-model="editState.paperSize" :items="[
|
||||
{ label: 'A4', value: 'A4' }, { label: 'Letter', value: 'Letter' },
|
||||
{ label: 'Scanner-Vorgabe', value: 'auto' }
|
||||
]" value-key="value" label-key="label" />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
|
||||
<UFormField label="Profil">
|
||||
|
||||
72
frontend/tests/file-scan.test.cjs
Normal file
72
frontend/tests/file-scan.test.cjs
Normal file
@@ -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('<script setup>')[1].split('</script>')[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)
|
||||
})
|
||||
Reference in New Issue
Block a user