Files
upload-platform/docs/PLAN-componentization.md
T

14 KiB
Raw Blame History

План: picker → универсальный встраиваемый компонент

Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду (upload-platform на коммите aec8ad4, ветка master). Кодить строго по этому плану, не вносить изменений за рамками перечисленных пунктов.

Цель

Превратить слой выбора файлов в переиспользуемый компонент/АПИ, который встраивается в любое приложение (включая managed Flask drhider) одной строкой. На входе задаются: типы файлов, порядок/скрытие колонок, все надписи, тема. Контент файлов на сервер не уходит.

Верифицированное текущее состояние

  • Язык: vanilla JS, ES-модули. Flask — только статический сервер.
  • Структура upload/frontend/:
    • table/init_upload_table.jsinitUploadTable(cfg) ТРЕБУЕТ готовые DOM-элементы fileInputEl, folderInputEl, tableBodyEl, countEl. Возвращает {pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}.
    • table/on_files_change.jsaddFiles, onFilesChange (фильтр расширений, ZIP).
    • table/on_folder_change.jsonFolderChange (webkitdirectory).
    • table/add_file_with_dedup.jsaddFileWithDedup (дедуп path + NUL + size).
    • table/render.jsrenderNode, 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.jslistZipFiles; на строке 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)

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.