feat: make file picker embeddable

This commit is contained in:
“Naeel”
2026-09-05 14:42:50 +03:00
parent aec8ad4fbe
commit 3d227f1daa
18 changed files with 3005 additions and 88 deletions
+27 -12
View File
@@ -8,7 +8,8 @@
```text
site/app.py
-> site/templates/index.html
-> upload/frontend/table/init_upload_table.js
-> dist/file-picker.iife.js
-> upload/frontend/index.js
-> table/on_files_change.js
-> table/on_folder_change.js
-> table/add_file_with_dedup.js
@@ -33,7 +34,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
| `children` | `Array` | Дочерние узлы группы. |
| `expanded` | `boolean` | Видимость дочерних строк в таблице. |
Внутреннее состояние `initUploadTable`:
Внутреннее состояние picker-а:
| Поле | Назначение |
|---|---|
@@ -82,7 +83,8 @@ Flask отдаёт страницу и ES-модули. Все выбранны
| `addFiles(state, cfg, files, elements)` | state, `allowedExt`, FileList/Array, DOM | `Promise<void>` | Фильтрует обычные файлы, раскрывает ZIP, вызывает render. |
| `onFilesChange(state, cfg, elements)` | state, config, DOM | async callback | Блокирует повторные события через `busy` и вызывает `addFiles`. |
Ошибка отдельного ZIP пропускается. `finally` обязательно освобождает `busy`.
Ошибка отдельного ZIP пропускается и передаётся в `cfg.onError(error, file)`, если
callback задан. `finally` обязательно освобождает `busy`.
### `on_folder_change.js`
@@ -105,7 +107,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
дочерние строки только при `expanded === true`.
### `init_upload_table.js`
### `init_upload_table.js` (legacy)
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
@@ -113,7 +115,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
| `removeNode(state, id)` | state и id | `void` | Удаляет узел из родительского массива. Внутренняя функция. |
| `initUploadTable(cfg)` | `allowedExt` и четыре DOM-элемента | API-объект | Создаёт state, события, render и публичные операции. |
Публичный API:
Публичный API legacy-совместимости:
| Метод | Вход | Результат |
|---|---|---|
@@ -137,11 +139,24 @@ Flask отдаёт страницу и ES-модули. Все выбранны
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
| `listEntries(data, zipName, allowedExt, depth)` | ZIP bytes и контекст | `Promise<node|null>` | Рекурсивно обрабатывает ZIP. Внутренняя. |
| `listZipFiles(file, allowedExt)` | browser File и расширения | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
| `listZipFiles(file, allowedExt, limits)` | browser File, расширения и limits | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
Безопасность: отклоняются абсолютные пути, backslash, пустые сегменты, `.` и
`..`; глубина nested ZIP ограничена 20. ZIP без разрешённых leaf-файлов возвращает
`null` и не создаёт пустую группу.
`..`; применяются лимиты `maxEntries`, `maxTotalBytes`, `maxEntryBytes` и
`maxDepth` с безопасными значениями по умолчанию. ZIP без разрешённых leaf-файлов
возвращает `null` и не создаёт пустую группу.
## `upload/frontend/index.js`
`initFilePicker({ mount, allowedExt, labels, layout, limits, onChange, onError })`
строит весь DOM внутри HTMLElement или CSS-селектора `mount`, подключает inputs,
таблицу и обработчики. Возвращаемый API: `pickFiles`, `pickFolder`, `addFiles`,
`getFiles`, `remove`, `clear`, `render`, `destroy`. `getFiles` и `onChange`
возвращают копии массива leaf-файлов. `destroy` снимает слушатели и очищает mount.
IIFE-бандл экспортирует этот API как `FilePicker.initFilePicker`; ESM-бандл
экспортирует функцию напрямую. `fflate` включён в оба bundle, поэтому глобальный
vendor script не требуется.
## Flask и HTML
@@ -153,13 +168,13 @@ Flask отдаёт страницу и ES-модули. Все выбранны
| `VERSION` | константа | Версия страницы |
| `index()` / `/` | HTTP GET | HTML с version и config |
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
| `upload_frontend(filename)` | HTTP GET и относительный путь | ES/CSS-файл из `upload/frontend` |
| `upload_frontend(filename)` | HTTP GET и относительный путь | Legacy-файл из `upload/frontend` |
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
### `site/templates/index.html`
Шаблон создаёт два скрытых input: обычный file input и `webkitdirectory`,
передаёт четыре DOM-элемента в `initUploadTable`, подключает локальный `fflate`
до ES-модуля и связывает три кнопки с `pickFiles`, `pickFolder` и `clear`.
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
сброса browser cache и вызывает `FilePicker.initFilePicker`.
## Legacy
+202
View File
@@ -0,0 +1,202 @@
# План: picker → универсальный встраиваемый компонент
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
не вносить изменений за рамками перечисленных пунктов.
## Цель
Превратить слой выбора файлов в переиспользуемый компонент/АПИ, который встраивается
в любое приложение (включая managed Flask `drhider`) одной строкой. На входе задаются:
типы файлов, порядок/скрытие колонок, все надписи, тема. Контент файлов на сервер не уходит.
## Верифицированное текущее состояние
- Язык: vanilla JS, ES-модули. Flask — только статический сервер.
- Структура `upload/frontend/`:
- `table/init_upload_table.js``initUploadTable(cfg)` ТРЕБУЕТ готовые DOM-элементы
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
- `table/on_files_change.js``addFiles`, `onFilesChange` (фильтр расширений, ZIP).
- `table/on_folder_change.js``onFolderChange` (webkitdirectory).
- `table/add_file_with_dedup.js``addFileWithDedup` (дедуп `path + NUL + size`).
- `table/render.js``renderNode`, `render`, `findNode`, `flattenFiles`.
ЗАШИТЫ строки: `'готов'`, `'Удалить ${name}'`, `'Нет выбранных файлов'`,
`'${n} файлов · ${bytes}'`.
- `table/rebase_tree.js`, `table/esc.js` (HTML-экранирование), `table/fs.js` (байты).
- `table/set_status.js` — LEGACY, не используется, НЕ трогать.
- `zip/list_zip_files.js``listZipFiles`; на строке 65 вызывает ГЛОБАЛЬНУЮ
`fflate.unzipSync(data)`. Содержит `safeEntryParts` (path traversal), лимит глубины 20.
- `site/app.py` — маршруты `/`, `/health`, `/upload-frontend/<path:filename>`
(`send_from_directory(ROOT/"upload"/"frontend", filename)`). VERSION `0.1.10`.
- `site/templates/index.html` — demo: глобально грузит `vendor/fflate.min.js`,
импортирует модуль по АБСОЛЮТНОМУ пути `/upload-frontend/table/init_upload_table.js`,
содержит зашитые `<thead>` «Путь/Размер/Статус» и кнопки.
- `site/static/vendor/fflate.min.js` — локальная глобальная `fflate`.
- `node_modules/fflate` есть, но корневого `package.json` НЕТ.
- `requirements.txt` — Flask>=3.0, gunicorn, requests.
## Жёсткие связи (что убираем)
1. Глобальная зависимость `fflate` (реальная — `list_zip_files.js:65`).
2. Абсолютный путь импорта `/upload-frontend/...``index.html` + маршрут в `app.py`).
3. Зашитые надписи/колонки в `render.js` и `<thead>`.
4. Принудительный DOM-контракт `initUploadTable`.
5. Нет сборки/единой точки входа.
## Целевая архитектура
Единая точка входа `initFilePicker(config)`:
- сама строит DOM внутри `config.mount` (не требует готовых элементов);
- переиспользует существующие модули (дедуп, ZIP, рендер, обход папок);
- возвращает API с `destroy()`.
Один бандл (ESM + IIFE) с вшитым `fflate` и инжектом CSS. Без Shadow DOM, без системы тем,
без npm-пайплайна (dist коммитится в git).
## Схема конфига `initFilePicker(config)`
```js
initFilePicker({
mount, // HTMLElement | CSS-селектор (обязательно)
allowedExt: ['.pdf', '.txt'],// обязательно; сопоставление регистронезависимое
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить', // подставляется в aria-label и title
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (n, bytes) => `${n} файлов · ${formatBytes(bytes)}`, // необязательно
},
layout: {
columns: ['path', 'size', 'status'], // порядок + скрытие (пустой массив = все)
controls: ['files', 'folder', 'clear'],
theme: null, // строка-класс для кастомизации (не система тем)
},
limits: { // защита ZIP (обязательные значения по умолчанию)
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024, // суммарный распакованный размер
maxEntryBytes: 50 * 1024 * 1024, // размер одного entry
maxDepth: 20,
},
onChange(files) {}, // вызывается после каждого изменения дерева
onError(err) {}, // необязательно
});
// Возвращает:
// { pickFiles, pickFolder, addFiles, getFiles, remove, clear, render, destroy }
// getFiles() возвращает копию массива (не внутренний state).
// destroy() снимает слушатели, чистит DOM и state.
```
## Пошаговые изменения по файлам
### 1. `upload/frontend/zip/list_zip_files.js`
- Заменить глобальный `fflate.unzipSync(data)` на импорт: `import { unzipSync } from 'fflate'`.
- Убрать вызов глобальной `fflate`; использовать `unzipSync(data, { filter })`.
`filter` получает entry-метаданные `{ name, size, originalSize, compression }`
и возвращает `false` для пропуска ДО распаковки. Через `filter` реализовать:
- счётчик обработанных entries > `limits.maxEntries` → выбросить ошибку/остановиться;
- `originalSize > limits.maxEntryBytes` → пропустить entry;
- накопительный распакованный размер > `limits.maxTotalBytes` → выбросить ошибку
(защита от zip-бомбы).
- `listZipFiles(file, allowedExt, limits)` — принять `limits`, прокинуть в `listEntries`.
- Лимит `depth > limits.maxDepth` вместо зашитого `20`.
- Сохранить `safeEntryParts` без изменений.
### 2. `upload/frontend/table/render.js`
- `renderNode(node, depth, cfg)` и `render(state, elements, cfg)` принимают `cfg`
(labels + layout). Заменить зашитые строки на `cfg.labels.*`:
- `'готов'``cfg.labels.statusReady`;
- `'Удалить ${name}'``${cfg.labels.remove} ${name}`;
- `'Нет выбранных файлов'``cfg.labels.empty`;
- счётчик → `cfg.labels.count(files.length, totalBytes)` или дефолт.
- Колонки: рендерить `<td>` только для колонок из `layout.columns` в заданном порядке;
при скрытой колонке не выводить её ячейку и соответствующий `<th>`.
- `flattenFiles` оставить как есть (он уже возвращает новые объекты), но в
`getFiles()` дополнительно отдавать `[...flattenFiles(state.nodes)]` (копия массива).
### 3. `upload/frontend/table/on_files_change.js` и `on_folder_change.js`
- Прокинуть `limits` в `listZipFiles(file, cfg.allowedExt, cfg.limits)`.
- Остальное (фильтр, busy, ZIP-catch) без изменений.
### 4. НОВЫЙ `upload/frontend/index.js` (единая точка входа)
- Экспортировать `initFilePicker(config)`.
- Внутри: построить разметку (кнопки, скрытые `<input type=file>`, таблица, счётчик)
внутри `config.mount`, с подписями из `config.labels` и набором кнопок из
`config.layout.controls`.
- Создать state `{ nodes, fileKeys: new Set(), busy }`.
- Переиспользовать существующие функции: навесить `onFilesChange`/`onFolderChange`
на созданные inputs, делегирование кликов (toggle/remove) как в `init_upload_table.js`.
- Вызывать `config.onChange` после каждой перерисовки с копией `getFiles()`.
- Реализовать `destroy()`: снять все слушатели, очистить `mount.innerHTML`, обнулить state.
- Оставить `initUploadTable` рабочим (обратная совместимость demo), но demo перевести на `initFilePicker`.
- Инжектировать CSS: `import cssText from './style.css'` (через esbuild loader `text`),
при первом вызове вставить `<style data-file-picker>` в `<head>` один раз.
### 5. НОВЫЙ `upload/frontend/style.css`
- Перенести релевантные стили из `site/static/style.css`, ВСЕ селекторы обернуть
под корневой класс `.file-picker` (префикс-изоляция, без Shadow DOM).
- Классы: `.file-picker`, `.fp-toolbar`, `.fp-table`, `.fp-tree-*`, `.fp-remove-btn`,
`.fp-status`, `.fp-count`. Поддержать `config.layout.theme` как доп. класс на контейнере.
### 6. Сборка
- Добавить `package.json` (devDependency `esbuild`).
- `build`-скрипт собирает из `upload/frontend/index.js`:
- `dist/file-picker.esm.js` (format=esm, fflate inline);
- `dist/file-picker.iife.js` (format=iife, глобал `FilePicker`, fflate inline);
- CSS инлайнится через loader `text`.
- `dist/` коммитится в git.
### 7. Demo `site/`
- `site/templates/index.html`: убрать глобальный `<script fflate>` и абсолютный import;
подключить собранный бандл (`dist/file-picker.iife.js` или ESM) и вызвать
`initFilePicker({ mount, allowedExt: {{ config|tojson }}.allowedExt, ... })`.
- `site/app.py`: убрать или оставить маршрут `/upload-frontend/...` (рекомендуется
оставить для совместимости, но demo использует бандл); добавить раздачу `dist/`
при необходимости. VERSION поднять (см. ниже).
### 8. Документация
- Обновить `README.md`, `upload/README.md`, `docs/CODE-REFERENCE.md` под новый API
`initFilePicker` и бандл. Пометить `initUploadTable`/`set_status.js` как legacy.
## Требования безопасности (обязательно сохранить)
- Экранирование всех имён/путей через `esc` перед `innerHTML` (уже есть — не сломать).
- Path traversal в ZIP (`safeEntryParts`) — без изменений.
- Новые лимиты ZIP из п.1 — обязательны (zip-бомба).
- `getFiles()`/`onChange` отдают копию, а не внутренний state.
- Не полагаться на абсолютные URL; пути бандла/ассетов задаются интегратором.
## Критерии приёмки
1. `initFilePicker({ mount, allowedExt, labels, layout, onChange })` встраивается
в пустую страницу одной строкой и работает (файл/папка/ZIP, дедуп, удаление, очистка).
2. Глобальной `fflate` больше нет (только внутри бандла).
3. Нет ни одного абсолютного импорта `/upload-frontend/...`.
4. Надписи и порядок/скрытие колонок меняются через `labels`/`layout` без правки кода.
5. `destroy()` полностью чистит DOM и слушатели; повторный `initFilePicker` в тот же
`mount` работает.
6. ZIP-бомба/превышение лимитов не вешает браузер (срабатывают `limits`).
7. `getFiles()` возвращает копию; мутация результата не ломает state.
8. Demo в `site/` работает через бандл; `/health` → 200.
## Что НЕ делать
- Не вводить Shadow DOM, не делать систему тем/плагинов, не публиковать в npm/CDN.
- Не трогать `set_status.js`, `site/static/vendor/fflate.min.js` (после перехода на
бандл тег можно удалить из index.html, но файл vendor можно оставить).
- Не менять формат возвращаемых leaf-объектов (`{ path, name, size, file }`).
## Порядок коммитов
Логические шаги (после каждого — `node --check`/сборка + `git diff --check`):
1. ZIP-лимиты + импорт fflate (п.1, п.3).
2. Параметризация render/labels/layout (п.2).
3. Новая точка входа `initFilePicker` + CSS + destroy (п.4, п.5).
4. Сборка esbuild (п.6).
5. Demo на бандл (п.7).
6. Документация (п.8) + bump VERSION в `site/app.py`.