203 lines
14 KiB
Markdown
203 lines
14 KiB
Markdown
# План: 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`.
|