From b5a14cfc3d970285022d189630128905c9b69d88 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?=
Date: Wed, 19 Aug 2026 18:29:34 +0400
Subject: [PATCH] =?UTF-8?q?v0.0.47:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?=
=?UTF-8?q?=D1=86=D0=B5=D0=BB=D0=BE=D0=B9=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8?=
=?UTF-8?q?=20(webkitdirectory)=20=E2=80=94=20=D1=80=D0=B5=D0=BA=D1=83?=
=?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B2=D0=BD=D0=BE,=20=D0=BA=D0=B0=D0=BA=20?=
=?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20ZIP?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
History/2026-08-19-folder-select-recursive.md | 45 +++++++++++++++++++
site/app.py | 2 +-
site/templates/index.html | 36 ++++++++++++++-
3 files changed, 81 insertions(+), 2 deletions(-)
create mode 100644 History/2026-08-19-folder-select-recursive.md
diff --git a/History/2026-08-19-folder-select-recursive.md b/History/2026-08-19-folder-select-recursive.md
new file mode 100644
index 0000000..0f7b020
--- /dev/null
+++ b/History/2026-08-19-folder-select-recursive.md
@@ -0,0 +1,45 @@
+# v0.0.47 — Выбор целой папки (рекурсивно, как раскрытие ZIP) — 2026-08-19
+
+## Проблема
+Нативный `` (`site/templates/index.html:133`) не умеет выбирать
+папку целиком: клик по папке просто «входит» внутрь. Нужно выбирать целую папку
+и рекурсивно забирать все файлы — как при раскрытии архива.
+
+## Решение
+- Второй `` + кнопка
+ «📁 Выбрать папку» (вызов `folderInput.click()`).
+ `webkitdirectory` взаимоисключающ с обычным выбором файлов на одном input —
+ поэтому отдельный input, а не переключатель.
+- Браузер сам отдаёт ВСЕ файлы папки рекурсивно (включая подпапки) с полем
+ `file.webkitRelativePath`. Имя добавляемого файла = `webkitRelativePath`
+ (структура папок сохраняется до итогового ZIP, как у архива сохраняется
+ внутренний `e.name`).
+- Обработка — та же логика, что у раскрытия ZIP (`listZipFiles`):
+ - `.zip` → рекурсивное раскрытие (вложенные zip тоже);
+ - документы `.pdf .doc .docx .txt .md` → в таблицу;
+ - прочее (картинки и т.п.) → пропускается.
+- Сортировка входящих файлов по `webkitRelativePath` — детерминированный порядок.
+
+## Файлы
+- `site/templates/index.html`
+ - CSS: `.folder-btn` (полная ширина), `#folderInput { display:none }`
+ - HTML: скрытый `#folderInput` + кнопка в `.file-input-wrap`
+ - JS: `const folderInput`, обработчик `change` (рекурсия+фильтр+имена с путями)
+ - `resetAll()` и `load` — сброс `folderInput.value`
+- `site/app.py` — VERSION 0.0.46 → 0.0.47
+
+## Бэкенд — не менялся
+- `extract_text` (drhider/extractor.py:204) берёт расширение через
+ `os.path.splitext` — пути в имени не мешают.
+- `build_zip` (drhider/builder.py) пишет `ZipInfo` с путями как есть;
+ `_resolve_name` дедуплицирует по полному имени — одноимённые файлы в разных
+ подпапках не сталкиваются.
+- Аплоад идёт из массива `sf` (`uploadFiles`), а не из `fi.files` — файлы из
+ папки уходят на бэк обычным путём.
+
+## Проверка
+1. «Выбрать папку» → папка с подпапками, вложенными .zip и картинками →
+ в таблице только документы, рекурсивно; .zip раскрыт; картинки пропущены;
+ имена с путями.
+2. Обфускация → скачать ZIP → вложенность папок сохранена, всё обфусцировано.
+3. Регрессия: обычный «Выбрать файлы» работает как раньше.
diff --git a/site/app.py b/site/app.py
index 8523028..6e72dc4 100644
--- a/site/app.py
+++ b/site/app.py
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
-VERSION = "0.0.46"
+VERSION = "0.0.47"
def create_app():
diff --git a/site/templates/index.html b/site/templates/index.html
index 1aa780e..a59d98e 100644
--- a/site/templates/index.html
+++ b/site/templates/index.html
@@ -44,6 +44,8 @@
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
.file-input-wrap { margin-bottom: 10px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
+ .folder-btn { margin-top: 6px; width: 100%; }
+ #folderInput { display: none; }
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
@@ -131,6 +133,8 @@
+
+
@@ -188,6 +192,7 @@ const fl = document.getElementById('fileList');
const fc = document.getElementById('fileCount');
const ub = document.getElementById('uploadBtn');
const st = document.getElementById('status');
+const folderInput = document.getElementById('folderInput');
const db = document.getElementById('dlBtns');
let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
@@ -202,6 +207,7 @@ function resetAll() {
sf = [];
fileMeta = new Map();
fi.value = '';
+ folderInput.value = '';
rr();
st.className = '';
st.textContent = '';
@@ -225,7 +231,7 @@ function 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 = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
+window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; folderInput.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
// ═══════════ Нативная распаковка ZIP (без внешних библиотек) ═══════════
function dosToMs(date, time) {
@@ -361,6 +367,34 @@ fi.addEventListener('change', async () => {
rr();
});
+// ═══ Выбор целой папки: рекурсивно, как раскрытие ZIP ═══
+// webkitdirectory отдаёт все файлы папки (включая подпапки) с webkitRelativePath.
+folderInput.addEventListener('change', async () => {
+ const docExts = ['.pdf', '.doc', '.docx', '.txt', '.md'];
+ const incoming = Array.from(folderInput.files).sort((a, b) =>
+ (a.webkitRelativePath || a.name).localeCompare(b.webkitRelativePath || b.name));
+ for (const f of incoming) {
+ const rel = f.webkitRelativePath || f.name; // сохраняем структуру папок
+ const low = rel.toLowerCase();
+ if (low.endsWith('.zip')) {
+ try {
+ const nested = await listZipFiles(new File([f], rel, { lastModified: f.lastModified }));
+ nested.forEach(x => addFileWithDedup(x));
+ } catch (err) {
+ addFileWithDedup(new File([f], rel, { lastModified: f.lastModified })); // не удалось — добавить как есть
+ }
+ } else if (docExts.some(ext => low.endsWith(ext))) {
+ addFileWithDedup(new File([f], rel, { lastModified: f.lastModified }));
+ }
+ // иначе — не документ, пропускаем
+ }
+ const d = new DataTransfer();
+ sf.forEach(f => d.items.add(f));
+ fi.files = d.files;
+ folderInput.files = d.files;
+ rr();
+});
+
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
async function uploadFiles() {