v0.0.33: раскрытие ZIP в таблицу (фронт) — нативный unzip, рекурсия, дедуп по размер+дате
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user