diff --git a/HISTORY/2026-09-05-gemini-3.8-flash-review.md b/HISTORY/2026-09-05-gemini-3.8-flash-review.md new file mode 100644 index 0000000..5caaf81 --- /dev/null +++ b/HISTORY/2026-09-05-gemini-3.8-flash-review.md @@ -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. diff --git a/HISTORY/2026-09-05-gemini-review-followup.md b/HISTORY/2026-09-05-gemini-review-followup.md new file mode 100644 index 0000000..129deec --- /dev/null +++ b/HISTORY/2026-09-05-gemini-review-followup.md @@ -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__` и любых каталогов не выполнялось. diff --git a/HISTORY/2026-09-05-gemini-review-qa.md b/HISTORY/2026-09-05-gemini-review-qa.md new file mode 100644 index 0000000..bb42e23 --- /dev/null +++ b/HISTORY/2026-09-05-gemini-review-qa.md @@ -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-дефектами. Исправления кода в рамках этой записи не +выполнялись. diff --git a/HISTORY/2026-09-05-remediation-plan.md b/HISTORY/2026-09-05-remediation-plan.md new file mode 100644 index 0000000..12a383b --- /dev/null +++ b/HISTORY/2026-09-05-remediation-plan.md @@ -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 ошибки. diff --git a/PLAN.md b/PLAN.md index c2aec7c..a2415cb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -77,22 +77,11 @@ upload-platform/ │ │ │ ├── esc.js │ │ │ ├── add_file_with_dedup.js │ │ │ ├── render.js -│ │ │ ├── set_status.js │ │ │ ├── on_files_change.js │ │ │ ├── on_folder_change.js -│ │ │ └── init_upload_table.js │ │ └── upload/ # слой 2 (фронт) │ │ ├── put_to_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 │ ├── create_session.py │ ├── add_file.py diff --git a/README.md b/README.md index cffe4a7..7dba2a0 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,68 @@ # Upload Platform -Переиспользуемый browser-only file-picker. Модуль выбирает отдельные файлы, -папки и ZIP, строит сворачиваемое дерево и возвращает исходные browser `File` -objects с полными путями. Файлы не отправляются на VM и не загружаются через -backend. +Переиспользуемый browser-only file-picker версии `0.1.12`. Он выбирает файлы, +папки и ZIP, строит дерево и возвращает интегрирующему приложению browser +`File` objects. Содержимое файлов остаётся в браузере: текущий проект не +загружает его на 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` | -| Picker | таблица, дедупликация, ZIP, рекурсивный обход папки | -| Интегрирующее приложение | получает leaf-файлы через `getFiles()` | +| `upload/README.md` | Краткая инструкция интеграции готового bundle. | +| `upload/config.example.json` | Пример конфигурации разрешённых расширений. | +| `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 -├── config.json # слой 0 (рабочий конфиг этого демо) -├── package.json # сборка browser bundle -├── build.mjs # esbuild: ESM + IIFE -├── dist/ # готовые bundle для встраивания -├── upload/ # ← переиспользуемый модуль (копируется в любой проект) -│ ├── README.md # инструкция интеграции -│ ├── config.example.json -│ ├── frontend/ # picker (vanilla JS, ES-модули) -│ │ ├── zip/ -│ │ ├── table/ -│ └── backend/ # LEGACY: VM upload/session remnants -└── site/ # демо-обёртка (не переиспользуется) - ├── app.py - ├── routes/ - ├── templates/ - └── static/ -``` +| Путь | Назначение | +|---|---| +| `site/app.py` | Flask entrypoint, `/`, `/health`, раздача bundle и совместимый маршрут исходников. | +| `site/templates/index.html` | Demo-страница и вызов `FilePicker.initFilePicker()`. | +| `site/static/style.css` | Стили demo-страницы. | +| `site/routes/` | Текущий каталог маршрутов; прикладного upload backend в нём нет. | + +### `docs/` + +| Путь | Назначение | Статус | +|---|---|---| +| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать | +| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план | +| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать | +| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация | + +### `HISTORY/` + +Файлы `HISTORY/` фиксируют состояние проекта на даты ревью, тестов и решений. +Они могут описывать удалённые файлы, VM-upload или старые версии. Это архив, +а не инструкция: при расхождении с кодом руководствоваться только исходниками, +`README.md`, `upload/README.md` и `docs/CODE-REFERENCE.md`. ## Локальный запуск @@ -76,10 +104,24 @@ ZIP-файлы используются как контейнеры и раск ## Legacy -`upload/backend/` и старые VM-upload упоминания сохранены только для истории и -совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их -не импортирует и не требует Flask API для обработки файлов. -Переиспользуемая инструкция находится в `upload/README.md`. +### Удалено и не должно восстанавливаться + +- `upload/backend/` — старый backend VM-upload, sessions и pull API; +- `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-контрактов и ограничений находится в [`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md). diff --git a/dist/file-picker.esm.js b/dist/file-picker.esm.js index cc2f608..81342e6 100644 --- a/dist/file-picker.esm.js +++ b/dist/file-picker.esm.js @@ -475,7 +475,7 @@ function unzipSync(data, opts) { // upload/frontend/table/rebase_tree.js function rebaseTree(root, prefix) { 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)); return root; } @@ -492,7 +492,7 @@ function extensionAllowed(name, allowedExt) { return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); } function makeFile(data, name) { - return new File([data], name); + return new File([data], name.split("/").at(-1)); } function safeEntryParts(entryName) { if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null; @@ -521,6 +521,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) { filter: (entry) => { 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"); + const lowerName = entry.name.toLowerCase(); + if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false; if (entry.originalSize > limits.maxEntryBytes) return false; 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"); @@ -570,9 +572,28 @@ function addFileWithDedup(state, fileNode) { }; const accepted = accept(fileNode); 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); 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 function esc(value) { @@ -682,7 +703,11 @@ async function addFiles(state, cfg, files, elements) { const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits); if (zipTree) addFileWithDedup(state, zipTree); } 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; } } @@ -695,6 +720,7 @@ function onFilesChange(state, cfg, elements) { try { await addFiles(state, cfg, elements.fileInputEl.files, elements); } finally { + elements.fileInputEl.value = ""; state.busy = false; } }; @@ -751,7 +777,11 @@ function onFolderChange(state, cfg, elements) { addToFolder(zip); } } 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; } } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { @@ -884,6 +914,7 @@ function initFilePicker(config) { const elements = { fileInputEl: root.querySelector(".fp-file-input"), folderInputEl: root.querySelector(".fp-folder-input"), + statusEl: root.querySelector(".fp-status"), tableBodyEl: root.querySelector(".fp-table-body"), countEl: root.querySelector(".fp-count") }; diff --git a/dist/file-picker.iife.js b/dist/file-picker.iife.js index 4f3d73d..4dbcf5d 100644 --- a/dist/file-picker.iife.js +++ b/dist/file-picker.iife.js @@ -501,7 +501,7 @@ var FilePicker = (() => { // upload/frontend/table/rebase_tree.js function rebaseTree(root, prefix) { 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)); return root; } @@ -518,7 +518,7 @@ var FilePicker = (() => { return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); } function makeFile(data, name) { - return new File([data], name); + return new File([data], name.split("/").at(-1)); } function safeEntryParts(entryName) { if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null; @@ -547,6 +547,8 @@ var FilePicker = (() => { filter: (entry) => { 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"); + const lowerName = entry.name.toLowerCase(); + if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false; if (entry.originalSize > limits.maxEntryBytes) return false; 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"); @@ -596,9 +598,28 @@ var FilePicker = (() => { }; const accepted = accept(fileNode); 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); 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 function esc(value) { @@ -708,7 +729,11 @@ var FilePicker = (() => { const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits); if (zipTree) addFileWithDedup(state, zipTree); } 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; } } @@ -721,6 +746,7 @@ var FilePicker = (() => { try { await addFiles(state, cfg, elements.fileInputEl.files, elements); } finally { + elements.fileInputEl.value = ""; state.busy = false; } }; @@ -777,7 +803,11 @@ var FilePicker = (() => { addToFolder(zip); } } 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; } } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { @@ -910,6 +940,7 @@ var FilePicker = (() => { const elements = { fileInputEl: root.querySelector(".fp-file-input"), folderInputEl: root.querySelector(".fp-folder-input"), + statusEl: root.querySelector(".fp-status"), tableBodyEl: root.querySelector(".fp-table-body"), countEl: root.querySelector(".fp-count") }; diff --git a/docs/CODE-REFERENCE.md b/docs/CODE-REFERENCE.md index 1c65279..ba6ed21 100644 --- a/docs/CODE-REFERENCE.md +++ b/docs/CODE-REFERENCE.md @@ -1,7 +1,7 @@ # Справочник кода Upload Platform Документ описывает актуальную 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 внутри выбранной папки и для nested ZIP. @@ -107,26 +107,6 @@ callback задан. `finally` обязательно освобождает `bu `render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает дочерние строки только при `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` ### `list_zip_files.js` @@ -134,7 +114,7 @@ callback задан. `finally` обязательно освобождает `bu | Функция | Вход | Выход | Назначение | |---|---|---|---| | `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. | -| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File. Внутренняя. | +| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. | | `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. | | `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. | | `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. | @@ -168,7 +148,7 @@ vendor script не требуется. | `VERSION` | константа | Версия страницы | | `index()` / `/` | HTTP GET | HTML с version и config | | `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` | ### `site/templates/index.html` @@ -176,9 +156,5 @@ vendor script не требуется. Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для сброса browser cache и вызывает `FilePicker.initFilePicker`. -## Legacy - -`upload/frontend/table/set_status.js` не импортируется актуальной страницей и -сохранён как legacy-заготовка. Vendor `site/static/vendor/fflate.min.js` не -документируется построчно: это внешняя библиотека, используемая как готовый -runtime dependency. +Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это +внешняя библиотека, используемая как готовый runtime dependency. diff --git a/package.json b/package.json index fb5d7f2..51dce0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "upload-platform-file-picker", "private": true, - "version": "0.1.11", + "version": "0.1.12", "type": "module", "scripts": { "build": "node build.mjs" diff --git a/site/app.py b/site/app.py index 6b61893..4c7059c 100644 --- a/site/app.py +++ b/site/app.py @@ -6,11 +6,15 @@ from flask import Flask, render_template, send_from_directory ROOT = Path(__file__).resolve().parent.parent # Конфигурация демо содержит только разрешённые расширения для browser picker. -with (ROOT / "config.json").open(encoding="utf-8") as config_file: - CONFIG = json.load(config_file) +DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]} +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-модулей. app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/upload/README.md b/upload/README.md index 4441862..9178f1c 100644 --- a/upload/README.md +++ b/upload/README.md @@ -25,9 +25,9 @@ Picker поддерживает разрешённые документы, па ## Legacy -Каталоги `backend/` и старые ссылки на `uploadViaVM`/VM-буфер относятся к -предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only -интеграции они не нужны. +Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к +предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий +в `HISTORY/` являются историческими и не должны использоваться как инструкция. Подробная таблица функций, входов, выходов и побочных эффектов находится в [`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md). diff --git a/upload/frontend/index.js b/upload/frontend/index.js index cd210be..c3b3edc 100644 --- a/upload/frontend/index.js +++ b/upload/frontend/index.js @@ -120,6 +120,7 @@ export function initFilePicker(config) { const elements = { fileInputEl: root.querySelector('.fp-file-input'), folderInputEl: root.querySelector('.fp-folder-input'), + statusEl: root.querySelector('.fp-status'), tableBodyEl: root.querySelector('.fp-table-body'), countEl: root.querySelector('.fp-count'), }; diff --git a/upload/frontend/table/add_file_with_dedup.js b/upload/frontend/table/add_file_with_dedup.js index 6be1073..9ef48b8 100644 --- a/upload/frontend/table/add_file_with_dedup.js +++ b/upload/frontend/table/add_file_with_dedup.js @@ -27,6 +27,28 @@ export function addFileWithDedup(state, fileNode) { const accepted = accept(fileNode); // Пустой ZIP/каталог не должен появляться в таблице как пустая строка. 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); 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); + }); } \ No newline at end of file diff --git a/upload/frontend/table/init_upload_table.js b/upload/frontend/table/init_upload_table.js deleted file mode 100644 index d44560d..0000000 --- a/upload/frontend/table/init_upload_table.js +++ /dev/null @@ -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}} 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}} 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; -} \ No newline at end of file diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js index 609e9b4..6e9ee0b 100644 --- a/upload/frontend/table/on_files_change.js +++ b/upload/frontend/table/on_files_change.js @@ -34,7 +34,11 @@ export async function addFiles(state, cfg, files, elements) { if (zipTree) addFileWithDedup(state, zipTree); } catch (error) { // Битый или небезопасный 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; } } @@ -61,6 +65,7 @@ export function onFilesChange(state, cfg, elements) { await addFiles(state, cfg, elements.fileInputEl.files, elements); } finally { // Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным. + elements.fileInputEl.value = ''; state.busy = false; } }; diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js index a24cac2..0cd5890 100644 --- a/upload/frontend/table/on_folder_change.js +++ b/upload/frontend/table/on_folder_change.js @@ -60,7 +60,11 @@ export function onFolderChange(state, cfg, elements) { } } catch (error) { // Ошибка одного 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; } } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { diff --git a/upload/frontend/table/rebase_tree.js b/upload/frontend/table/rebase_tree.js index 9d45b74..af78db6 100644 --- a/upload/frontend/table/rebase_tree.js +++ b/upload/frontend/table/rebase_tree.js @@ -5,12 +5,12 @@ * @param {string} prefix Путь выбранной папки, в которую попал узел. * @returns {object} Тот же узел после изменения путей. * - * Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные - * browser File и путь, возвращаемый через getFiles(), оставались согласованными. + * Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь + * хранится отдельно в узле дерева. */ export function rebaseTree(root, prefix) { 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)); return root; } diff --git a/upload/frontend/table/set_status.js b/upload/frontend/table/set_status.js deleted file mode 100644 index 86cf730..0000000 --- a/upload/frontend/table/set_status.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js index f773de3..9e3ff01 100644 --- a/upload/frontend/zip/list_zip_files.js +++ b/upload/frontend/zip/list_zip_files.js @@ -15,9 +15,9 @@ function extensionAllowed(name, allowedExt) { return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase())); } -/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */ +/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */ 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) => { budget.entries += 1; 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 (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) { throw new Error('Превышен суммарный размер распакованных ZIP entries');