fix: harden browser file picker and remove legacy
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# Ревью Gemini 3.8 Flash — 2026-09-05
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Ревью ограничивалось проектом `upload-platform`. Проверены frontend-модули
|
||||||
|
универсального browser-only picker-а, Flask-обёртка, конфигурация и структура
|
||||||
|
проекта. Ниже зафиксированы findings ревью; исправления по этим пунктам в рамках
|
||||||
|
данной записи не выполнялись.
|
||||||
|
|
||||||
|
## Критические ошибки и логические баги (High)
|
||||||
|
|
||||||
|
1. **Не сбрасывается `fileInputEl.value` при выборе файлов.**
|
||||||
|
**Файл:** `upload/frontend/table/on_files_change.js`.
|
||||||
|
После удаления файла повторный выбор того же файла может не вызвать событие
|
||||||
|
`change`, если значение input не сброшено.
|
||||||
|
|
||||||
|
2. **Распаковка лишних файлов и преждевременный OOM в ZIP-фильтре.**
|
||||||
|
**Файл:** `upload/frontend/zip/list_zip_files.js`.
|
||||||
|
Callback `filter` проверяет размер и бюджет, но до декомпрессии не отбрасывает
|
||||||
|
записи с неразрешёнными расширениями. Бинарные файлы могут быть распакованы в
|
||||||
|
память, хотя затем будут отброшены.
|
||||||
|
|
||||||
|
3. **Синхронная блокировка UI при распаковке архивов.**
|
||||||
|
**Файл:** `upload/frontend/zip/list_zip_files.js`.
|
||||||
|
`unzipSync` выполняется в главном потоке браузера и может замораживать UI на
|
||||||
|
больших архивах. Предложенное направление: async unzip с чанками или Web Worker.
|
||||||
|
|
||||||
|
## Архитектурные недочёты и надёжность (Medium)
|
||||||
|
|
||||||
|
4. **Нарушается семантика `File.name` для файлов из папок и ZIP.**
|
||||||
|
**Файлы:** `upload/frontend/table/rebase_tree.js`,
|
||||||
|
`upload/frontend/zip/list_zip_files.js`.
|
||||||
|
При создании нового `File` полный логический путь может попасть в `file.name`,
|
||||||
|
хотя путь должен храниться отдельно в метаданных узла.
|
||||||
|
|
||||||
|
5. **Не выполняется слияние папок с одинаковыми путями.**
|
||||||
|
**Файл:** `upload/frontend/table/add_file_with_dedup.js`.
|
||||||
|
Повторное добавление корневого узла с тем же путём может создать два отдельных
|
||||||
|
узла вместо объединения их дочерних элементов.
|
||||||
|
|
||||||
|
6. **Потенциально небезопасная вставка HTML в статус.**
|
||||||
|
**Файл:** `upload/frontend/table/set_status.js`.
|
||||||
|
Прямое присваивание `innerHTML` требует гарантировать экранирование всех
|
||||||
|
передаваемых значений либо заменить его на безопасную работу с текстом.
|
||||||
|
|
||||||
|
7. **Молчаливый пропуск ошибок ZIP без `cfg.onError`.**
|
||||||
|
**Файлы:** `upload/frontend/table/on_files_change.js`,
|
||||||
|
`upload/frontend/table/on_folder_change.js`.
|
||||||
|
При отсутствии callback интегратор может не получить индикацию ошибки
|
||||||
|
повреждённого архива или превышения лимита.
|
||||||
|
|
||||||
|
8. **Падение `app.py` при отсутствии или повреждении `config.json`.**
|
||||||
|
**Файл:** `site/app.py`.
|
||||||
|
Чтение конфигурации выполняется на уровне импорта без обработки исключений;
|
||||||
|
при ошибке Flask-приложение не запускается.
|
||||||
|
|
||||||
|
## Мёртвый код и гигиена (Low)
|
||||||
|
|
||||||
|
9. **Дубликат `init_upload_table.js`.**
|
||||||
|
Файл выглядит неиспользуемым и дублирует логику текущего picker API.
|
||||||
|
Предложенное направление: удалить либо явно пометить как deprecated после
|
||||||
|
проверки всех импортов.
|
||||||
|
|
||||||
|
10. **Пустые служебные каталоги backend.**
|
||||||
|
Каталоги `session` и `upload_refs` содержат только старые `__pycache__`.
|
||||||
|
Требуется отдельное решение о сохранении или очистке структуры.
|
||||||
|
|
||||||
|
11. **Недостаточная обработка ошибок сборки в `build.mjs`.**
|
||||||
|
**Файл:** `build.mjs`.
|
||||||
|
Предложено добавить явную обработку ошибки сборки с `process.exit(1)` и
|
||||||
|
определить поведение каталога `dist/` перед сборкой.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Gemini 3.8 Flash создал 4 задачи: три high/medium направления по ZIP и
|
||||||
|
событиям выбора, а также дополнительный набор архитектурных и гигиенических
|
||||||
|
проверок. Эта запись фиксирует результаты ревью как backlog. Код проекта и
|
||||||
|
рабочая реализация picker-а в рамках документирования не изменялись.
|
||||||
|
|
||||||
|
## Проверка записи
|
||||||
|
|
||||||
|
- Файл ревью создан в `HISTORY/`.
|
||||||
|
- Исходники проекта не изменялись.
|
||||||
|
- Findings сохранены в исходной классификации High, Medium и Low.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Продолжение вопросов и ответов по ревью Gemini 3.8 Flash — 2026-09-05
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Зафиксирован второй блок ответов Gemini на дополнительные вопросы по ревью
|
||||||
|
`upload-platform`. Ответы уточняют границы ответственности picker-а, приоритеты
|
||||||
|
и декомпозицию findings. Код приложения в рамках документирования не изменялся.
|
||||||
|
|
||||||
|
## Уточнения
|
||||||
|
|
||||||
|
### №4: `File.name` и логический путь
|
||||||
|
|
||||||
|
Рекомендация Gemini: сохранять в `file.name` только базовое имя файла, а полный
|
||||||
|
логический путь передавать через `node.path` и контракт `getFiles()`.
|
||||||
|
|
||||||
|
Основание: стандартный browser `File` использует `name` как имя файла без
|
||||||
|
каталогов. Полный путь в `file.name` может некорректно обрабатываться при
|
||||||
|
`FormData.append('files', file)` и сторонними upload-библиотеками. Передача пути
|
||||||
|
третьим аргументом `FormData.append(name, file, filename)` устраняет проблему,
|
||||||
|
но перекладывает внутреннюю особенность picker-а на интегратора.
|
||||||
|
|
||||||
|
### №8: fallback для `config.json`
|
||||||
|
|
||||||
|
Пункт признан защитной мерой demo-сервера, а не production-риском библиотеки.
|
||||||
|
В целевом сценарии распространяется JavaScript bundle из `dist/`, а `site/app.py`
|
||||||
|
служит demo-обёрткой и не участвует в интеграции picker-а с host-системой. Поэтому
|
||||||
|
пункт не входит в обязательный pre-release топ-3.
|
||||||
|
|
||||||
|
### №3: порог `unzipSync`
|
||||||
|
|
||||||
|
Оценочный порог заметного фриза заявлен как 15–25 MiB сжатых данных или сотни
|
||||||
|
мелких XML/DOCX entries с суммарной распаковкой свыше 50 MiB. Для архивов до
|
||||||
|
10 MiB задержка обычно несущественна. Для целевого профиля офисных документов и
|
||||||
|
текущих лимитов переход на Web Worker признан преждевременной оптимизацией.
|
||||||
|
Приоритетнее сначала фильтровать расширения до декомпрессии и сохранять жёсткие
|
||||||
|
лимиты. Worker остаётся backlog для архивов порядка 100 MiB и более.
|
||||||
|
|
||||||
|
### №7: поведение без `onError`
|
||||||
|
|
||||||
|
Рекомендованный default — выводить ошибку в общий статус picker-а, например в
|
||||||
|
`elements.statusEl` или `.fp-status`. Молчаливый `continue` создаёт плохой UX:
|
||||||
|
пользователь не понимает, почему содержимое архива не появилось. Создание
|
||||||
|
неотправляемого сломанного корневого узла в таблице также признано нежелательным.
|
||||||
|
|
||||||
|
### №10: состояние backend-каталогов
|
||||||
|
|
||||||
|
По заявленному результату проверки файловой системы в `upload/backend/` остаются
|
||||||
|
только каталоги `session/` и `upload_refs/`, внутри которых нет исходников; есть
|
||||||
|
лишь пустые каталоги `__pycache__` от удалённых модулей. Вердикт Gemini: чинить
|
||||||
|
нечего, это остатки, которые можно удалить отдельным разрешённым изменением.
|
||||||
|
|
||||||
|
## Декомпозиция 11 findings на 4 задачи
|
||||||
|
|
||||||
|
### Задача 1: pre-release hotfixes UI и ZIP
|
||||||
|
|
||||||
|
- №1: сброс `fileInputEl.value` после обработки выбора;
|
||||||
|
- №2: фильтрация расширения внутри ZIP `filter` до декомпрессии;
|
||||||
|
- №5: слияние корневых папок с одинаковым путём.
|
||||||
|
|
||||||
|
Результат: корректный повторный выбор, снижение риска OOM и отсутствие дубликатов
|
||||||
|
корневых папок.
|
||||||
|
|
||||||
|
### Задача 2: контракт данных и обработка ошибок
|
||||||
|
|
||||||
|
- №4: базовое имя в `file.name`, путь в метаданных;
|
||||||
|
- №7: fallback-вывод ошибок ZIP в общий статус;
|
||||||
|
- №8: безопасная загрузка `config.json` с fallback-конфигурацией.
|
||||||
|
|
||||||
|
Результат: предсказуемый контракт browser `File` и понятная обратная связь.
|
||||||
|
|
||||||
|
### Задача 3: очистка репозитория
|
||||||
|
|
||||||
|
- №6: удаление или отдельное решение по legacy `set_status.js`;
|
||||||
|
- №9: удаление или отдельное решение по legacy `init_upload_table.js`;
|
||||||
|
- №10: удаление пустого `backend/` с остатками `__pycache__`;
|
||||||
|
- №11: определение поведения `dist/` перед сборкой.
|
||||||
|
|
||||||
|
Результат: в репозитории остаются актуальные исходники и ясная структура сборки.
|
||||||
|
|
||||||
|
### Задача 4: производительность больших архивов
|
||||||
|
|
||||||
|
- №3: перевод тяжёлой ZIP-распаковки в async-поток или Web Worker.
|
||||||
|
|
||||||
|
Задача отнесена в backlog и актуальна при появлении сценариев с очень большими
|
||||||
|
архивами.
|
||||||
|
|
||||||
|
## Итоговый приоритет
|
||||||
|
|
||||||
|
До следующего release в первую очередь предлагаются пункты **№2, №1 и №5**.
|
||||||
|
Пункты №4, №7, №8 и очистка legacy-кода относятся ко второй очереди. №3
|
||||||
|
остаётся производительным backlog до подтверждения реальных сценариев больших
|
||||||
|
архивов.
|
||||||
|
|
||||||
|
## Статус документирования
|
||||||
|
|
||||||
|
- Ответ Gemini сохранён в `HISTORY/`.
|
||||||
|
- Исходный код picker-а не изменялся.
|
||||||
|
- Удаление `__pycache__` и любых каталогов не выполнялось.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Вопросы и ответы по ревью Gemini 3.8 Flash — 2026-09-05
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
После критического разбора ревью Gemini 3.8 Flash были заданы уточняющие
|
||||||
|
вопросы по версии исходников, дублированию API, семантике `File.name`, legacy
|
||||||
|
XSS, `build.mjs`, ZIP-фильтрации и приоритетам исправлений.
|
||||||
|
|
||||||
|
## Зафиксированные ответы
|
||||||
|
|
||||||
|
### Версия кода и номера строк
|
||||||
|
|
||||||
|
Ревью выполнялось по исходникам `upload-platform`; `dist/` отдельно не
|
||||||
|
исследовался. Номера строк в первой сводке были смещены из-за объединения
|
||||||
|
отчётов субагента. Фактические участки находятся в текущих исходниках
|
||||||
|
`on_files_change.js` и `list_zip_files.js`.
|
||||||
|
|
||||||
|
### `init_upload_table.js`
|
||||||
|
|
||||||
|
Буквального совпадения интерфейса с `initFilePicker` нет: один entry point
|
||||||
|
принимает готовые DOM-узлы, другой создаёт DOM внутри `mount`. Однако внутренняя
|
||||||
|
логика жизненного цикла таблицы параллельна: состояние `nodes/fileKeys/busy`,
|
||||||
|
удаление узлов, делегирование кликов и похожий API. Файл не входит в граф сборки
|
||||||
|
`build.mjs`, поэтому это legacy-альтернатива, создающая риск рассинхронизации.
|
||||||
|
|
||||||
|
### `File.name` и логический путь
|
||||||
|
|
||||||
|
`flattenFiles()` возвращает `name`, `path` и `file` раздельно, но при `rebaseTree`
|
||||||
|
вложенный browser `File` получает полный путь в `file.name`. При прямом
|
||||||
|
`FormData.append('files', file)` сервер получает filename с разделителями пути.
|
||||||
|
Это может конфликтовать с серверными `basename`/`secure_filename` и нарушает
|
||||||
|
обычный контракт browser `File`, где `name` является базовым именем. При явной
|
||||||
|
передаче третьего аргумента `FormData.append('files', file, path)` проблема не
|
||||||
|
возникает.
|
||||||
|
|
||||||
|
### XSS в `set_status.js`
|
||||||
|
|
||||||
|
Замечание относится только к legacy-файлу. `set_status.js` не входит в активный
|
||||||
|
граф сборки и не используется боевым рендерером. В активном `render.js` значения
|
||||||
|
статуса экранируются через `esc()`. Реальная уязвимость в текущем активном
|
||||||
|
рендеринге не подтверждена; пункт относится к гигиене мёртвого кода.
|
||||||
|
|
||||||
|
### `build.mjs`
|
||||||
|
|
||||||
|
Для top-level `await` необработанная ошибка сборки сама приводит Node.js к
|
||||||
|
завершению с ненулевым кодом. Явные `try/catch` и `process.exit(1)` технически
|
||||||
|
избыточны. Реальное возможное улучшение этого пункта — определить поведение
|
||||||
|
каталога `dist/` перед сборкой, например его предварительную очистку.
|
||||||
|
|
||||||
|
### Проверка фильтра `fflate`
|
||||||
|
|
||||||
|
Проверка исходника `fflate` подтвердила, что `filter` вызывается после чтения
|
||||||
|
метаданных ZIP entry, но до копирования или декомпрессии данных. Если фильтр
|
||||||
|
возвращает `false`, `inflateSync` для этой записи не вызывается и буфер для
|
||||||
|
распакованных данных не создаётся. Поэтому добавление проверки расширения в
|
||||||
|
`filter` является корректным способом не распаковывать тяжёлые неразрешённые
|
||||||
|
entries.
|
||||||
|
|
||||||
|
### Приоритет до прода
|
||||||
|
|
||||||
|
Согласован обязательный приоритет:
|
||||||
|
|
||||||
|
1. **№2:** отбрасывать неразрешённые расширения внутри ZIP `filter` до
|
||||||
|
декомпрессии, чтобы снизить риск OOM и лишнего расхода бюджета.
|
||||||
|
2. **№1:** сбрасывать `elements.fileInputEl.value`, чтобы повторный выбор того
|
||||||
|
же файла после удаления снова генерировал `change`.
|
||||||
|
3. **№5:** объединять корневые папки с одинаковыми путями, чтобы избежать
|
||||||
|
дублирующихся деревьев при последовательном выборе.
|
||||||
|
|
||||||
|
Пункты `File.name`, `onError`, fallback-конфигурация, legacy-код и очистка
|
||||||
|
служебных каталогов отнесены ко второй очереди.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Уточнение подтвердило, что главным техническим finding является ZIP-фильтрация
|
||||||
|
до декомпрессии. Пункты про `set_status.js` и явный `process.exit(1)` не являются
|
||||||
|
активными production-дефектами. Исправления кода в рамках этой записи не
|
||||||
|
выполнялись.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# План исправлений по ревью Gemini — 2026-09-05
|
||||||
|
|
||||||
|
## Обязательный релизный набор
|
||||||
|
|
||||||
|
1. Сбрасывать `fileInputEl.value` после обработки выбора, чтобы повторный выбор
|
||||||
|
того же файла снова генерировал `change`.
|
||||||
|
2. Фильтровать ZIP entries по разрешённому расширению до декомпрессии, сохраняя
|
||||||
|
отдельный проход для вложенных `.zip`.
|
||||||
|
3. Объединять корневые папки с одинаковым путём вместо создания дублей.
|
||||||
|
4. Сохранять базовое имя в `File.name`, а логический путь — в `node.path` и
|
||||||
|
результате `flattenFiles()`.
|
||||||
|
5. Показывать ошибку ZIP в статусе по умолчанию, если `onError` не передан.
|
||||||
|
6. Использовать fallback-конфигурацию при отсутствии или повреждении
|
||||||
|
`config.json` в demo-сервере.
|
||||||
|
|
||||||
|
## Статус выполнения
|
||||||
|
|
||||||
|
- Выполнено: обязательный релизный набор пунктов 1–6.
|
||||||
|
- Выполнено: удалены legacy `set_status.js`, `init_upload_table.js` и пустой
|
||||||
|
`upload/backend/` с остатками `__pycache__`.
|
||||||
|
- Выполнено: живая документация очищена от ссылок на удалённую legacy-архитектуру.
|
||||||
|
|
||||||
|
## Отложенные задачи
|
||||||
|
|
||||||
|
- Перевести тяжёлую синхронную ZIP-распаковку в Web Worker после подтверждения
|
||||||
|
реальными измерениями необходимости.
|
||||||
|
- Перед сборкой определить явное поведение каталога `dist/`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- Пересобрать ESM/IIFE bundles.
|
||||||
|
- Проверить синтаксис изменённых JavaScript и Python-файлов.
|
||||||
|
- Обновить версию с `0.1.11` до `0.1.12` в `package.json` и `site/app.py`.
|
||||||
|
- Выполнить browser smoke-тесты для повторного выбора, ZIP-фильтрации,
|
||||||
|
слияния папок и fallback ошибки.
|
||||||
@@ -77,22 +77,11 @@ upload-platform/
|
|||||||
│ │ │ ├── esc.js
|
│ │ │ ├── esc.js
|
||||||
│ │ │ ├── add_file_with_dedup.js
|
│ │ │ ├── add_file_with_dedup.js
|
||||||
│ │ │ ├── render.js
|
│ │ │ ├── render.js
|
||||||
│ │ │ ├── set_status.js
|
|
||||||
│ │ │ ├── on_files_change.js
|
│ │ │ ├── on_files_change.js
|
||||||
│ │ │ ├── on_folder_change.js
|
│ │ │ ├── on_folder_change.js
|
||||||
│ │ │ └── init_upload_table.js
|
|
||||||
│ │ └── upload/ # слой 2 (фронт)
|
│ │ └── upload/ # слой 2 (фронт)
|
||||||
│ │ ├── put_to_vm.js
|
│ │ ├── put_to_vm.js
|
||||||
│ │ └── upload_via_vm.js
|
│ │ └── upload_via_vm.js
|
||||||
│ └── backend/
|
|
||||||
│ ├── upload_refs/ # слой 2 (бэк)
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── config.py
|
|
||||||
│ │ ├── safe_name.py
|
|
||||||
│ │ ├── pull_file.py
|
|
||||||
│ │ └── blueprint.py
|
|
||||||
│ └── session/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── state.py
|
│ ├── state.py
|
||||||
│ ├── create_session.py
|
│ ├── create_session.py
|
||||||
│ ├── add_file.py
|
│ ├── add_file.py
|
||||||
|
|||||||
@@ -1,40 +1,68 @@
|
|||||||
# Upload Platform
|
# Upload Platform
|
||||||
|
|
||||||
Переиспользуемый browser-only file-picker. Модуль выбирает отдельные файлы,
|
Переиспользуемый browser-only file-picker версии `0.1.12`. Он выбирает файлы,
|
||||||
папки и ZIP, строит сворачиваемое дерево и возвращает исходные browser `File`
|
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
|
||||||
objects с полными путями. Файлы не отправляются на VM и не загружаются через
|
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
|
||||||
backend.
|
загружает его на VM, в Flask или в другое хранилище.
|
||||||
|
|
||||||
## Слои
|
## Карта файлов
|
||||||
|
|
||||||
| Слой | Ответственность |
|
### Корень проекта
|
||||||
|
|
||||||
|
| Путь | Назначение | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| `package.json` | Версия пакета и команда `npm run build`. | Используется |
|
||||||
|
| `package-lock.json` | Зафиксированные npm-зависимости. | Используется |
|
||||||
|
| `build.mjs` | esbuild-сборка ESM и IIFE bundle. | Используется |
|
||||||
|
| `requirements.txt` | Python-зависимости demo-сервера. | Используется demo |
|
||||||
|
| `config.json` | Конфигурация demo: разрешённые расширения. | Используется demo |
|
||||||
|
| `dist/file-picker.esm.js` | Готовый ESM bundle для интеграции. | Используется |
|
||||||
|
| `dist/file-picker.iife.js` | Готовый IIFE bundle `FilePicker`. | Используется |
|
||||||
|
| `upload/` | Исходники переиспользуемого picker-модуля. | Используется |
|
||||||
|
| `site/` | Flask demo, который раздаёт страницу и bundle. | Используется только demo |
|
||||||
|
| `docs/` | Справочная и историческая документация. | См. раздел LEGACY |
|
||||||
|
| `HISTORY/` | Архив решений, ревью и результатов проверок. | Только история |
|
||||||
|
|
||||||
|
### `upload/` — исходники picker-а
|
||||||
|
|
||||||
|
| Путь | Назначение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Конфиг | `allowedExt`, `labels`, `layout`, `limits` |
|
| `upload/README.md` | Краткая инструкция интеграции готового bundle. |
|
||||||
| Picker | таблица, дедупликация, ZIP, рекурсивный обход папки |
|
| `upload/config.example.json` | Пример конфигурации разрешённых расширений. |
|
||||||
| Интегрирующее приложение | получает leaf-файлы через `getFiles()` |
|
| `upload/frontend/index.js` | Единственная актуальная точка входа `initFilePicker(config)`, DOM, lifecycle API. |
|
||||||
|
| `upload/frontend/table/add_file_with_dedup.js` | Дедупликация файлов и слияние одинаковых корней. |
|
||||||
|
| `upload/frontend/table/esc.js` | HTML-экранирование имён, путей и атрибутов. |
|
||||||
|
| `upload/frontend/table/fs.js` | Форматирование размеров файлов. |
|
||||||
|
| `upload/frontend/table/on_files_change.js` | Выбор файлов, фильтрация, ZIP-разбор и fallback ошибок. |
|
||||||
|
| `upload/frontend/table/on_folder_change.js` | Выбор папки через `webkitdirectory` и построение дерева. |
|
||||||
|
| `upload/frontend/table/rebase_tree.js` | Добавление префикса пути без изменения базового `File.name`. |
|
||||||
|
| `upload/frontend/table/render.js` | Рендер дерева, счётчик, поиск узлов и `flattenFiles()`. |
|
||||||
|
| `upload/frontend/zip/list_zip_files.js` | Безопасный рекурсивный разбор ZIP через встроенный `fflate`. |
|
||||||
|
|
||||||
## Структура
|
### `site/` — demo-обёртка
|
||||||
|
|
||||||
```
|
| Путь | Назначение |
|
||||||
upload-platform/
|
|---|---|
|
||||||
├── requirements.txt
|
| `site/app.py` | Flask entrypoint, `/`, `/health`, раздача bundle и совместимый маршрут исходников. |
|
||||||
├── config.json # слой 0 (рабочий конфиг этого демо)
|
| `site/templates/index.html` | Demo-страница и вызов `FilePicker.initFilePicker()`. |
|
||||||
├── package.json # сборка browser bundle
|
| `site/static/style.css` | Стили demo-страницы. |
|
||||||
├── build.mjs # esbuild: ESM + IIFE
|
| `site/routes/` | Текущий каталог маршрутов; прикладного upload backend в нём нет. |
|
||||||
├── dist/ # готовые bundle для встраивания
|
|
||||||
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
|
### `docs/`
|
||||||
│ ├── README.md # инструкция интеграции
|
|
||||||
│ ├── config.example.json
|
| Путь | Назначение | Статус |
|
||||||
│ ├── frontend/ # picker (vanilla JS, ES-модули)
|
|---|---|---|
|
||||||
│ │ ├── zip/
|
| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать |
|
||||||
│ │ ├── table/
|
| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план |
|
||||||
│ └── backend/ # LEGACY: VM upload/session remnants
|
| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать |
|
||||||
└── site/ # демо-обёртка (не переиспользуется)
|
| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация |
|
||||||
├── app.py
|
|
||||||
├── routes/
|
### `HISTORY/`
|
||||||
├── templates/
|
|
||||||
└── static/
|
Файлы `HISTORY/` фиксируют состояние проекта на даты ревью, тестов и решений.
|
||||||
```
|
Они могут описывать удалённые файлы, VM-upload или старые версии. Это архив,
|
||||||
|
а не инструкция: при расхождении с кодом руководствоваться только исходниками,
|
||||||
|
`README.md`, `upload/README.md` и `docs/CODE-REFERENCE.md`.
|
||||||
|
|
||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
|
|
||||||
@@ -76,10 +104,24 @@ ZIP-файлы используются как контейнеры и раск
|
|||||||
|
|
||||||
## Legacy
|
## Legacy
|
||||||
|
|
||||||
`upload/backend/` и старые VM-upload упоминания сохранены только для истории и
|
### Удалено и не должно восстанавливаться
|
||||||
совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их
|
|
||||||
не импортирует и не требует Flask API для обработки файлов.
|
- `upload/backend/` — старый backend VM-upload, sessions и pull API;
|
||||||
Переиспользуемая инструкция находится в `upload/README.md`.
|
- `upload/frontend/table/init_upload_table.js` — старый entry point с готовыми DOM-узлами;
|
||||||
|
- `upload/frontend/table/set_status.js` — неиспользуемый legacy helper;
|
||||||
|
- `upload/frontend/upload/` — старый frontend VM-upload layer.
|
||||||
|
|
||||||
|
Эти части не входят в сборку `build.mjs`, не импортируются текущим picker-ом и
|
||||||
|
не являются частью API. Не добавлять их обратно при интеграции.
|
||||||
|
|
||||||
|
### Исторические документы
|
||||||
|
|
||||||
|
`PLAN.md`, `docs/PLAN-componentization.md`, review prompts и записи `HISTORY/`
|
||||||
|
могут содержать описания старой VM/backend-архитектуры. Они нужны для аудита
|
||||||
|
решений, но не являются актуальной спецификацией и не должны использоваться
|
||||||
|
как план разработки.
|
||||||
|
|
||||||
|
Актуальная инструкция интеграции находится в `upload/README.md`.
|
||||||
|
|
||||||
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
|
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
|
||||||
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
||||||
|
|||||||
Vendored
+35
-4
@@ -475,7 +475,7 @@ function unzipSync(data, opts) {
|
|||||||
// upload/frontend/table/rebase_tree.js
|
// upload/frontend/table/rebase_tree.js
|
||||||
function rebaseTree(root, prefix) {
|
function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
root.path = `${prefix}/${root.path}`;
|
||||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ function extensionAllowed(name, allowedExt) {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name.split("/").at(-1));
|
||||||
}
|
}
|
||||||
function safeEntryParts(entryName) {
|
function safeEntryParts(entryName) {
|
||||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||||
@@ -521,6 +521,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
|||||||
filter: (entry) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
budget.entries += 1;
|
||||||
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
||||||
|
const lowerName = entry.name.toLowerCase();
|
||||||
|
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false;
|
||||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||||
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
||||||
@@ -570,9 +572,28 @@ function addFileWithDedup(state, fileNode) {
|
|||||||
};
|
};
|
||||||
const accepted = accept(fileNode);
|
const accepted = accept(fileNode);
|
||||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
||||||
|
if (accepted.kind !== "file") {
|
||||||
|
const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.path === accepted.path);
|
||||||
|
if (existing) {
|
||||||
|
mergeChildren(existing, accepted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
state.nodes.push(accepted);
|
state.nodes.push(accepted);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
function mergeChildren(target, incoming) {
|
||||||
|
incoming.children.forEach((child) => {
|
||||||
|
if (child.kind === "file") {
|
||||||
|
const duplicate = target.children.some((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.file.size === child.file.size);
|
||||||
|
if (!duplicate) target.children.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = target.children.find((candidate) => candidate.kind !== "file" && candidate.path === child.path);
|
||||||
|
if (existing) mergeChildren(existing, child);
|
||||||
|
else target.children.push(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// upload/frontend/table/esc.js
|
// upload/frontend/table/esc.js
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -682,7 +703,11 @@ async function addFiles(state, cfg, files, elements) {
|
|||||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
if (typeof cfg.onError === "function") {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,6 +720,7 @@ function onFilesChange(state, cfg, elements) {
|
|||||||
try {
|
try {
|
||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
elements.fileInputEl.value = "";
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -751,7 +777,11 @@ function onFolderChange(state, cfg, elements) {
|
|||||||
addToFolder(zip);
|
addToFolder(zip);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
if (typeof cfg.onError === "function") {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
@@ -884,6 +914,7 @@ function initFilePicker(config) {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector(".fp-file-input"),
|
fileInputEl: root.querySelector(".fp-file-input"),
|
||||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||||
|
statusEl: root.querySelector(".fp-status"),
|
||||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||||
countEl: root.querySelector(".fp-count")
|
countEl: root.querySelector(".fp-count")
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+35
-4
@@ -501,7 +501,7 @@ var FilePicker = (() => {
|
|||||||
// upload/frontend/table/rebase_tree.js
|
// upload/frontend/table/rebase_tree.js
|
||||||
function rebaseTree(root, prefix) {
|
function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
root.path = `${prefix}/${root.path}`;
|
||||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -518,7 +518,7 @@ var FilePicker = (() => {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name.split("/").at(-1));
|
||||||
}
|
}
|
||||||
function safeEntryParts(entryName) {
|
function safeEntryParts(entryName) {
|
||||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||||
@@ -547,6 +547,8 @@ var FilePicker = (() => {
|
|||||||
filter: (entry) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
budget.entries += 1;
|
||||||
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
||||||
|
const lowerName = entry.name.toLowerCase();
|
||||||
|
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false;
|
||||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||||
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
||||||
@@ -596,9 +598,28 @@ var FilePicker = (() => {
|
|||||||
};
|
};
|
||||||
const accepted = accept(fileNode);
|
const accepted = accept(fileNode);
|
||||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
||||||
|
if (accepted.kind !== "file") {
|
||||||
|
const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.path === accepted.path);
|
||||||
|
if (existing) {
|
||||||
|
mergeChildren(existing, accepted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
state.nodes.push(accepted);
|
state.nodes.push(accepted);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
function mergeChildren(target, incoming) {
|
||||||
|
incoming.children.forEach((child) => {
|
||||||
|
if (child.kind === "file") {
|
||||||
|
const duplicate = target.children.some((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.file.size === child.file.size);
|
||||||
|
if (!duplicate) target.children.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = target.children.find((candidate) => candidate.kind !== "file" && candidate.path === child.path);
|
||||||
|
if (existing) mergeChildren(existing, child);
|
||||||
|
else target.children.push(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// upload/frontend/table/esc.js
|
// upload/frontend/table/esc.js
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -708,7 +729,11 @@ var FilePicker = (() => {
|
|||||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
if (typeof cfg.onError === "function") {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,6 +746,7 @@ var FilePicker = (() => {
|
|||||||
try {
|
try {
|
||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
elements.fileInputEl.value = "";
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -777,7 +803,11 @@ var FilePicker = (() => {
|
|||||||
addToFolder(zip);
|
addToFolder(zip);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
if (typeof cfg.onError === "function") {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
@@ -910,6 +940,7 @@ var FilePicker = (() => {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector(".fp-file-input"),
|
fileInputEl: root.querySelector(".fp-file-input"),
|
||||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||||
|
statusEl: root.querySelector(".fp-status"),
|
||||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||||
countEl: root.querySelector(".fp-count")
|
countEl: root.querySelector(".fp-count")
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-30
@@ -1,7 +1,7 @@
|
|||||||
# Справочник кода Upload Platform
|
# Справочник кода Upload Platform
|
||||||
|
|
||||||
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
|
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
|
||||||
и API загрузки являются legacy и в текущем коде не используются.
|
и API загрузки относятся к отменённой архитектуре и в текущем коде отсутствуют.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ Flask отдаёт страницу и готовый bundle. Все выбра
|
|||||||
|
|
||||||
| Функция | Вход | Выход | Побочные эффекты |
|
| Функция | Вход | Выход | Побочные эффекты |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf создаёт File с новым именем. |
|
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf сохраняет базовое имя `File.name`. |
|
||||||
|
|
||||||
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
|
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
|
||||||
и для nested ZIP.
|
и для nested ZIP.
|
||||||
@@ -107,26 +107,6 @@ callback задан. `finally` обязательно освобождает `bu
|
|||||||
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
||||||
дочерние строки только при `expanded === true`.
|
дочерние строки только при `expanded === true`.
|
||||||
|
|
||||||
### `init_upload_table.js` (legacy)
|
|
||||||
|
|
||||||
| Функция | Вход | Выход | Назначение |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `removeFileMeta(state, node)` | state и удаляемое поддерево | `void` | Рекурсивно удаляет dedup-ключи. Внутренняя функция. |
|
|
||||||
| `removeNode(state, id)` | state и id | `void` | Удаляет узел из родительского массива. Внутренняя функция. |
|
|
||||||
| `initUploadTable(cfg)` | `allowedExt` и четыре DOM-элемента | API-объект | Создаёт state, события, render и публичные операции. |
|
|
||||||
|
|
||||||
Публичный API legacy-совместимости:
|
|
||||||
|
|
||||||
| Метод | Вход | Результат |
|
|
||||||
|---|---|---|
|
|
||||||
| `pickFiles()` | нет | Открывает обычный file input. |
|
|
||||||
| `pickFolder()` | нет | Открывает folder input. |
|
|
||||||
| `addFiles(files)` | FileList/Array | Добавляет файлы с фильтрацией и ZIP-разбором. |
|
|
||||||
| `getFiles()` | нет | `{path, name, size, file}[]` только для leaf. |
|
|
||||||
| `remove(id)` | id узла | Удаляет узел и его dedup-ключи. |
|
|
||||||
| `render()` | нет | Перерисовывает текущий state. |
|
|
||||||
| `clear()` | нет | Очищает дерево и dedup Set. |
|
|
||||||
|
|
||||||
## `upload/frontend/zip`
|
## `upload/frontend/zip`
|
||||||
|
|
||||||
### `list_zip_files.js`
|
### `list_zip_files.js`
|
||||||
@@ -134,7 +114,7 @@ callback задан. `finally` обязательно освобождает `bu
|
|||||||
| Функция | Вход | Выход | Назначение |
|
| Функция | Вход | Выход | Назначение |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
|
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
|
||||||
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File. Внутренняя. |
|
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. |
|
||||||
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
|
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
|
||||||
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
|
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
|
||||||
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
|
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
|
||||||
@@ -168,7 +148,7 @@ vendor script не требуется.
|
|||||||
| `VERSION` | константа | Версия страницы |
|
| `VERSION` | константа | Версия страницы |
|
||||||
| `index()` / `/` | HTTP GET | HTML с version и config |
|
| `index()` / `/` | HTTP GET | HTML с version и config |
|
||||||
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
|
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
|
||||||
| `upload_frontend(filename)` | HTTP GET и относительный путь | Legacy-файл из `upload/frontend` |
|
| `upload_frontend(filename)` | HTTP GET и относительный путь | Совместимый маршрут для исходных frontend-модулей |
|
||||||
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
|
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
|
||||||
|
|
||||||
### `site/templates/index.html`
|
### `site/templates/index.html`
|
||||||
@@ -176,9 +156,5 @@ vendor script не требуется.
|
|||||||
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
|
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
|
||||||
сброса browser cache и вызывает `FilePicker.initFilePicker`.
|
сброса browser cache и вызывает `FilePicker.initFilePicker`.
|
||||||
|
|
||||||
## Legacy
|
Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это
|
||||||
|
внешняя библиотека, используемая как готовый runtime dependency.
|
||||||
`upload/frontend/table/set_status.js` не импортируется актуальной страницей и
|
|
||||||
сохранён как legacy-заготовка. Vendor `site/static/vendor/fflate.min.js` не
|
|
||||||
документируется построчно: это внешняя библиотека, используемая как готовый
|
|
||||||
runtime dependency.
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-platform-file-picker",
|
"name": "upload-platform-file-picker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs"
|
"build": "node build.mjs"
|
||||||
|
|||||||
+7
-3
@@ -6,11 +6,15 @@ from flask import Flask, render_template, send_from_directory
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
||||||
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]}
|
||||||
CONFIG = json.load(config_file)
|
try:
|
||||||
|
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
||||||
|
CONFIG = json.load(config_file)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
CONFIG = DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
VERSION = "0.1.11"
|
VERSION = "0.1.12"
|
||||||
|
|
||||||
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|||||||
+3
-3
@@ -25,9 +25,9 @@ Picker поддерживает разрешённые документы, па
|
|||||||
|
|
||||||
## Legacy
|
## Legacy
|
||||||
|
|
||||||
Каталоги `backend/` и старые ссылки на `uploadViaVM`/VM-буфер относятся к
|
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
|
||||||
предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only
|
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
|
||||||
интеграции они не нужны.
|
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
|
||||||
|
|
||||||
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||||
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export function initFilePicker(config) {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector('.fp-file-input'),
|
fileInputEl: root.querySelector('.fp-file-input'),
|
||||||
folderInputEl: root.querySelector('.fp-folder-input'),
|
folderInputEl: root.querySelector('.fp-folder-input'),
|
||||||
|
statusEl: root.querySelector('.fp-status'),
|
||||||
tableBodyEl: root.querySelector('.fp-table-body'),
|
tableBodyEl: root.querySelector('.fp-table-body'),
|
||||||
countEl: root.querySelector('.fp-count'),
|
countEl: root.querySelector('.fp-count'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,28 @@ export function addFileWithDedup(state, fileNode) {
|
|||||||
const accepted = accept(fileNode);
|
const accepted = accept(fileNode);
|
||||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||||
|
if (accepted.kind !== 'file') {
|
||||||
|
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
|
||||||
|
if (existing) {
|
||||||
|
mergeChildren(existing, accepted);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
state.nodes.push(accepted);
|
state.nodes.push(accepted);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeChildren(target, incoming) {
|
||||||
|
incoming.children.forEach((child) => {
|
||||||
|
if (child.kind === 'file') {
|
||||||
|
const duplicate = target.children.some((existing) => existing.kind === 'file'
|
||||||
|
&& existing.path === child.path && existing.file.size === child.file.size);
|
||||||
|
if (!duplicate) target.children.push(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = target.children.find((candidate) => candidate.kind !== 'file'
|
||||||
|
&& candidate.path === child.path);
|
||||||
|
if (existing) mergeChildren(existing, child);
|
||||||
|
else target.children.push(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { addFiles, onFilesChange } from './on_files_change.js';
|
|
||||||
import { onFolderChange } from './on_folder_change.js';
|
|
||||||
import { findNode, flattenFiles, render } from './render.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет ключи дедупликации для leaf-файлов внутри удаляемого поддерева.
|
|
||||||
*
|
|
||||||
* @param {{fileKeys: Set<string>}} state Состояние дедупликации.
|
|
||||||
* @param {{kind: string, path: string, file?: File, children?: Array}} node Удаляемый узел.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function removeFileMeta(state, node) {
|
|
||||||
if (node.kind === 'file') {
|
|
||||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.children.forEach((child) => removeFileMeta(state, child));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Удаляет узел из дерева и синхронно освобождает его dedup-ключи.
|
|
||||||
*
|
|
||||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Состояние таблицы.
|
|
||||||
* @param {string} id Идентификатор узла.
|
|
||||||
* @returns {void} Ничего не делает, если id не найден.
|
|
||||||
*/
|
|
||||||
function removeNode(state, id) {
|
|
||||||
const found = findNode(state.nodes, id);
|
|
||||||
if (!found) return;
|
|
||||||
removeFileMeta(state, found.node);
|
|
||||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт picker и возвращает его публичный API.
|
|
||||||
*
|
|
||||||
* @param {{allowedExt: string[], fileInputEl: HTMLInputElement,
|
|
||||||
* folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} cfg
|
|
||||||
* Конфигурация и обязательные DOM-элементы интегратора.
|
|
||||||
* @returns {{pickFiles: Function, pickFolder: Function, addFiles: Function,
|
|
||||||
* getFiles: Function, remove: Function, render: Function, clear: Function}}
|
|
||||||
* Управляющий API без прямого доступа к внутреннему state.
|
|
||||||
*
|
|
||||||
* Функция регистрирует DOM-события один раз, хранит дерево и dedup Set внутри
|
|
||||||
* замыкания и сразу рисует пустое состояние. Внешнее приложение получает только
|
|
||||||
* операции выбора, добавления, чтения и удаления.
|
|
||||||
*/
|
|
||||||
export function initUploadTable(cfg) {
|
|
||||||
// Нормализованный набор DOM-ссылок передаётся во все функции рендера.
|
|
||||||
const elements = {
|
|
||||||
fileInputEl: cfg.fileInputEl,
|
|
||||||
folderInputEl: cfg.folderInputEl,
|
|
||||||
tableBodyEl: cfg.tableBodyEl,
|
|
||||||
countEl: cfg.countEl,
|
|
||||||
};
|
|
||||||
// nodes — дерево; fileKeys — ключи path+NUL+size; busy защищает async change handlers.
|
|
||||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
|
||||||
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
|
|
||||||
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
|
|
||||||
elements.tableBodyEl.addEventListener('click', (event) => {
|
|
||||||
// Делегирование событий позволяет обслуживать динамически созданные кнопки.
|
|
||||||
const toggle = event.target.closest('[data-toggle]');
|
|
||||||
if (toggle) {
|
|
||||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
|
||||||
if (found) found.node.expanded = !found.node.expanded;
|
|
||||||
render(state, elements, cfg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const remove = event.target.closest('[data-remove]');
|
|
||||||
if (remove) {
|
|
||||||
removeNode(state, remove.dataset.remove);
|
|
||||||
render(state, elements, cfg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
// Открывают системные диалоги, не обходя браузерные ограничения File API.
|
|
||||||
pickFiles: () => elements.fileInputEl.click(),
|
|
||||||
pickFolder: () => elements.folderInputEl.click(),
|
|
||||||
// Программное добавление использует тот же фильтр и ZIP-парсер, что и input.
|
|
||||||
addFiles: (files) => addFiles(state, cfg, files, elements),
|
|
||||||
// Возвращает только leaf-файлы; группы и внутренний state наружу не выдаются.
|
|
||||||
getFiles: () => flattenFiles(state.nodes),
|
|
||||||
remove: (id) => {
|
|
||||||
removeNode(state, id);
|
|
||||||
render(state, elements, cfg);
|
|
||||||
},
|
|
||||||
render: () => render(state, elements),
|
|
||||||
clear: () => {
|
|
||||||
// Очистка сбрасывает и дерево, и dedup Set, чтобы повторный выбор был возможен.
|
|
||||||
state.nodes = [];
|
|
||||||
state.fileKeys.clear();
|
|
||||||
render(state, elements);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
api.render();
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,11 @@ export async function addFiles(state, cfg, files, elements) {
|
|||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
if (typeof cfg.onError === 'function') {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,7 @@ export function onFilesChange(state, cfg, elements) {
|
|||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||||
|
elements.fileInputEl.value = '';
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
if (typeof cfg.onError === 'function') {
|
||||||
|
cfg.onError(error, file);
|
||||||
|
} else if (elements.statusEl) {
|
||||||
|
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||||
* @returns {object} Тот же узел после изменения путей.
|
* @returns {object} Тот же узел после изменения путей.
|
||||||
*
|
*
|
||||||
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
|
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||||
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
|
* хранится отдельно в узле дерева.
|
||||||
*/
|
*/
|
||||||
export function rebaseTree(root, prefix) {
|
export function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
root.path = `${prefix}/${root.path}`;
|
||||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
|
||||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { flattenFiles } from './render.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy helper: заменяет HTML статуса у найденного leaf-файла.
|
|
||||||
*
|
|
||||||
* @param {string} path Полный логический путь файла в текущем дереве.
|
|
||||||
* @param {string} html Готовая HTML-строка статуса; вызывающая сторона отвечает
|
|
||||||
* за её безопасность.
|
|
||||||
* @param {{nodes: Array}} state Состояние дерева, из которого ищется файл.
|
|
||||||
* @param {{tableBodyEl: HTMLElement}} elements DOM-элементы старого renderer.
|
|
||||||
* @returns {void} Ничего не возвращает; при отсутствии файла или ячейки ничего
|
|
||||||
* не изменяет.
|
|
||||||
*/
|
|
||||||
export function setStatus(path, html, state, elements) {
|
|
||||||
const file = flattenFiles(state.nodes).find((item) => item.path === path);
|
|
||||||
if (!file) return;
|
|
||||||
const cell = Array.from(elements.tableBodyEl.querySelectorAll('.tree-row.tree-file'))
|
|
||||||
.find((row) => row.dataset.path === file.path)
|
|
||||||
?.querySelector('td:nth-child(3)');
|
|
||||||
if (cell) cell.innerHTML = html;
|
|
||||||
}
|
|
||||||
@@ -15,9 +15,9 @@ function extensionAllowed(name, allowedExt) {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
|
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name.split('/').at(-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +74,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
|||||||
filter: (entry) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
budget.entries += 1;
|
||||||
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
|
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
|
||||||
|
const lowerName = entry.name.toLowerCase();
|
||||||
|
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
|
||||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||||
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
||||||
|
|||||||
Reference in New Issue
Block a user