/** * v2/chunk_upload.js — Клиентская загрузка файла чанками по 32KB. * * Использование: * *
* * * После успешной загрузки появляется кнопка «Парсинг». */ (function () { "use strict"; const CHUNK_SIZE = 32 * 1024; // 32 KB // ── MD5 (встроенная реализация без зависимостей) ────────────────────────── // RFC 1321 — используется только для checksum, не для безопасности. function md5(buffer) { // SparkMD5 должен быть подключён отдельно, либо используем SubtleCrypto // Здесь используем встроенный Web Crypto API (SHA-256 недостаточен — нужен MD5) // Простая MD5 реализация: return SparkMD5.ArrayBuffer.hash(buffer); } // ── UI ──────────────────────────────────────────────────────────────────── function getUI() { return document.getElementById("upload-ui"); } function setStatus(html) { const ui = getUI(); if (ui) ui.innerHTML = html; } function renderProgress(filename, pct, elapsed) { return `
${escHtml(filename)}
${pct}%  |  ${elapsed}с
`; } function escHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">"); } // ── XHR helper ──────────────────────────────────────────────────────────── function post(url, body) { return new Promise(function (resolve, reject) { const xhr = new XMLHttpRequest(); xhr.open("POST", url, true); xhr.setRequestHeader("Content-Type", "application/json"); xhr.timeout = 15000; xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error("bad json: " + xhr.responseText)); } } else { reject(new Error("HTTP " + xhr.status + ": " + xhr.responseText)); } }; xhr.onerror = function () { reject(new Error("network error")); }; xhr.ontimeout = function () { reject(new Error("timeout")); }; xhr.send(JSON.stringify(body)); }); } // ── Base64 encode ArrayBuffer ───────────────────────────────────────────── function toBase64(buffer) { let binary = ""; const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } // ── Генерация upload_id ─────────────────────────────────────────────────── function genId() { return "u" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } // ── Основная функция загрузки ───────────────────────────────────────────── async function uploadFile(file) { const uploadId = genId(); const total = Math.ceil(file.size / CHUNK_SIZE); const startTs = Date.now(); // Считаем MD5 всего файла через SparkMD5 const fullBuffer = await file.arrayBuffer(); const checksum = md5(fullBuffer); setStatus(renderProgress(file.name, 0, 0)); for (let i = 0; i < total; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const slice = fullBuffer.slice(start, end); const b64 = toBase64(slice); const elapsed = Math.round((Date.now() - startTs) / 1000); const pct = Math.round(((i + 1) / total) * 90); // до 90%, финал = 100% setStatus(renderProgress(file.name, pct, elapsed)); let res; try { res = await post("/v2/chunk", { upload_id: uploadId, index: i, total: total, data: b64, }); } catch (e) { setStatus(`

Ошибка чанка ${i}: ${escHtml(e.message)}

`); return; } if (!res.ok) { setStatus(`

Сервер отклонил чанк ${i}: ${escHtml(res.error)}

`); return; } } // Финализация const elapsed = Math.round((Date.now() - startTs) / 1000); setStatus(renderProgress(file.name, 95, elapsed)); let fin; try { fin = await post("/v2/finalize", { upload_id: uploadId, filename: file.name, mime_type: file.type || "application/octet-stream", total: total, checksum: checksum, }); } catch (e) { setStatus(`

Ошибка финализации: ${escHtml(e.message)}

`); return; } if (!fin.ok) { setStatus(`

Финализация не удалась: ${escHtml(fin.error)}

`); return; } const docId = fin.doc_id; const done = Math.round((Date.now() - startTs) / 1000); setStatus(`
${escHtml(file.name)} загружен за ${done}с.
`); document.getElementById("btn-parse").addEventListener("click", function () { pollStatus(docId); }); } // ── Опрос статуса парсинга ──────────────────────────────────────────────── function pollStatus(docId) { const ps = document.getElementById("parse-status"); if (ps) ps.textContent = "Парсинг запущен, ожидаем…"; let attempts = 0; const timer = setInterval(async function () { attempts++; try { const r = await fetch("/v2/status/" + docId); const d = await r.json(); if (d.status === "parsed") { clearInterval(timer); if (ps) ps.innerHTML = `Готово: ${escHtml(d.filename)}`; } else if (d.status === "error") { clearInterval(timer); if (ps) ps.innerHTML = `Ошибка: ${escHtml(d.error || "unknown")}`; } else { if (ps) ps.textContent = `Статус: ${d.status} (${attempts * 2}с)`; } } catch (e) { if (ps) ps.textContent = `Ошибка опроса: ${e.message}`; } if (attempts > 60) { clearInterval(timer); } }, 2000); } // ── Инициализация ───────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", function () { const input = document.getElementById("file-input"); if (!input) return; input.addEventListener("change", function () { const file = input.files[0]; if (!file) return; uploadFile(file).catch(function (e) { setStatus(`

Критическая ошибка: ${escHtml(e.message)}

`); }); }); }); })();