Files
OCRmyPDF/webui/static/app.js
T

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));