v2: чанки 30KB + MD5, /v2/chunk + /v2/finalize, Redis, worker, v2.0
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* v2/chunk_upload.js — Клиентская загрузка файла чанками по 32KB.
|
||||
*
|
||||
* Использование:
|
||||
* <input type="file" id="file-input">
|
||||
* <div id="upload-ui"></div>
|
||||
* <script src="/static/v2/chunk_upload.js"></script>
|
||||
*
|
||||
* После успешной загрузки появляется кнопка «Парсинг».
|
||||
*/
|
||||
|
||||
(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 `
|
||||
<div class="upload-progress">
|
||||
<strong>${escHtml(filename)}</strong><br>
|
||||
<progress value="${pct}" max="100" style="width:100%"></progress>
|
||||
<span>${pct}% | ${elapsed}с</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.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(`<p class="error">Ошибка чанка ${i}: ${escHtml(e.message)}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus(`<p class="error">Сервер отклонил чанк ${i}: ${escHtml(res.error)}</p>`);
|
||||
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(`<p class="error">Ошибка финализации: ${escHtml(e.message)}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fin.ok) {
|
||||
setStatus(`<p class="error">Финализация не удалась: ${escHtml(fin.error)}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = fin.doc_id;
|
||||
const done = Math.round((Date.now() - startTs) / 1000);
|
||||
setStatus(`
|
||||
<div class="upload-done">
|
||||
<strong>${escHtml(file.name)}</strong> загружен за ${done}с.<br>
|
||||
<button id="btn-parse" data-docid="${escHtml(docId)}">Парсинг</button>
|
||||
<div id="parse-status"></div>
|
||||
</div>`);
|
||||
|
||||
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 = `<span style="color:green">Готово: ${escHtml(d.filename)}</span>`;
|
||||
} else if (d.status === "error") {
|
||||
clearInterval(timer);
|
||||
if (ps) ps.innerHTML = `<span style="color:red">Ошибка: ${escHtml(d.error || "unknown")}</span>`;
|
||||
} 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(`<p class="error">Критическая ошибка: ${escHtml(e.message)}</p>`);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user