v0.0.33: раскрытие ZIP в таблицу (фронт) — нативный unzip, рекурсия, дедуп по размер+дате
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-19 08:36:41 +04:00
parent 0ad5a5fabd
commit 53a1fb40d2
3 changed files with 181 additions and 10 deletions
+52
View File
@@ -0,0 +1,52 @@
# v0.0.33 — раскрытие ZIP в таблицу (фронтенд) — 2026-08-19
**Дата:** 2026-08-19
**Версия:** 0.0.32 → 0.0.33
**Файл:** `site/templates/index.html` (фронтенд), `site/app.py` (версия)
---
## Суть
Раньше выбранный `.zip` попадал в таблицу одной строкой и не раскрывался.
Теперь при выборе файлов ZIP **распаковывается на клиенте**, файлы из архива
попадают в таблицу **по отдельности** (полный относительный путь).
## Что добавлено (`index.html`, в тот же `<script>`)
1. **Нативный распаковщик ZIP** без внешних библиотек:
- `DecompressionStream('deflate-raw')` — поддержка сжатых (DEFLATE) архивов
(WhatsApp/1С архивы сжаты; старый STORE-only `unzip` из v5 не подходил).
- `parseZip()` — разбор EOCD → Central Directory → local headers.
- `dosToMs()` — DOS date/time (4 байта) → timestamp (мс).
- `decodeZipName()` — имя: UTF-8 (если флаг 0x800) иначе CP437→CP866
(кириллица из 1С, зеркалит серверный `_decode_name`).
2. **Рекурсия** `listZipFiles()`:
- директории (`name` оканчивается на `/`) пропускаются;
- вложенный `.zip` распаковывается рекурсивно;
- имя сохраняется **полным путём** (`dir1/sub/file.pdf`).
3. **Дедуп + суффикс** `addFileWithDedup()`:
- структура `fileMeta` (Map: имя → `{size, mtime}`);
- имя есть + тот же размер И та же дата → точный дубль → **пропустить**;
- имя есть + разный размер ИЛИ дата → **суффикс** `name_2.ext`, `name_3.ext`…;
- дата: для обычных файлов `File.lastModified` (мс), для извлечённых из ZIP —
DOS date/time → мс.
4. `fi.addEventListener('change')` переписан в `async`:
- `.zip``listZipFiles` → каждый файл через `addFileWithDedup`;
- при ошибке распаковки (неподдерживаемый метод/не-ZIP) — zip добавляется как есть.
## Проверка
- JS-синтаксис: `node --check` — OK.
- Рекурсия вложенного ZIP (`nested.zip``sub/a.txt`) — извлечён, проверено в Node.
- Дедуп/суффикс: `dup.txt` (3) → `dup_2.txt` (5) → `dup_3.txt` (2); точный дубль пропущен.
- Backend не менялся (кроме версии в `app.py`).
## Открытый вопрос
`DecompressionStream` требует современных браузеров (Chrome 103+, Safari 16.4+,
Firefox 113+). AES-шифрованные ZIP (метод 99) не поддерживаются — при этом архив
добавляется в таблицу как есть (fallback).
+1 -1
View File
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.32"
VERSION = "0.0.33"
def create_app():
+128 -9
View File
@@ -138,6 +138,7 @@ const ub = document.getElementById('uploadBtn');
const st = document.getElementById('status');
const db = document.getElementById('dlBtns');
let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
@@ -147,6 +148,7 @@ function resetAll() {
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
sf = [];
fileMeta = new Map();
fi.value = '';
rr();
st.className = '';
@@ -167,17 +169,134 @@ function rr() {
ub.disabled = sf.length === 0;
}
function rm(i) { sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
function rm(i) { fileMeta.delete(sf[i].name); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
fi.addEventListener('change', () => {
const incoming = Array.from(fi.files);
// Добавляем к существующим, исключая дубликаты по имени+размеру
const seen = new Set(sf.map(f => f.name + '|' + f.size));
for (const f of incoming) {
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
function dosToMs(date, time) {
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const min = (time >> 5) & 0x3f;
const sec = (time & 0x1f) * 2;
return new Date(year, month - 1, day, hour, min, sec).getTime();
}
function decodeZipName(bytes, isUtf8) {
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
let name;
try { name = new TextDecoder('ibm437').decode(bytes); }
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
if (/[^\x00-\x7f]/.test(name)) {
try { name = new TextDecoder('ibm866').decode(bytes); }
catch (e) { /* оставить как есть */ }
}
return name;
}
async function inflateRaw(bytes) {
const ds = new DecompressionStream('deflate-raw');
const stream = new Blob([bytes]).stream().pipeThrough(ds);
const ab = await new Response(stream).arrayBuffer();
return new Uint8Array(ab);
}
async function parseZip(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error('Не ZIP');
const cdSize = dv.getUint32(eocd + 12, true);
const cdOffset = dv.getUint32(eocd + 16, true);
const entries = [];
let pos = cdOffset;
const cdEnd = cdOffset + cdSize;
while (pos < cdEnd) {
if (dv.getUint32(pos, true) !== 0x02014b50) break;
const flags = dv.getUint16(pos + 8, true);
const method = dv.getUint16(pos + 10, true);
const modTime = dv.getUint16(pos + 12, true);
const modDate = dv.getUint16(pos + 14, true);
const compSize = dv.getUint32(pos + 20, true);
const nameLen = dv.getUint16(pos + 28, true);
const extraLen = dv.getUint16(pos + 30, true);
const commentLen = dv.getUint16(pos + 32, true);
const localOffset = dv.getUint32(pos + 42, true);
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
const lhNameLen = dv.getUint16(localOffset + 26, true);
const lhExtraLen = dv.getUint16(localOffset + 28, true);
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
const comp = buf.slice(dataStart, dataStart + compSize);
let data;
if (method === 0) data = comp;
else if (method === 8) data = await inflateRaw(comp);
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
async function listZipFiles(file) {
const buf = new Uint8Array(await file.arrayBuffer());
const entries = await parseZip(buf);
const out = [];
for (const e of entries) {
if (e.isDir) continue;
if (e.name.toLowerCase().endsWith('.zip')) {
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
out.push(...(await listZipFiles(sub)));
} else {
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
}
}
return out;
}
function addFileWithDedup(file) {
const size = file.size;
const mtime = file.lastModified;
let name = file.name;
if (fileMeta.has(name)) {
const e = fileMeta.get(name);
if (e.size === size && e.mtime === mtime) return; // точный дубль — пропустить
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let n = 2;
while (fileMeta.has(base + '_' + n + ext)) n++;
name = base + '_' + n + ext;
}
fileMeta.set(name, { size, mtime });
sf.push(new File([file], name, { lastModified: mtime }));
}
fi.addEventListener('change', async () => {
const incoming = Array.from(fi.files);
for (const f of incoming) {
if (f.name.toLowerCase().endsWith('.zip')) {
try {
const nested = await listZipFiles(f);
nested.forEach(x => addFileWithDedup(x));
} catch (err) {
addFileWithDedup(f); // не удалось распаковать — добавить zip как есть
}
} else {
addFileWithDedup(f);
}
}
// Обновить file input — все файлы из sf
const d = new DataTransfer();
sf.forEach(f => d.items.add(f));
fi.files = d.files;