const drop = document.getElementById('drop'); const fileInput = document.getElementById('file'); const pickBtn = document.getElementById('pick'); const queue = document.getElementById('queue'); const processAllBtn = document.getElementById('process-all'); const clearBtn = document.getElementById('clear'); const summaryTitle = document.getElementById('summary-title'); const summaryMeta = document.getElementById('summary-meta'); const jobs = []; let nextId = 1; let activeXhr = null; ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('drag'); }) ); ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('drag'); }) ); drop.addEventListener('drop', e => addFiles(e.dataTransfer.files)); drop.addEventListener('click', () => fileInput.click()); drop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); pickBtn.addEventListener('click', e => { e.stopPropagation(); fileInput.click(); }); fileInput.addEventListener('change', () => { addFiles(fileInput.files); fileInput.value = ''; }); processAllBtn.addEventListener('click', processPending); clearBtn.addEventListener('click', clearQueue); function addFiles(files) { for (const file of files) { jobs.unshift({ id: nextId++, file, state: 'ready', status: 'в очереди', progress: 0, reports: [], error: '', download: null, outputName: '' }); } render(); } function clearQueue() { if (activeXhr) activeXhr.abort(); activeXhr = null; for (const job of jobs) { if (job.download) URL.revokeObjectURL(job.download); } jobs.splice(0, jobs.length); render(); } function processPending() { const selected = jobs.filter(j => j.state === 'ready' || j.state === 'error'); if (!selected.length || activeXhr) return; processJobs(selected); } function retryJob(id) { const job = jobs.find(j => j.id === id); if (!job || activeXhr) return; processJobs([job]); } function removeJob(id) { const idx = jobs.findIndex(j => j.id === id); if (idx < 0 || activeXhr) return; const [job] = jobs.splice(idx, 1); if (job.download) URL.revokeObjectURL(job.download); render(); } function processJobs(selected) { selected.forEach(job => { job.state = 'run'; job.status = 'загрузка'; job.progress = 4; job.error = ''; job.reports = []; if (job.download) URL.revokeObjectURL(job.download); job.download = null; job.outputName = ''; }); render(); const fd = new FormData(); selected.forEach(job => fd.append('files', job.file)); const xhr = new XMLHttpRequest(); activeXhr = xhr; xhr.open('POST', '/api/process'); xhr.responseType = 'blob'; xhr.upload.onprogress = e => { const pct = e.lengthComputable ? Math.min(70, Math.round(e.loaded / e.total * 70)) : 35; selected.forEach(job => { job.progress = pct; job.status = pct >= 70 ? 'обработка' : 'загрузка'; }); render(); }; xhr.onprogress = () => { selected.forEach(job => { job.progress = Math.max(job.progress, 85); job.status = 'получение'; }); render(); }; xhr.onload = () => { activeXhr = null; if (xhr.status >= 200 && xhr.status < 300) { handleSuccess(xhr, selected); } else { handleFailure(xhr, selected); } render(); }; xhr.onerror = () => { activeXhr = null; selected.forEach(job => markError(job, 'Сеть или сервер оборвали запрос')); render(); }; xhr.onabort = () => { selected.forEach(job => markError(job, 'Обработка отменена')); render(); }; xhr.send(fd); } function handleSuccess(xhr, selected) { const blob = xhr.response; const cd = xhr.getResponseHeader('Content-Disposition') || ''; const outputName = filenameFromContentDisposition(cd) || (selected.length > 1 ? 'orientator-output.zip' : 'output'); const reports = parseReports(xhr.getResponseHeader('X-Orientator-Reports')); if (selected.length === 1) { const job = selected[0]; job.state = 'done'; job.status = 'готово'; job.progress = 100; job.outputName = outputName; job.download = URL.createObjectURL(blob); job.reports = reports; return; } const batchJob = { id: nextId++, file: new File([blob], outputName), kind: 'batch', sourceCount: selected.length, state: 'done', status: 'готов к скачиванию', progress: 100, reports, error: '', download: URL.createObjectURL(blob), outputName }; jobs.unshift(batchJob); selected.forEach(job => { const jobReports = reports.filter(r => r.file === job.file.name); job.state = 'done'; job.status = jobReports.length ? 'добавлено в архив' : 'см. архив'; job.progress = 100; job.reports = jobReports; job.error = jobReports.length ? '' : 'По этому файлу нет отчета. Если формат не поддерживается, в итоговом zip будет файл с ошибкой .error.txt.'; }); } function handleFailure(xhr, selected) { if (!xhr.response) { selected.forEach(job => markError(job, 'Сервер вернул ошибку без текста')); return; } const reader = new FileReader(); reader.onload = () => { let msg = reader.result || 'Ошибка обработки'; try { msg = JSON.parse(reader.result).error || msg; } catch {} selected.forEach(job => markError(job, msg)); render(); }; reader.readAsText(xhr.response); } function markError(job, msg) { job.state = 'error'; job.status = 'ошибка'; job.progress = 100; job.error = msg; } function render() { updateSummary(); queue.innerHTML = ''; if (!jobs.length) { queue.innerHTML = '
Добавьте документы для обработки.
'; return; } for (const job of jobs) { queue.appendChild(renderJob(job)); } } function renderJob(job) { const item = document.createElement('article'); item.className = `item ${job.state}`; const main = document.createElement('div'); const name = document.createElement('div'); name.className = 'name'; name.textContent = job.kind === 'batch' ? `Итоговый архив: ${job.outputName || job.file.name}` : (job.outputName || job.file.name); const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = job.kind === 'batch' ? `Скачать один zip с результатами по ${job.sourceCount || 0} файл(ам) · ${fmtBytes(job.file.size)}` : [fmtBytes(job.file.size), fileKind(job.file.name)].filter(Boolean).join(' · '); main.append(name, meta); const right = document.createElement('div'); right.className = 'right'; if (job.download) { const a = document.createElement('a'); a.href = job.download; a.textContent = 'Скачать'; a.download = job.outputName || job.file.name; a.className = 'btn'; right.appendChild(a); } if (job.kind !== 'batch' && (job.state === 'ready' || job.state === 'error' || job.state === 'done')) { right.appendChild(button('Обработать заново', 'btn ghost', () => retryJob(job.id), Boolean(activeXhr))); } if (job.state !== 'run') { right.appendChild(button('Убрать', 'icon-btn', () => removeJob(job.id), Boolean(activeXhr), 'x')); } const status = document.createElement('span'); status.className = 'status'; status.textContent = job.status; right.appendChild(status); const bar = document.createElement('div'); bar.className = 'bar'; const fill = document.createElement('i'); fill.style.width = `${job.progress}%`; bar.appendChild(fill); item.append(main, right, bar); if (job.error) { const err = document.createElement('div'); err.className = 'report error-text'; err.textContent = job.error; item.appendChild(err); } else if (job.reports.length) { item.appendChild(renderReport(job.reports)); } return item; } function renderReport(reports) { const box = document.createElement('div'); box.className = 'report'; const changed = reports.filter(r => r.angle && r.angle !== 0).length; const avg = reports.reduce((sum, r) => sum + (r.confidence || 0), 0) / reports.length; const files = new Set(reports.map(r => r.file)); const head = document.createElement('div'); head.className = 'report-head'; head.textContent = `${reports.length} стр. · повернуто ${changed} · средняя уверенность ${(avg * 100).toFixed(1)}%`; const list = document.createElement('pre'); list.textContent = reports.map(r => { const page = r.page ? `стр. ${r.page}` : 'файл'; return `${page}: ${r.angle}° · ${(r.confidence * 100).toFixed(1)}%`; }).map((line, i) => { const r = reports[i]; return files.size > 1 ? `${r.file} · ${line}` : line; }).join('\n'); box.append(head, list); return box; } function updateSummary() { const total = jobs.length; const ready = jobs.filter(j => j.state === 'ready' || j.state === 'error').length; const done = jobs.filter(j => j.state === 'done').length; const running = jobs.some(j => j.state === 'run'); const bytes = jobs.reduce((sum, j) => sum + j.file.size, 0); summaryTitle.textContent = total ? `${total} файл(ов) · ${fmtBytes(bytes)}` : 'Файлов нет'; summaryMeta.textContent = total ? `${ready} ожидает · ${done} готово` : 'pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz'; processAllBtn.disabled = !ready || running; clearBtn.disabled = !total; processAllBtn.textContent = running ? 'Идет обработка' : (ready > 1 ? 'Обработать все в zip' : 'Обработать'); } function button(text, className, onClick, disabled, title) { const b = document.createElement('button'); b.type = 'button'; b.className = className; b.textContent = title || text; b.title = text; b.disabled = disabled; b.addEventListener('click', onClick); return b; } function fmtBytes(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; } function fileKind(name) { const lower = name.toLowerCase(); if (lower.endsWith('.tar.gz')) return 'tar.gz'; const i = lower.lastIndexOf('.'); return i >= 0 ? lower.slice(i + 1) : ''; } function parseReports(header) { if (!header) return []; try { return JSON.parse(decodeURIComponent(header.replace(/\+/g, ' '))); } catch { return []; } } function filenameFromContentDisposition(cd) { const encoded = cd.match(/filename\*=UTF-8''([^;]+)/i); if (encoded) { try { return decodeURIComponent(encoded[1]); } catch {} } const plain = cd.match(/filename="([^"]+)"|filename=([^;]+)/i); return plain ? (plain[1] || plain[2]).trim() : ''; } render();