462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
// 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));
|