feat: make file picker embeddable
This commit is contained in:
+27
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user