feat(scan): support iX1600 duplex scanning on Linux

This commit is contained in:
2026-09-17 18:22:43 +02:00
parent cfef07b96d
commit dd37622bb6
24 changed files with 1411 additions and 215 deletions

View File

@@ -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"