fix: harden browser file picker and remove legacy

This commit is contained in:
“Naeel”
2026-09-05 20:50:36 +03:00
parent f6b2f6d1b3
commit 017bd8c354
20 changed files with 499 additions and 216 deletions
@@ -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__` и любых каталогов не выполнялось.
+78
View File
@@ -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-дефектами. Исправления кода в рамках этой записи не
выполнялись.
+35
View File
@@ -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 ошибки.
-11
View File
@@ -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
+76 -34
View File
@@ -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).
+35 -4
View File
@@ -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")
}; };
+35 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+1
View File
@@ -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;
}
+6 -1
View File
@@ -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;
} }
}; };
+5 -1
View File
@@ -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))) {
+3 -3
View File
@@ -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;
} }
-21
View File
@@ -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;
}
+4 -2
View File
@@ -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');