| |
| |
| |
| |
| |
| |
| |
|
|
| import { state, emit, on, api, toast } from '../app.js'; |
|
|
| const $ = id => document.getElementById(id); |
|
|
| |
| const batch = { |
| items: [], |
| running: false, |
| cancelled: false, |
| currentIndex: -1, |
| processingIndex: -1, |
| userNavigated: false, |
| abortController: null, |
| }; |
|
|
| export function initBatchPanel() { |
| |
| |
| |
| const fileInput = $('file-input'); |
| fileInput.addEventListener('change', e => { |
| const files = Array.from(fileInput.files); |
| const hasPdf = files.some(f => f.name.toLowerCase().endsWith('.pdf')); |
| |
| if (files.length > 1 || hasPdf || (files.length === 1 && !hasPdf && state.imageId)) { |
| e.stopImmediatePropagation(); |
| handleMultipleFiles(files); |
| fileInput.value = ''; |
| } |
| |
| }, true); |
|
|
| |
| const xmlInput = $('xml-input'); |
| xmlInput.addEventListener('change', e => { |
| if (xmlInput.files.length <= 1) return; |
| e.stopImmediatePropagation(); |
| uploadXmlFiles(Array.from(xmlInput.files)); |
| xmlInput.value = ''; |
| }, true); |
|
|
| |
| const uploadArea = $('upload-area'); |
| uploadArea.addEventListener('drop', e => { |
| const files = Array.from(e.dataTransfer.files); |
| const xmlFiles = files.filter(f => f.name.toLowerCase().endsWith('.xml')); |
| const nonXml = files.filter(f => !f.name.toLowerCase().endsWith('.xml')); |
| const hasPdf = nonXml.some(f => f.name.toLowerCase().endsWith('.pdf')); |
|
|
| |
| const takeBatch = nonXml.length > 1 || hasPdf || (nonXml.length === 1 && state.imageId); |
| const takeXml = xmlFiles.length > 1 || (xmlFiles.length === 1 && batch.items.length > 0); |
|
|
| if (takeBatch || takeXml) { |
| e.preventDefault(); |
| e.stopImmediatePropagation(); |
| if (nonXml.length > 0) handleMultipleFiles(nonXml); |
| if (xmlFiles.length > 0) uploadXmlFiles(xmlFiles); |
| } |
| }, true); |
|
|
| |
| on('pdf-pages-ready', data => { |
| const existing = new Set(batch.items.map(i => i.filename)); |
| for (const page of data.pages) { |
| if (!existing.has(page.filename)) { |
| batch.items.push({ |
| file: null, |
| imageId: page.image_id, |
| status: 'pending', |
| lines: [], |
| filename: page.filename, |
| preUploaded: true, |
| }); |
| existing.add(page.filename); |
| } |
| } |
| if (batch.items.length > 0) { |
| renderQueue(); |
| |
| |
| const first = batch.items[0]; |
| if (first && first.preUploaded && first.imageId) { |
| batch.currentIndex = 0; |
| emit('batch-item-start', { imageId: first.imageId, filename: first.filename }); |
| updateNavButtons(); |
| } |
| } |
| }); |
|
|
| $('btn-process-batch').addEventListener('click', processBatch); |
| $('btn-clear-batch').addEventListener('click', clearBatch); |
| $('btn-export-batch-txt').addEventListener('click', exportAllTxt); |
| $('btn-export-batch-csv').addEventListener('click', exportAllCsv); |
| $('btn-export-batch-txt-zip').addEventListener('click', exportAllTxtZip); |
| $('btn-export-batch-thinking-zip').addEventListener('click', exportAllThinkingZip); |
| $('btn-export-batch-xml').addEventListener('click', exportAllXml); |
|
|
| $('btn-nav-prev').addEventListener('click', () => navigate(-1)); |
| $('btn-nav-next').addEventListener('click', () => navigate(+1)); |
|
|
| |
| const usePageXmlEl = $('batch-use-pagexml'); |
| const resumeEl = $('batch-resume'); |
| const savedPageXml = localStorage.getItem('batch_use_pagexml'); |
| const savedResume = localStorage.getItem('batch_resume'); |
| if (savedPageXml !== null) usePageXmlEl.checked = savedPageXml === 'true'; |
| if (savedResume !== null) resumeEl.checked = savedResume === 'true'; |
| usePageXmlEl.addEventListener('change', () => localStorage.setItem('batch_use_pagexml', usePageXmlEl.checked)); |
| resumeEl.addEventListener('change', () => localStorage.setItem('batch_resume', resumeEl.checked)); |
|
|
| |
| $('btn-cancel').addEventListener('click', () => { |
| if (!batch.running) return; |
| batch.cancelled = true; |
| batch.abortController?.abort(); |
| }, { capture: true }); |
| } |
|
|
| |
|
|
| |
| async function uploadXmlFiles(xmlFiles) { |
| if (!xmlFiles.length) return; |
| const stem = name => name.replace(/\.[^/.]+$/, '').toLowerCase(); |
|
|
| let matched = 0, deferred = 0, skipped = 0; |
|
|
| for (const xml of xmlFiles) { |
| const xmlStem = stem(xml.name); |
| const item = batch.items.find(it => stem(it.filename) === xmlStem); |
| if (!item) { skipped++; continue; } |
|
|
| if (item.imageId) { |
| |
| try { |
| const fd = new FormData(); |
| fd.append('file', xml); |
| const resp = await fetch(`/api/image/${item.imageId}/xml`, { method: 'POST', body: fd }); |
| if (!resp.ok) throw new Error((await resp.json()).detail); |
| item.xmlUploaded = true; |
| matched++; |
| } catch (err) { |
| toast(`XML ${xml.name}: ${err.message}`, 'error'); |
| } |
| } else { |
| |
| item.xmlFile = xml; |
| deferred++; |
| } |
| } |
|
|
| const parts = []; |
| if (matched > 0) parts.push(`${matched} uploaded`); |
| if (deferred > 0) parts.push(`${deferred} queued for batch`); |
| if (skipped > 0) parts.push(`${skipped} unmatched`); |
| toast(`XML files: ${parts.join(', ')}`, matched + deferred > 0 ? 'success' : 'error'); |
| } |
|
|
| |
|
|
| function handleMultipleFiles(files) { |
| |
| if (batch.items.length === 0 && state.imageId) { |
| batch.items.push({ |
| file: null, |
| imageId: state.imageId, |
| status: 'pending', |
| lines: state.lines.length ? state.lines : [], |
| filename: (state.imageInfo && state.imageInfo.filename) || 'current image', |
| preUploaded: true, |
| }); |
| } |
| |
| const existing = new Set(batch.items.map(i => i.filename)); |
| const added = files.filter(f => !existing.has(f.name)); |
| added.forEach(f => { |
| batch.items.push({ file: f, imageId: null, status: 'pending', lines: [], filename: f.name }); |
| }); |
| if (batch.items.length > 0) { renderQueue(); previewFirstBatchItem(); } |
| } |
|
|
| |
| async function previewFirstBatchItem() { |
| if (batch.running) return; |
|
|
| let i = 0; |
| let safetyCounter = 0; |
| while (i < batch.items.length && safetyCounter < 100) { |
| safetyCounter++; |
| const item = batch.items[i]; |
|
|
| if (item.preUploaded && item.imageId) { |
| i++; |
| continue; |
| } |
|
|
| if (item.file) { |
| try { |
| const fd = new FormData(); |
| fd.append('file', item.file); |
| const resp = await fetch('/api/image/upload', { method: 'POST', body: fd }); |
| if (!resp.ok) { i++; continue; } |
| const data = await resp.json(); |
|
|
| if (data.is_pdf) { |
| const newItems = data.pages.map(p => ({ |
| file: null, imageId: p.image_id, status: 'pending', |
| lines: [], filename: p.filename, preUploaded: true, |
| })); |
| batch.items.splice(i, 1, ...newItems); |
| renderQueue(); |
| continue; |
| } |
|
|
| item.imageId = data.image_id; |
| item.preUploaded = true; |
| renderQueue(); |
|
|
| if (i === 0 && !state.imageId) { |
| batch.currentIndex = 0; |
| emit('batch-item-start', { imageId: item.imageId, filename: item.filename }); |
| updateNavButtons(); |
| } |
| i++; |
| } catch (err) { |
| console.error('Error pre-uploading batch item:', err); |
| i++; |
| } |
| } else { |
| i++; |
| } |
| } |
| } |
|
|
| function clearBatch() { |
| if (batch.running) return; |
| batch.items = []; |
| batch.currentIndex = -1; |
| $('batch-queue-section').classList.add('hidden'); |
| $('batch-export-row').classList.add('hidden'); |
| updateNavButtons(); |
| } |
|
|
| let _dragSrcIndex = null; |
|
|
| function renderQueue() { |
| const section = $('batch-queue-section'); |
| const list = $('batch-list'); |
| section.classList.remove('hidden'); |
| list.innerHTML = ''; |
| batch.items.forEach((item, i) => { |
| const row = document.createElement('div'); |
| row.className = 'batch-item'; |
| row.id = `batch-item-${i}`; |
| row.dataset.index = i; |
|
|
| |
| const handle = document.createElement('span'); |
| handle.className = 'batch-drag-handle'; |
| handle.textContent = 'β Ώ'; |
| handle.title = 'Drag to reorder'; |
|
|
| const name = document.createElement('span'); |
| name.className = 'batch-item-name'; |
| name.title = item.filename; |
| name.textContent = item.filename; |
|
|
| const status = document.createElement('span'); |
| status.className = 'batch-status'; |
| status.id = `batch-status-${i}`; |
| _setStatusEl(status, item.status, item.lines.length); |
|
|
| row.appendChild(handle); |
| row.appendChild(name); |
| row.appendChild(status); |
|
|
| |
| const canPreview = item.status === 'done' || (item.preUploaded && item.imageId); |
| if (canPreview) { |
| row.style.cursor = 'pointer'; |
| row.addEventListener('click', e => { |
| if (e.target === handle) return; |
| if (item.status === 'done') { |
| loadBatchItem(i); |
| } else { |
| |
| batch.currentIndex = i; |
| emit('batch-item-start', { imageId: item.imageId, filename: item.filename }); |
| updateNavButtons(); |
| } |
| }); |
| } |
|
|
| |
| if (!batch.running) { |
| row.draggable = true; |
| row.addEventListener('dragstart', e => { |
| _dragSrcIndex = i; |
| e.dataTransfer.effectAllowed = 'move'; |
| row.classList.add('batch-dragging'); |
| }); |
| row.addEventListener('dragend', () => { |
| row.classList.remove('batch-dragging'); |
| list.querySelectorAll('.batch-item').forEach(r => r.classList.remove('batch-drag-over')); |
| }); |
| row.addEventListener('dragover', e => { |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'move'; |
| list.querySelectorAll('.batch-item').forEach(r => r.classList.remove('batch-drag-over')); |
| row.classList.add('batch-drag-over'); |
| }); |
| row.addEventListener('dragleave', () => row.classList.remove('batch-drag-over')); |
| row.addEventListener('drop', e => { |
| e.preventDefault(); |
| row.classList.remove('batch-drag-over'); |
| const destIndex = parseInt(row.dataset.index, 10); |
| if (_dragSrcIndex == null || _dragSrcIndex === destIndex) return; |
|
|
| |
| const [moved] = batch.items.splice(_dragSrcIndex, 1); |
| batch.items.splice(destIndex, 0, moved); |
|
|
| |
| if (batch.currentIndex === _dragSrcIndex) { |
| batch.currentIndex = destIndex; |
| } else if (_dragSrcIndex < destIndex) { |
| if (batch.currentIndex > _dragSrcIndex && batch.currentIndex <= destIndex) batch.currentIndex--; |
| } else { |
| if (batch.currentIndex >= destIndex && batch.currentIndex < _dragSrcIndex) batch.currentIndex++; |
| } |
|
|
| _dragSrcIndex = null; |
| renderQueue(); |
| }); |
| } |
|
|
| list.appendChild(row); |
| }); |
|
|
| |
| const anyDone = batch.items.some(i => i.status === 'done'); |
| $('batch-export-row').classList.toggle('hidden', !anyDone); |
| updateNavButtons(); |
| } |
|
|
| function _setStatusEl(el, status, lineCount) { |
| el.className = 'batch-status'; |
| if (status === 'pending') { el.textContent = 'pending'; } |
| else if (status === 'active'){ el.textContent = 'runningβ¦'; el.classList.add('active'); } |
| else if (status === 'done') { el.textContent = `β ${lineCount} lines`; el.classList.add('done'); } |
| else if (status === 'error') { el.textContent = 'error'; el.classList.add('error'); } |
| } |
|
|
| function updateItemStatus(index, status, lineCount = 0) { |
| batch.items[index].status = status; |
| const el = $(`batch-status-${index}`); |
| if (el) _setStatusEl(el, status, lineCount); |
| } |
|
|
| function updateOverallProgress(current = null, total = null) { |
| const el = $('batch-overall-progress'); |
| if (current == null) { |
| el.classList.add('hidden'); |
| el.textContent = ''; |
| } else { |
| el.textContent = `${current} / ${total}`; |
| el.classList.remove('hidden'); |
| } |
| } |
|
|
| function updateNavButtons() { |
| const done = batch.items.filter(i => i.status === 'done'); |
| const hasBatch = done.length > 0; |
| const idx = batch.currentIndex; |
| |
| const prevDone = hasBatch && batch.items.slice(0, idx).some(i => i.status === 'done'); |
| const nextDone = hasBatch && batch.items.slice(idx + 1).some(i => i.status === 'done'); |
| $('btn-nav-prev').disabled = !prevDone; |
| $('btn-nav-next').disabled = !nextDone; |
| const label = $('batch-nav-label'); |
| if (hasBatch && idx >= 0) { |
| const pos = done.indexOf(batch.items[idx]) + 1; |
| label.textContent = `${pos}/${done.length}`; |
| } else { |
| label.textContent = ''; |
| } |
| } |
|
|
| function navigate(delta) { |
| const indices = batch.items |
| .map((item, i) => item.status === 'done' ? i : -1) |
| .filter(i => i >= 0); |
| if (indices.length < 2) return; |
| const cur = indices.indexOf(batch.currentIndex); |
| const next = indices[cur + delta]; |
| if (next != null) loadBatchItem(next); |
| } |
|
|
| |
|
|
| async function processBatch() { |
| if (batch.running || !state.engineLoaded) { |
| if (!state.engineLoaded) toast('Load an engine first', 'error'); |
| return; |
| } |
| batch.running = true; |
| batch.cancelled = false; |
| batch.userNavigated = false; |
| $('btn-process-batch').disabled = true; |
| $('btn-cancel').classList.remove('hidden'); |
|
|
| const segMethod = $('seg-method').value; |
| const segDevice = $('seg-device').value; |
| const maxColumns = parseInt($('seg-max-columns')?.value || '6', 10); |
| const splitWidth = parseFloat($('seg-split-width')?.value || '40') / 100; |
| const textDirection = $('seg-text-direction')?.value || 'horizontal-lr'; |
| const usePageXml = $('batch-use-pagexml').checked; |
| const resume = $('batch-resume').checked; |
| const pending = batch.items.filter(i => resume ? i.status === 'pending' : i.status !== 'done').length; |
| let doneThisRun = 0; |
| updateOverallProgress(0, pending); |
|
|
| for (let i = 0; i < batch.items.length; i++) { |
| if (batch.cancelled) { |
| |
| break; |
| } |
|
|
| const item = batch.items[i]; |
| if (item.status === 'done') { |
| |
| continue; |
| } |
|
|
| batch.processingIndex = i; |
| updateItemStatus(i, 'active'); |
| updateNavButtons(); |
|
|
| try { |
| |
| if (item.preUploaded && item.imageId) { |
| |
| } else { |
| const fd = new FormData(); |
| fd.append('file', item.file); |
| const upResp = await fetch('/api/image/upload', { method: 'POST', body: fd }); |
| if (!upResp.ok) throw new Error(`Upload failed: ${upResp.statusText}`); |
| const upData = await upResp.json(); |
| |
| if (upData.is_pdf) { |
| const newItems = upData.pages.map(p => ({ |
| file: null, imageId: p.image_id, status: 'pending', |
| lines: [], filename: p.filename, preUploaded: true, |
| })); |
| batch.items.splice(i + 1, 0, ...newItems); |
| updateItemStatus(i, 'done', 0); |
| renderQueue(); |
| continue; |
| } |
| item.imageId = upData.image_id; |
| } |
|
|
| |
| if (item.xmlFile && item.imageId) { |
| try { |
| const fd = new FormData(); |
| fd.append('file', item.xmlFile); |
| await fetch(`/api/image/${item.imageId}/xml`, { method: 'POST', body: fd }); |
| item.xmlUploaded = true; |
| } catch { } |
| } |
|
|
| |
| if (!batch.userNavigated) { |
| batch.currentIndex = i; |
| emit('batch-item-start', { imageId: item.imageId, filename: item.filename }); |
| } |
|
|
| |
| batch.abortController = new AbortController(); |
| const result = await transcribeSSE( |
| item.imageId, segMethod, segDevice, maxColumns, splitWidth, usePageXml, batch.abortController.signal, textDirection |
| ); |
| item.lines = result.lines; |
| item.time_s = result.time_s; |
| item.token_usage = result.token_usage; |
| updateItemStatus(i, 'done', result.lines.length); |
| doneThisRun++; |
| updateOverallProgress(doneThisRun, pending); |
| |
| if (batch.currentIndex === i) { |
| emit('sse-complete', { lines: item.lines, total_time_s: item.time_s, engine: '(batch)', token_usage: item.token_usage }); |
| } |
|
|
| } catch (err) { |
| if (err.name === 'AbortError' || batch.cancelled) { |
| updateItemStatus(i, 'pending'); |
| } else { |
| updateItemStatus(i, 'error'); |
| toast(`${item.filename}: ${err.message}`, 'error'); |
| } |
| } |
|
|
| |
| renderQueue(); |
| } |
|
|
| batch.running = false; |
| batch.processingIndex = -1; |
| batch.userNavigated = false; |
| batch.abortController = null; |
| $('btn-process-batch').disabled = false; |
| $('btn-cancel').classList.add('hidden'); |
| $('batch-export-row').classList.remove('hidden'); |
| updateOverallProgress(null); |
| updateNavButtons(); |
|
|
| const doneCount = batch.items.filter(i => i.status === 'done').length; |
| if (batch.cancelled) { |
| toast(`Batch cancelled β ${doneCount} image(s) done`, 'info', 4000); |
| } else { |
| toast(`Batch complete: ${doneCount}/${batch.items.length} images`, 'success', 5000); |
| } |
| emit('batch-complete', { items: batch.items }); |
| } |
|
|
| function _collectLiveOverrides() { |
| const overrides = {}; |
| const form = document.getElementById('config-form'); |
| if (!form) return overrides; |
| for (const el of form.querySelectorAll('[data-key]')) { |
| if (el.dataset.saveFor) continue; |
| if (el.dataset.passwordField) continue; |
| const key = el.dataset.key; |
| if (el.type === 'checkbox') overrides[key] = el.checked; |
| else if (el.type === 'number') overrides[key] = Number(el.value); |
| else overrides[key] = el.value; |
| } |
| return overrides; |
| } |
|
|
| function transcribeSSE(imageId, segMethod, segDevice, maxColumns, splitWidthFraction = 0.4, usePageXml = true, signal = null, textDirection = 'horizontal-lr') { |
| return new Promise((resolve, reject) => { |
| const lines = []; |
| let startTime = null; |
| let lastTokenUsage = null; |
| const body = JSON.stringify({ |
| image_id: imageId, seg_method: segMethod, |
| seg_device: segDevice, max_columns: maxColumns, |
| split_width_fraction: splitWidthFraction, |
| text_direction: textDirection, |
| use_pagexml: usePageXml, |
| engine_config_overrides: _collectLiveOverrides(), |
| }); |
|
|
| const finish = (cancelled = false) => { |
| const time_s = startTime ? Math.round((Date.now() - startTime) / 100) / 10 : 0; |
| resolve({ lines, time_s, token_usage: lastTokenUsage, cancelled }); |
| }; |
|
|
| fetch('/api/transcribe', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body, |
| signal, |
| }).then(resp => { |
| if (!resp.ok) return reject(new Error(resp.statusText)); |
| const reader = resp.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buf = ''; |
|
|
| const pump = () => reader.read().then(({ done, value }) => { |
| if (done) { finish(); return; } |
| buf += decoder.decode(value, { stream: true }); |
| const parts = buf.split('\n\n'); |
| buf = parts.pop(); |
| for (const chunk of parts) { |
| const evLine = chunk.split('\n').find(l => l.startsWith('event:')); |
| const dataLine = chunk.split('\n').find(l => l.startsWith('data:')); |
| if (!evLine || !dataLine) continue; |
| const event = evLine.slice(7).trim(); |
| const data = JSON.parse(dataLine.slice(5).trim()); |
| if (event === 'progress') { |
| if (!startTime) startTime = Date.now(); |
| if (data.token_usage) lastTokenUsage = data.token_usage; |
| lines.push(data.line); |
| |
| if (batch.currentIndex === batch.processingIndex) emit('sse-progress', data); |
| } else if (event === 'segmentation') { |
| |
| if (batch.items[batch.processingIndex]) { |
| batch.items[batch.processingIndex].bboxes = data.bboxes || []; |
| batch.items[batch.processingIndex].regions = data.regions || []; |
| } |
| if (batch.currentIndex === batch.processingIndex) emit('sse-segmentation', data); |
| } else if (event === 'complete') { |
| if (data.token_usage) lastTokenUsage = data.token_usage; |
| finish(); |
| } else if (event === 'error') { |
| reject(new Error(data.message)); |
| } else if (event === 'cancelled') { |
| finish(true); |
| } |
| } |
| pump(); |
| }).catch(reject); |
| pump(); |
| }).catch(reject); |
| }); |
| } |
|
|
| |
| function loadBatchItem(index) { |
| const item = batch.items[index]; |
| if (item.status !== 'done') return; |
| batch.currentIndex = index; |
| batch.userNavigated = true; |
| emit('batch-item-start', { imageId: item.imageId, filename: item.filename }); |
| updateNavButtons(); |
| |
| |
| const bboxes = item.bboxes || []; |
| const regions = item.regions || []; |
| emit('sse-segmentation', { num_lines: item.lines.length, bboxes, regions, source: 'batch-restore' }); |
| |
| state.lines = item.lines.map((l, i) => ({ ...l, index: i })); |
| |
| $('transcription-lines').innerHTML = ''; |
| $('conf-filter-row').classList.add('hidden'); |
| state.lines.forEach(l => emit('sse-progress', { |
| current: l.index + 1, total: state.lines.length, line: l |
| })); |
| emit('sse-complete', { lines: state.lines, total_time_s: item.time_s || 0, engine: '(batch)', token_usage: item.token_usage || null }); |
| } |
|
|
| |
|
|
| function exportAllTxt() { |
| const done = batch.items.filter(i => i.status === 'done'); |
| if (!done.length) return; |
| const text = done.map(item => |
| `=== ${item.filename} ===\n` + item.lines.map(l => l.text).join('\n') |
| ).join('\n\n'); |
| downloadFile('batch_transcription.txt', text, 'text/plain'); |
| } |
|
|
| function exportAllCsv() { |
| const done = batch.items.filter(i => i.status === 'done'); |
| if (!done.length) return; |
| const header = 'File,Line,Text,Confidence\n'; |
| const rows = done.flatMap(item => |
| item.lines.map(l => { |
| const conf = l.confidence != null ? l.confidence.toFixed(4) : ''; |
| return `"${item.filename.replace(/"/g,'""')}",${l.index + 1},"${l.text.replace(/"/g,'""')}",${conf}`; |
| }) |
| ); |
| downloadFile('batch_transcription.csv', header + rows.join('\n'), 'text/csv'); |
| } |
|
|
| async function exportAllThinkingZip() { |
| const done = batch.items.filter(i => i.status === 'done' && i.imageId); |
| if (!done.length) return; |
| try { |
| const resp = await fetch('/api/batch/export-thinking', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image_ids: done.map(i => i.imageId) }), |
| }); |
| if (!resp.ok) throw new Error(await resp.text()); |
| const blob = await resp.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = 'batch_thinking.zip'; a.click(); |
| URL.revokeObjectURL(url); |
| } catch (err) { |
| toast(`Thinking export failed: ${err.message}`, 'error'); |
| } |
| } |
|
|
| async function exportAllTxtZip() { |
| const done = batch.items.filter(i => i.status === 'done' && i.imageId); |
| if (!done.length) return; |
| try { |
| const resp = await fetch('/api/batch/export-txt', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image_ids: done.map(i => i.imageId) }), |
| }); |
| if (!resp.ok) throw new Error(await resp.text()); |
| const blob = await resp.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = 'batch_export_txt.zip'; a.click(); |
| URL.revokeObjectURL(url); |
| } catch (err) { |
| toast(`TXT ZIP export failed: ${err.message}`, 'error'); |
| } |
| } |
|
|
| async function exportAllXml() { |
| const done = batch.items.filter(i => i.status === 'done' && i.imageId); |
| if (!done.length) return; |
| try { |
| const resp = await fetch('/api/batch/export-xml', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ image_ids: done.map(i => i.imageId) }), |
| }); |
| if (!resp.ok) throw new Error(await resp.text()); |
| const blob = await resp.blob(); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = 'batch_export.zip'; a.click(); |
| URL.revokeObjectURL(url); |
| } catch (err) { |
| toast(`XML export failed: ${err.message}`, 'error'); |
| } |
| } |
|
|
| function downloadFile(filename, content, mime) { |
| const blob = new Blob([content], { type: mime }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = filename; a.click(); |
| URL.revokeObjectURL(url); |
| } |
|
|