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)
|
sys.path.insert(0, _sys_path_root)
|
||||||
|
|
||||||
# Версия приложения (меняется при изменениях)
|
# Версия приложения (меняется при изменениях)
|
||||||
VERSION = "0.0.32"
|
VERSION = "0.0.33"
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
|||||||
+128
-9
@@ -138,6 +138,7 @@ const ub = document.getElementById('uploadBtn');
|
|||||||
const st = document.getElementById('status');
|
const st = document.getElementById('status');
|
||||||
const db = document.getElementById('dlBtns');
|
const db = document.getElementById('dlBtns');
|
||||||
let sf = [];
|
let sf = [];
|
||||||
|
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
|
||||||
let currentSid = '';
|
let currentSid = '';
|
||||||
let activeES = null; // активный EventSource
|
let activeES = null; // активный EventSource
|
||||||
let activeXHR = null; // активный XHR
|
let activeXHR = null; // активный XHR
|
||||||
@@ -147,6 +148,7 @@ function resetAll() {
|
|||||||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||||||
currentSid = '';
|
currentSid = '';
|
||||||
sf = [];
|
sf = [];
|
||||||
|
fileMeta = new Map();
|
||||||
fi.value = '';
|
fi.value = '';
|
||||||
rr();
|
rr();
|
||||||
st.className = '';
|
st.className = '';
|
||||||
@@ -167,17 +169,134 @@ function rr() {
|
|||||||
ub.disabled = sf.length === 0;
|
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(() => {}); });
|
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
|
||||||
fi.addEventListener('change', () => {
|
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
|
||||||
const incoming = Array.from(fi.files);
|
|
||||||
// Добавляем к существующим, исключая дубликаты по имени+размеру
|
function dosToMs(date, time) {
|
||||||
const seen = new Set(sf.map(f => f.name + '|' + f.size));
|
const year = 1980 + ((date >> 9) & 0x7f);
|
||||||
for (const f of incoming) {
|
const month = (date >> 5) & 0x0f;
|
||||||
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
|
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();
|
const d = new DataTransfer();
|
||||||
sf.forEach(f => d.items.add(f));
|
sf.forEach(f => d.items.add(f));
|
||||||
fi.files = d.files;
|
fi.files = d.files;
|
||||||
|
|||||||
Reference in New Issue
Block a user