// SPDX-FileCopyrightText: 2026 James R. Barlow // SPDX-License-Identifier: AGPL-3.0-or-later 'use strict'; const $ = (id) => document.getElementById(id); const el = { uploadView: $('upload-view'), resultsView: $('results-view'), dropzone: $('dropzone'), dropzoneHint: $('dropzone-hint'), fileInput: $('file-input'), queueWrap: $('queue-wrap'), queue: $('queue'), queueCount: $('queue-count'), clearQueue: $('clear-queue'), form: $('options-form'), languageList: $('language-list'), run: $('run'), uploadStatus: $('upload-status'), uploadProgress: $('upload-progress'), uploadProgressBar: $('upload-progress-bar'), results: $('results'), resultsProgress: $('results-progress'), downloadAll: $('download-all'), startOver: $('start-over'), retentionNote: $('retention-note'), error: $('error'), version: $('version'), }; /** Server capabilities, filled in by loadConfig(). */ let config = null; /** Files staged for upload, keyed by name+size+lastModified. */ const queue = new Map(); /** Identifier of the batch currently being polled, if any. */ let currentBatchId = null; let pollTimer = null; /** File ids whose log panel is expanded. */ const openLogs = new Set(); // ---------------------------------------------------------------- helpers function formatBytes(bytes) { if (bytes === null || bytes === undefined) return ''; if (bytes < 1024) return `${bytes} B`; const units = ['KB', 'MB', 'GB']; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; } function showError(message) { el.error.textContent = message; el.error.hidden = false; el.error.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function clearError() { el.error.hidden = true; el.error.textContent = ''; } /** Pull a useful message out of a FastAPI error response. */ async function errorDetail(response, fallback) { try { const body = await response.json(); if (typeof body.detail === 'string') return body.detail; if (Array.isArray(body.detail) && body.detail.length) { return body.detail.map((d) => d.msg || String(d)).join('; '); } } catch { /* not JSON; fall through */ } return fallback; } // ---------------------------------------------------------------- config async function loadConfig() { const response = await fetch('/api/config'); if (!response.ok) throw new Error('Could not load server configuration.'); config = await response.json(); el.version.textContent = `v${config.version}`; el.fileInput.accept = config.accepted_extensions.join(','); el.dropzoneHint.textContent = `PDF and image files · up to ${config.max_files} files, ` + `${formatBytes(config.max_upload_bytes)} each`; el.retentionNote.textContent = `Results are deleted from the server after ` + `${Math.round(config.batch_ttl_seconds / 60)} minutes. Download what you need.`; const defaults = config.defaults; for (const lang of config.languages) { const label = document.createElement('label'); label.className = 'check'; const input = document.createElement('input'); input.type = 'checkbox'; input.value = lang; input.checked = defaults.languages.includes(lang); label.append(input, document.createTextNode(` ${lang}`)); el.languageList.append(label); } $('mode').value = defaults.mode; $('output-type').value = defaults.output_type; $('optimize').value = String(defaults.optimize); $('image-dpi').value = defaults.image_dpi; } function collectOptions() { const languages = [...el.languageList.querySelectorAll('input:checked')].map( (input) => input.value ); return { languages: languages.length ? languages : ['eng'], mode: $('mode').value, output_type: $('output-type').value, optimize: Number($('optimize').value), image_dpi: Number($('image-dpi').value), deskew: $('deskew').checked, clean: $('clean').checked, rotate_pages: $('rotate-pages').checked, }; } // ---------------------------------------------------------------- queue function addFiles(fileList) { clearError(); const rejected = []; for (const file of fileList) { const extension = file.name.includes('.') ? `.${file.name.split('.').pop().toLowerCase()}` : ''; if (!config.accepted_extensions.includes(extension)) { rejected.push(`${file.name} (unsupported type)`); continue; } if (file.size > config.max_upload_bytes) { rejected.push(`${file.name} (over ${formatBytes(config.max_upload_bytes)})`); continue; } const key = `${file.name}:${file.size}:${file.lastModified}`; if (queue.has(key)) continue; if (queue.size >= config.max_files) { rejected.push(`${file.name} (batch limit of ${config.max_files} reached)`); continue; } queue.set(key, file); } if (rejected.length) { showError(`Skipped ${rejected.length} file(s): ${rejected.join(', ')}`); } renderQueue(); } function renderQueue() { el.queue.replaceChildren(); for (const [key, file] of queue) { const row = document.createElement('li'); row.className = 'file-row'; const main = document.createElement('div'); main.className = 'file-main'; const name = document.createElement('div'); name.className = 'file-name'; name.textContent = file.name; const meta = document.createElement('div'); meta.className = 'file-meta'; meta.textContent = formatBytes(file.size); main.append(name, meta); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'icon-button'; remove.title = `Remove ${file.name}`; remove.setAttribute('aria-label', `Remove ${file.name}`); remove.textContent = '✕'; remove.addEventListener('click', () => { queue.delete(key); renderQueue(); }); row.append(main, remove); el.queue.append(row); } el.queueCount.textContent = String(queue.size); el.queueWrap.hidden = queue.size === 0; el.run.disabled = queue.size === 0; el.run.textContent = queue.size > 1 ? `Run OCR on ${queue.size} files` : 'Run OCR'; } // ---------------------------------------------------------------- submit function submitBatch(event) { event.preventDefault(); if (queue.size === 0) return; clearError(); const form = new FormData(); for (const file of queue.values()) form.append('files', file, file.name); form.append('options', JSON.stringify(collectOptions())); el.run.disabled = true; el.clearQueue.disabled = true; el.uploadProgress.hidden = false; el.uploadStatus.textContent = 'Uploading…'; // XHR rather than fetch: it reports upload progress, which matters when // someone drops 50 scanned PDFs on a slow link. const request = new XMLHttpRequest(); request.open('POST', '/api/batches'); request.responseType = 'json'; request.upload.addEventListener('progress', (progress) => { if (!progress.lengthComputable) return; const percent = Math.round((progress.loaded / progress.total) * 100); el.uploadProgressBar.style.width = `${percent}%`; el.uploadStatus.textContent = percent < 100 ? `Uploading… ${percent}%` : 'Starting OCR…'; }); request.addEventListener('load', () => { resetUploadUi(); if (request.status === 201 && request.response) { currentBatchId = request.response.id; showResults(request.response); poll(); } else { const detail = (request.response && request.response.detail) || `Upload failed (HTTP ${request.status}).`; showError(typeof detail === 'string' ? detail : 'Upload failed.'); } }); request.addEventListener('error', () => { resetUploadUi(); showError('Upload failed: the server could not be reached.'); }); request.send(form); } function resetUploadUi() { el.uploadProgress.hidden = true; el.uploadProgressBar.style.width = '0%'; el.uploadStatus.textContent = ''; el.run.disabled = queue.size === 0; el.clearQueue.disabled = false; } // ---------------------------------------------------------------- results function showResults(batch) { el.uploadView.hidden = true; el.resultsView.hidden = false; renderResults(batch); } const STATUS_TEXT = { pending: 'Queued', running: 'Working', succeeded: 'Done', failed: 'Failed', cancelled: 'Cancelled', }; function renderResults(batch) { el.resultsProgress.textContent = `${batch.completed} / ${batch.total}`; el.downloadAll.disabled = batch.downloadable === 0; el.downloadAll.textContent = batch.downloadable && batch.downloadable < batch.total ? `Download ${batch.downloadable} finished (.zip)` : 'Download all (.zip)'; el.results.replaceChildren(); for (const file of batch.files) { el.results.append(renderResultRow(batch, file)); } } function renderResultRow(batch, file) { const row = document.createElement('li'); row.className = 'file-row'; const main = document.createElement('div'); main.className = 'file-main'; const name = document.createElement('div'); name.className = 'file-name'; name.textContent = file.name; main.append(name); const meta = document.createElement('div'); if (file.status === 'failed' && file.error) { meta.className = 'file-meta err'; meta.textContent = file.error; } else { meta.className = 'file-meta'; meta.textContent = describeProgress(file); } main.append(meta); // Failed files get their ocrmypdf output on demand, so a user can see why. if (file.status === 'failed') { const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'link-button'; const open = openLogs.has(file.id); toggle.textContent = open ? 'Hide details' : 'Show details'; toggle.addEventListener('click', () => { if (openLogs.has(file.id)) openLogs.delete(file.id); else openLogs.add(file.id); renderResultRow.refresh(batch); }); main.append(toggle); if (open) main.append(renderLog(batch.id, file.id)); } const pill = document.createElement('span'); pill.className = `pill ${file.status}`; if (file.status === 'running') { const spinner = document.createElement('span'); spinner.className = 'spinner'; pill.append(spinner); } pill.append(document.createTextNode(STATUS_TEXT[file.status] || file.status)); row.append(main, pill); if (file.status === 'succeeded') { const link = document.createElement('a'); link.className = 'download'; link.href = `/api/batches/${batch.id}/files/${file.id}`; link.download = file.output_name; link.textContent = 'Download'; row.append(link); } return row; } // Re-render without waiting for the next poll (used by the log toggle). renderResultRow.refresh = (batch) => renderResults(batch); function describeProgress(file) { const parts = [formatBytes(file.size_bytes)]; if (file.status === 'succeeded') { parts.push(`→ ${formatBytes(file.output_size_bytes)}`); if (file.duration_seconds) parts.push(`${file.duration_seconds}s`); } else if (file.status === 'running' && file.log_tail.length) { parts.push(file.log_tail[file.log_tail.length - 1]); } return parts.filter(Boolean).join(' · '); } function renderLog(batchId, fileId) { const pre = document.createElement('pre'); pre.className = 'log'; pre.textContent = 'Loading…'; fetch(`/api/batches/${batchId}/files/${fileId}/log`) .then((response) => (response.ok ? response.text() : 'Log unavailable.')) .then((text) => { pre.textContent = text; }) .catch(() => { pre.textContent = 'Log unavailable.'; }); return pre; } // ---------------------------------------------------------------- polling async function poll() { if (!currentBatchId) return; try { const response = await fetch(`/api/batches/${currentBatchId}`); if (response.status === 404) { showError('This batch expired and its files were deleted.'); currentBatchId = null; return; } if (!response.ok) throw new Error(await errorDetail(response, 'Status check failed.')); const batch = await response.json(); renderResults(batch); if (!batch.finished) { pollTimer = setTimeout(poll, 1500); } } catch (error) { showError(`Lost contact with the server: ${error.message}`); pollTimer = setTimeout(poll, 5000); } } function startOver() { clearTimeout(pollTimer); clearError(); // Free the server's copies rather than waiting for the TTL sweep. if (currentBatchId) { fetch(`/api/batches/${currentBatchId}`, { method: 'DELETE' }).catch(() => {}); } currentBatchId = null; openLogs.clear(); queue.clear(); renderQueue(); el.results.replaceChildren(); el.resultsView.hidden = true; el.uploadView.hidden = false; window.scrollTo({ top: 0, behavior: 'smooth' }); } // ---------------------------------------------------------------- wiring el.dropzone.addEventListener('click', () => el.fileInput.click()); el.dropzone.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.fileInput.click(); } }); el.fileInput.addEventListener('change', () => { addFiles(el.fileInput.files); el.fileInput.value = ''; }); for (const type of ['dragenter', 'dragover']) { el.dropzone.addEventListener(type, (event) => { event.preventDefault(); el.dropzone.classList.add('dragover'); }); } for (const type of ['dragleave', 'drop']) { el.dropzone.addEventListener(type, () => el.dropzone.classList.remove('dragover')); } el.dropzone.addEventListener('drop', (event) => { event.preventDefault(); if (event.dataTransfer?.files?.length) addFiles(event.dataTransfer.files); }); // Dropping outside the zone would otherwise make the browser navigate away. window.addEventListener('dragover', (event) => event.preventDefault()); window.addEventListener('drop', (event) => event.preventDefault()); el.clearQueue.addEventListener('click', () => { queue.clear(); clearError(); renderQueue(); }); el.form.addEventListener('submit', submitBatch); el.startOver.addEventListener('click', startOver); el.downloadAll.addEventListener('click', () => { if (currentBatchId) window.location = `/api/batches/${currentBatchId}/download`; }); loadConfig().then(renderQueue).catch((error) => showError(error.message));