Author SHA1 Message Date
“Naeel” 0ea7ba058c docs: add LAYER2-RESUME.md and layer 2 architecture history record 2026-09-06 11:08:44 +03:00
“Naeel” 6586b5d893 docs: finalize current picker architecture 2026-09-05 20:51:45 +03:00
“Naeel” 017bd8c354 fix: harden browser file picker and remove legacy 2026-09-05 20:50:36 +03:00
“Naeel” f6b2f6d1b3 docs: sync readmes with picker API 2026-09-05 15:12:20 +03:00
“Naeel” a0bfd9f0e3 docs: record real file picker test 2026-09-05 15:11:15 +03:00
“Naeel” 3d227f1daa feat: make file picker embeddable 2026-09-05 14:42:50 +03:00
“Naeel” aec8ad4fbe Add architecture review prompts and responses (Sonnet, GPT SOL) 2026-09-05 14:26:12 +03:00
“Naeel” 3bd8ee2b23 Bump version to 0.1.10 2026-09-05 13:28:36 +03:00
“Naeel” e4b4dca53a Document picker code and architecture 2026-09-05 13:24:00 +03:00
“Naeel” 13ea0efcd7 Resolve Opus review code findings 2026-09-05 13:11:25 +03:00
“Naeel” edf1de5aa2 Split Opus review into separate history file 2026-09-05 13:07:38 +03:00
“Naeel” 5a0f82b75e Document Opus review findings 2026-09-05 13:05:31 +03:00
“Naeel” 23c1c86968 Mark obsolete implementation plan as legacy 2026-09-05 12:59:56 +03:00
“Naeel” 8df4d22cfb Update picker documentation 2026-09-05 12:58:47 +03:00
“Naeel” 5d44c836e7 Mark legacy history entries 2026-09-05 12:55:42 +03:00
“Naeel” 1e305485be Fix async picker race 2026-09-05 12:31:17 +03:00
“Naeel” eda5263d8b Reject unsafe ZIP paths 2026-09-05 12:20:26 +03:00
“Naeel” 317526b246 Document strict file picker tests 2026-09-05 10:08:11 +03:00
“Naeel” 8f775e34a9 Fix review findings in file picker 2026-09-05 10:00:14 +03:00
“Naeel” 6fea5bce49 Fix file picker extension filtering 2026-09-05 09:55:44 +03:00
“Naeel” a965f181d9 Add hierarchical file picker 2026-09-05 09:29:03 +03:00
“Naeel” 85605fe8c6 Remove VM upload layer; keep file picker only 2026-09-05 09:23:13 +03:00
“Naeel” c0b87880ae Implement reusable upload platform 2026-09-05 08:51:32 +03:00
41 changed files with 4886 additions and 59 deletions
+6
View File
@@ -7,6 +7,9 @@ __pycache__/
build/
develop-eggs/
dist/
!dist/
!dist/file-picker.esm.js
!dist/file-picker.iife.js
downloads/
eggs/
.eggs/
@@ -46,6 +49,9 @@ htmlcov/
.DS_Store
Thumbs.db
# Local frontend tooling
node_modules/
# Logs / temp
*.log
/tmp/
+23
View File
@@ -0,0 +1,23 @@
# Документация кода picker-only
## Что сделано
- Добавлены подробные комментарии и JSDoc в собственные JS-модули picker-а.
- Добавлены пояснения к Flask entrypoint, HTML-шаблону, CSS и пакетному
`upload/__init__.py`.
- Добавлен `docs/CODE-REFERENCE.md` со схемой архитектуры, моделью узлов,
состоянием, публичным API, функциями модулей и описанием legacy-кода.
- Ссылки на справочник добавлены в корневой и модульный README.
- Legacy `set_status.js` документирован отдельно; его поведение не менялось.
## Границы
- Поведение picker-а намеренно не изменялось.
- `site/static/vendor/fflate.min.js` не редактировался.
- Старые backend/VM-файлы и `.pyc` не удалялись.
## Проверка
- `node --input-type=module --check` для собственных JS-файлов: PASS.
- `python3 -m py_compile site/app.py upload/__init__.py`: PASS.
- `git diff --check`: PASS.
+205
View File
@@ -0,0 +1,205 @@
# 2026-09-05 — browser file-picker и иерархическое дерево
> **Статус документа:** исторический журнал изменений. Устаревшие промежуточные
> состояния помечены `LEGACY`; исправленные дефекты помечены `RESOLVED`.
## Текущее состояние
- **Актуальная версия:** `0.1.9`.
- **Актуальная архитектура:** browser-only file-picker без VM upload, backend
sessions и upload API.
- **Актуальный commit:** `1e30548 Fix async picker race`, отправлен в
`origin/master`.
## Исходная задача
- Требовался браузерный выбор документов в `upload-platform`.
- После уточнения требований сервис должен работать только как file-picker.
- Загрузка файлов на VM, pull-фаза, backend sessions и upload API не требовались.
- Интерфейс должен поддерживать выбор отдельных файлов, папок и ZIP.
- Выбранные данные должны отображаться в виде сворачиваемого иерархического дерева.
- Должна быть возможность удалить отдельный файл или целую группу.
- Последующий сервисный слой должен получать leaf-файлы и их полные пути.
- Разрешены только расширения `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
- Ограничения по размеру и количеству файлов не добавлялись.
- Должны поддерживаться вложенные структуры: `folder -> ZIP -> subfolder -> ZIP -> file`.
## Реализовано на этапе версии 0.1.7 — LEGACY
> **LEGACY:** раздел фиксирует состояние на момент версии `0.1.7`, а не текущий
> состав кода. Для актуального состояния см. раздел «Текущее состояние» и
> последующие исправления ниже.
- Удалён VM upload layer: PUT на внешний VM endpoint, pull, backend sessions и upload API.
- Удалены старые backend/API части upload-platform по явному запросу:
- `upload/backend/`;
- `site/routes/`;
- `upload/frontend/upload/`.
- Flask-приложение оставлено только для отдачи страницы и frontend-модулей:
- `/`;
- `/health`;
- `/upload-frontend/<path:filename>`.
- Реализован интерфейс выбора файлов, выбора папки и очистки.
- Добавлена конфигурация разрешённых расширений.
- Реализована рекурсивная модель дерева с типами `file`, `folder`, `zip`.
- Для ZIP реализованы чтение через локальный `fflate`, сохранение полных путей, раскрытие вложенных ZIP и фильтрация расширений.
- Для выбранной папки реализованы чтение `webkitRelativePath`, построение вложенных каталогов, раскрытие ZIP внутри папки и удаление пустых групп.
- Добавлена дедупликация по `path + size`.
- Реализовано рекурсивное удаление дублей и пустых групп.
- В таблице реализованы рендер дерева, сворачивание/разворачивание групп, удаление leaf-файлов и удаление групп вместе со всем поддеревом.
- Счётчики количества файлов и общего размера пересчитываются после изменений.
- Состояние таблицы хранит исходные browser `File` objects и метаданные для последующего сервисного слоя.
- Версия приложения повышена до `0.1.7`.
## Исправления по ходу работы
- Первоначальная реализация отправляла файлы на VM; она заменена на picker-only архитектуру.
- Убраны устаревшие фрагменты VM upload и API.
- Исправлены дублированные фрагменты кода, обнаруженные синтаксической проверкой после первоначального патча.
- Исправлено построение полных путей для файлов в ZIP и вложенных ZIP.
- Исправлено построение путей корневых папок и обычных файлов при выборе директории.
- Исправлено удаление пустых групп после дедупликации.
- Исправлен показ запрещённых файлов при обработке ZIP: ZIP без разрешённых leaf-файлов больше не отображается как группа.
- Исправлен fallback для ошибочно раскрывающихся ZIP: нераскрытый ZIP не показывается пользователю как содержимое.
- Добавлен HTML-фильтр `accept` для системного диалога выбора: показываются только разрешённые документы и `.zip`, а не `Все файлы (*.*)`.
## Проверки
- Синтаксис JavaScript проверен через Node.js для frontend-модулей.
- Синтетический тест вложенного ZIP подтвердил пути `archive.zip/root.pdf` и `archive.zip/docs/inner.zip/deep.txt`, а `.png` был исключён.
- Повторная загрузка одинакового дерева не добавляет дубликаты.
- Удаление корневого дерева удаляет все leaf-файлы.
- Flask smoke test подтвердил страницу, `/health` и frontend-маршруты.
- `git diff --check` прошёл.
- Реальный браузерный тест подтвердил:
- отображение обычного `plain.md`;
- ZIP с вложенным ZIP;
- фильтрацию `.png` и `.jpg`;
- три сохранённых leaf-файла и общий размер `16 B`;
- сворачивание `archive.zip` через `aria-expanded=false`;
- удаление группы `archive.zip` вместе со всем поддеревом;
- удаление leaf-файла до состояния `0 файлов · 0 B`;
- выбор папки и фильтрацию запрещённых `main.py` и `image.png`.
- Несколько автоматических кликов через browser tool не сработали из-за нестабильности Playwright locator; точные accessibility-селекторы и DOM-события подтвердили те же операции без дефектов приложения.
- Дополнительный браузерный тест подтвердил, что ZIP только с `image.jpg` оставляет таблицу пустой: `0 файлов · 0 B`.
- Проверено наличие `accept=".pdf,.doc,.docx,.txt,.md,.zip"` у обоих file input.
## Код-ревью и исправления — 2026-09-05
Проведено ревью frontend-модулей file-picker после версии `0.1.6`.
### Найдено
- **Критично:** удаление файла или группы не удаляло ключ из `state.fileMeta`. После удаления повторный выбор того же файла с тем же путём и размером ошибочно считался дубликатом.
- **Средний приоритет:** для leaf-файлов `padding-left` задавался одновременно на `<td>` и `<span>`, поэтому отступ листьев удваивался.
- **Средний приоритет:** `set_status.js` обращался к удалённому `state.files` и падал с `TypeError` при вызове. Кроме того, старый поиск по имени был бы неоднозначен для одинаковых имён в разных каталогах.
- **Низкий приоритет:** `addFiles` и `onFilesChange` импортировались двумя отдельными строками из одного модуля.
### Исправлено
- В `init_upload_table.js` добавлено рекурсивное удаление ключей из `state.fileMeta` перед удалением leaf или группы. Исправлены и UI-обработчик, и публичный `api.remove`.
- В `render.js` убран второй источник отступа для leaf-файлов.
- В `render.js` для файлов добавлен `data-path` с экранированным полным путём.
- В `set_status.js` переход выполнен на актуальный `state.nodes` через `flattenFiles`; строка статуса ищется по полному `data-path`.
- Объединён дублирующий импорт из `on_files_change.js`.
- `state.fileMeta` заменён на `Set state.fileKeys`, поскольку значения идентификаторов узлов не использовались.
- Логика удаления узла объединена в одну функцию и используется обработчиком таблицы и публичным `api.remove`.
- Из `findNode` удалён неиспользуемый параметр `parent`.
### Проверено после ревью
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- `git diff --check` проходит.
- Браузерная проверка удаления leaf-файла показала `0 файлов · 0 B`; после удаления ключ дедупликации очищается.
- Повторный выбор того же файла после удаления снова разрешён.
- Изменения не затрагивают фильтрацию расширений, ZIP-рекурсию и публичный API picker.
## Жёсткие тесты по плану — 2026-09-05 — HISTORICAL SNAPSHOT
> **HISTORICAL SNAPSHOT:** результаты ниже относятся к тестовому проходу до
> исправления traversal. Актуальная повторная проверка приведена в разделе
> «Повторная проверка» и в последующих записях.
### PASS
- Расширения без учёта регистра: `UPPER.PDF`, `Doc.DOCX`, `note.TxT` и `file.name.md` приняты.
- Некорректные расширения и имена без разрешённого суффикса: `archive.tar.gz`, `README` и `file.` отклонены.
- Пустой ZIP не создаёт группу и оставляет состояние `0 файлов · 0 B`.
- ZIP только с запрещёнными `.jpg` и `.py` не создаёт группу.
- ZIP с разрешёнными файлами и вложенным ZIP оставляет только разрешённые leaf-файлы.
- ZIP с вложенностью глубже 20 отклоняется без зависания страницы.
- Дедупликация: одинаковый путь и размер схлопываются; другой путь сохраняется.
- XSS-имена с `<script>`, `<img>`, кавычками и `&` экранируются: в DOM нет `script` и `img` элементов, исходный текст отображается безопасно.
- Выбор папки с одинаковыми именами в разных каталогах сохраняет оба файла.
- Рабочее дерево Git после тестов осталось чистым.
### FAIL — RESOLVED / LEGACY RESULT
> **RESOLVED:** перечисленные FAIL описывают состояние до исправления traversal
> и не являются текущими дефектами. Раздел сохранён как исторический результат
> тестирования.
- ZIP entry `folder/../../escape.txt` попадает в дерево с тем же traversal-путём.
- ZIP entry `/absolute.md` попадает в дерево с двойным слэшем `archive.zip//absolute.md`.
- Пути с `..` создают видимые группы `..`.
- Путь `<пустая часть>/absolute.md` создаёт группу с пустым отображаемым именем.
### Вывод — LEGACY
> **LEGACY:** это вывод на момент обнаружения дефекта; после исправления
> traversal он больше не описывает текущее состояние.
Фильтрация расширений, XSS-экранирование, дедупликация и обработка пустых/глубоких ZIP работают. Обнаружен отдельный дефект нормализации ZIP-путей: перед построением дерева нужно отклонять абсолютные пути, `..`, пустые сегменты и backslash traversal. В текущем тестовом проходе код не менялся; FAIL оставлен документированным для следующего исправления.
## Git — HISTORICAL REFERENCES
> **HISTORICAL REFERENCES:** ссылки ниже перечисляют коммиты этапов разработки,
> а не являются текущим состоянием ветки. Текущий commit указан в начале файла.
## Исправление traversal — 2026-09-05
### Причина
`list_zip_files.js` использовал сырой `entryName` из ZIP: он напрямую попадал в `path` и разбивался через `split('/')`. Поэтому `..`, ведущий `/`, пустые сегменты и Windows backslash создавали видимые небезопасные группы и пути.
### Исправление
- Добавлена проверка сегментов ZIP entry до построения дерева.
- Отклоняются пустые entry, абсолютные пути, backslash, `.`, `..` и пустые сегменты.
- Пути и имена leaf-файлов строятся только из проверенных сегментов.
- Вложенный ZIP с опасным путём пропускается без прерывания обработки внешнего ZIP.
- Версия на момент исправления повышена до `0.1.8`; **LEGACY:** текущая версия
уже `0.1.9` после исправления async race.
### Повторная проверка
ZIP с `folder/../../escape.txt`, `/absolute.md`, `windows\\..\\bad.txt`, `folder/./dot.txt` и безопасным `safe/normal.txt` оставил только `paths.zip/safe/normal.txt`. Группы `..`, пустая группа и абсолютный путь больше не отображаются. Результат: `1 файлов · 2 B`.
## Исправление async race — 2026-09-05 — АКТУАЛЬНО
### Причина
`onFilesChange` запускал асинхронный `addFiles` без ожидания и без установки `state.busy` до первого `await`. При быстрых повторных событиях выбора ZIP несколько обработчиков могли одновременно менять дерево и вызывать `render`.
### Исправление
- `onFilesChange` теперь сразу устанавливает `state.busy`, ожидает `addFiles` и сбрасывает флаг в `finally`.
- `onFolderChange` использует тот же порядок: ранний выход при занятом состоянии, блокировка до асинхронного разбора ZIP и гарантированный сброс в `finally`.
- Ошибка разбора ZIP больше не оставляет picker заблокированным.
### Проверки
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- `git diff --check` проходит.
- Flask smoke test загрузил страницу и оба изменённых ES-модуля без HTTP-ошибок.
- Узкий Node-тест не применён: в проекте отсутствует `package.json` с `type: module`, поэтому Node трактует browser ES-модули как CommonJS; синтаксис проверен отдельным module-check, а загрузка проверена браузером.
- `c0b8788 Implement reusable upload platform` — первоначальный reusable upload platform.
- `85605fe Remove VM upload layer; keep file picker only` — удаление VM upload и переход к picker-only.
- `a965f18 Add hierarchical file picker` — иерархическое дерево, вложенные ZIP, удаление групп и файлов.
- До добавления этой записи `master` был синхронизирован с `origin/master`.
- Текущая запись добавлялась отдельными коммитами и отправлялась в
`origin/master`; **LEGACY:** эта формулировка описывает историю публикации,
а не текущую операцию деплоя.
@@ -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-дефектами. Исправления кода в рамках этой записи не
выполнялись.
+44
View File
@@ -0,0 +1,44 @@
# Ревью Opus 4.8 — 2026-09-05
## Контекст
Ревью ограничивалось проектом `upload-platform`. Проверены структура проекта,
frontend-модули, Flask-обёртка, конфигурация и HISTORY. Исходники backend
VM-upload отсутствуют; в рабочем дереве обнаруживались только артефакты
`__pycache__` от удалённых модулей.
## Замечания и текущий статус
1. **Документация описывала отменённую VM-архитектуру.**
**RESOLVED:** `README.md`, `upload/README.md` и `PLAN.md` приведены к
picker-only архитектуре. Старые VM/backend разделы в `PLAN.md` и README
помечены как `LEGACY`.
2. **Документация содержала неиспользуемые параметры конфигурации и лимиты.**
**RESOLVED:** актуальная документация указывает только `allowedExt` и
прямо фиксирует отсутствие ограничений размера и количества файлов.
3. **В рабочем дереве присутствовали `.pyc` в `__pycache__`.**
**OPEN:** это локальные игнорируемые артефакты, не попадающие в Git. Удаление
не выполнялось, поскольку удаление файлов требует отдельного решения.
4. **`set_status.js` и `api.setBusy` выглядят неиспользуемыми.**
**PARTIALLY RESOLVED:** публичный `api.setBusy` удалён как неиспользуемый.
`set_status.js` сохранён как legacy-файл до отдельного решения об удалении.
5. **`rebaseTree` дублируется в ZIP- и folder-обработчиках.**
**RESOLVED:** функция вынесена в общий модуль
`upload/frontend/table/rebase_tree.js`.
## Вывод
Критичных дефектов в picker-only коде ревью не выявило. Основное замечание по
рассинхронизации документации устранено. После дополнительной проверки и
рефакторинга остаются две технические задачи: решение по локальным
`__pycache__` и отдельное решение по удалению либо сохранению legacy-файла
`set_status.js`.
### Проверка после исправлений
- Все frontend `.js` файлы проходят `node --input-type=module --check`.
- `site/app.py` проходит `python3 -m py_compile`.
- ZIP-функциональность в браузере сохранена: разрешённые leaf-файлы приняты,
`.exe` отфильтрован.
- Общий `rebaseTree` загружается, дублированных реализаций нет.
- `api.setBusy` отсутствует в публичном API.
+20
View File
@@ -0,0 +1,20 @@
# 2026-09-05 — синхронизация README с универсальным picker API
## Что исправлено
- Корневой `README.md` теперь описывает актуальные `package.json`, `build.mjs`
и `dist/`.
- Добавлены команды `npm install` и `npm run build` в локальный запуск.
- Инструкция интеграции переведена на готовые ESM/IIFE bundle.
- Зафиксировано, что `fflate` уже встроен в bundle и отдельный vendor script
для интеграции не требуется.
- В `upload/README.md` описаны `onChange(files)` и `onError(error, file)`.
- `docs/CODE-REFERENCE.md` синхронизирован с bundle-архитектурой и актуальными
сигнатурами `render`.
## Проверка
- Все целевые файлы и ссылки на `docs/CODE-REFERENCE.md`, `HISTORY/` и оба
bundle существуют.
- `git diff --check` прошёл.
- Исходный код picker-а не изменялся.
+65
View File
@@ -0,0 +1,65 @@
# 2026-09-05 — тестирование реальных файлов из `/mnt/y/T`
## Контекст
Универсальный browser file-picker версии `0.1.11` проверен на реальных
документах из каталога `/mnt/y/T`, а не только на синтетических `File` objects.
Для доступа браузера к Linux-файлам использовался временный HTTP-сервер с
CORS на порту `8766`. В репозитории и в каталоге тестовых файлов изменения не
вносились.
## Набор файлов
В `/mnt/y/T` на уровне проверки обнаружены:
- `186` PDF;
- `81` DOCX;
- `42` DOC;
- архивов `zip`, `7z` и `rar` не обнаружено.
Для массового browser-теста использован каталог `/mnt/y/T/many`:
- найдено файлов разрешённых типов: `300`;
- загружено в browser `File`: `300`;
- принято picker-ом: `300`;
- уникальных имён: `300`;
- суммарный размер: `6 453 081 B`.
## Отдельные реальные файлы
Проверены документы с пробелами, кириллицей и скобками в именах:
| Файл | Размер |
|---|---:|
| `Assumption of Responsibility for Hosting.pdf` | `19 755 B` |
| `1 РИМО.docx` | `18 301 B` |
| `GU2_ot_23.01.2047 (1).doc` | `29 184 B` |
| `Evaporator Drain Access.pdf` | `29 767 B` |
Суммарный размер этих четырёх файлов: `97 007 B`.
## Результаты
- Все четыре отдельных файла отображаются со статусом `готов`.
- Имена с кириллицей, пробелами, `$` и скобками не искажаются.
- Размеры browser `File` совпали с размерами файлов на диске.
- PDF, DOC и DOCX принимаются при конфигурации `allowedExt: ['.pdf', '.doc', '.docx']`.
- Массовый набор из `300` документов принят без потерь и без дубликатов.
- Счётчик picker-а показывает `300 файлов · 6453081 B`.
- Файлы не отправляются в upload backend: обработка остаётся browser-only.
## Ограничения теста
- В каталоге `/mnt/y/T` не было реальных архивов, поэтому ZIP проверялся
отдельными синтетическими архивами в предыдущем browser-тесте.
- Прямой системный chooser remote browser не смог принять Linux-пути
`/mnt/y/T`; поэтому реальные байты были получены браузером через временный
CORS-сервер и переданы в тот же публичный API `picker.addFiles()`.
- Временные Flask и HTTP-серверы завершались по заданному `timeout`; это
нормальное завершение тестового окружения, а не ошибка picker-а.
## Итог
Тест реальных документов подтверждает корректную работу фильтра расширений,
чтение browser `File`, сохранение имён и размеров, массовую загрузку и
отображение документов на русском, английском и смешанном наборе имён.
+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-архитектуру.
- Выполнено: `build.mjs` очищает `dist/` перед сборкой; версия повышена до `0.1.13`.
## Отложенные задачи
- Перевести тяжёлую синхронную ZIP-распаковку в Web Worker после подтверждения
реальными измерениями необходимости.
## Проверка
- Пересобрать ESM/IIFE bundles.
- Проверить синтаксис изменённых JavaScript и Python-файлов.
- Обновить версию с `0.1.11` до `0.1.12` в `package.json` и `site/app.py`.
- Выполнить browser smoke-тесты для повторного выбора, ZIP-фильтрации,
слияния папок и fallback ошибки.
@@ -0,0 +1,87 @@
# 2026-09-06: Архитектурные решения Слоя 2 и границы независимых API слоёв
## 1. Контекст и цели
Зафиксированы ключевые проектные и архитектурные решения по разработке Слоя 2 в репозитории `upload-platform` перед началом реализации:
1. Полная автономность и независимость каждого слоя как отдельного API.
2. Легкость интеграции обоих слоёв (Слой 1 + Слой 2) в целевые сервисы (`drhider`, `contractor`) без переделки исходного кода ядра.
3. Составлено подробное ТЗ и резюме в файле `LAYER2-RESUME.md`.
---
## 2. Архитектурные границы слоёв и API-контракты
### Слой 1: File Picker (v0.1.13 — готов и протестирован)
- **Зона ответственности**: выбор отдельных файлов, выбор папок (`webkitdirectory`), клиентская рекурсивная распаковка ZIP-архивов с помощью `fflate`, дедупликация, фильтрация по расширениям, проверка лимитов, отрисовка древовидного UI.
- **API-контракт**: метод `picker.getFiles()` возвращает плоский массив стандартных объектов `File`. Слой полностью изолирован от транспорта и бэкенда.
### Слой 2: Транзитная доставка через ВМ-буфер в RAM бэкенда (текущая задача)
- **Причина существования**: Ingress-контроллер k8s-кластера (`pythonk8s`) обрывает входящие запросы с телом более 64 КБ. Прямой `POST` больших файлов в кластер невозможен; исходящий трафик (egress) из кластера не ограничен.
- **Принципиальные решения**:
1. **Пофайловый транзит (Per-File Transit)** вместо накопления всей пачки в буфере:
- Файл $k$ отправляется браузером через `PUT` в буфер на ВМ.
- Браузер сразу отправляет `POST /api/upload_refs` бэкенду во Flask для одного файла $k$.
- Бэкенд забирает файл исходящим потоковым `GET` прямо в RAM сессии (`upload/backend/session/state.py`).
- Бэкенд немедленно отправляет `DELETE` на ВМ.
- Файл на ВМ удалён, буфер чист. Браузер переходит к файлу $k+1$.
- В любой момент времени в буфере на ВМ находится максимум один файл.
2. **RAM-only (хранение строго в памяти)**:
- Никакой записи на диск ни на бэкенде, ни на ВМ в постоянном режиме.
3. **Zero Hardcode**:
- Полная параметризация через конфигурацию (`vmUploadUrl` на фронтенде, `vmUploadPrefix` на бэкенде для обязательной SSRF-валидации).
4. **Автономный mock-буфер**:
- In-memory mock WebDAV (`PUT`, `GET`, `DELETE`) внутри тестового стенда `site/app.py` для автономного прогона pytest и Playwright без внешней сети.
- **API-контракт фронтенда**: функция `uploadFilesViaVm(files, options)` с колбэками прогресса по файлу/пачке, завершения файла и поддержкой отмены (`AbortController`).
- **API-контракт бэкенда**: Blueprint `upload_refs` и менеджер сессий `session` (создание сессии, потоковое вытягивание, хранение в RAM, TTL, мягкая отмена).
### Слой 3: Бизнес-логика потребителей (не входит в текущую задачу)
- Сервисы `drhider` (обфускация, LLM) и `contractor` (парсинг, diff договоров) забирают файлы из оперативной памяти сессии через `session.get_files(session_id)`.
- В `upload-platform` Слой 3 эмулируется тестовым callback/эндпоинтом, фиксирующим успешный приём файла из сессии для дальнейшей обработки.
---
## 3. Файловая структура Слоя 2 в `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Слой 1 (initFilePicker)
│ │ ├── table/ # UI Слоя 1
│ │ ├── zip/ # fflate
│ │ └── upload/ # Слой 2 (Фронтенд):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # Слой 2 (Бэкенд):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен файлов
│ │ └── config.py # Конфигурация по умолчанию
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий
│ ├── create_session.py
│ ├── add_file.py
│ ├── get_files.py
│ ├── store_result.py
│ ├── store_csv.py
│ ├── ttl.py
│ ├── cancel.py
│ └── cleanup.py
├── site/
│ ├── app.py # Flask demo + local RAM mock buffer
│ └── templates/index.html
├── tests/ # pytest + browser smoke
├── LAYER2-RESUME.md # Техническое задание и сводка
├── build.mjs
├── package.json
└── requirements.txt
```
---
## 4. Зафиксированные факты инфраструктуры
1. **ВМ `5.172.178.213`**: Nginx WebDAV настроен на сброс на диск (`/var/www/drhider-upload/`, `/var/www/contracts-upload/`) с cron-очисткой каждые 5 мин (`find ... -mmin +30 -delete`). Для целевого прода требуется перевод в RAM/in-memory буфер.
2. **Кластер k8s**: неймспейсы `20a75175-a58c-49cb-b8fa-e86367b1a8dc` (`drhider`), `b4523aba-b5e6-40f1-be56-bb4d2509357c` (`contractor`), `0e108526-5bb2-4757-9499-3f0bac4f0e83` (`uploader-dev`).
+145
View File
@@ -0,0 +1,145 @@
# РЕЗЮМЕ И ТЕХНИЧЕСКОЕ ЗАДАНИЕ: СЛОЙ 2 (ЗАГРУЗКА ЧЕРЕЗ ВМ-БУФЕР)
Документ составлен 2026-09-06 для старта нового рабочего контекста в репозитории [upload-platform](upload-platform).
---
## 1. ЦЕЛЬ И АРХИТЕКТУРНЫЕ ГРАНИЦЫ
В репозитории [upload-platform](upload-platform) разрабатывается и изолированно тестируется переиспользуемый встраиваемый модуль, состоящий из двух независимых слоёв:
1. **Слой 1 (Выбор файлов / File Picker)** — **ГОТОВ И ПРОТЕСТИРОВАН (v0.1.13)**:
- Фронтенд-модуль на Vanilla JS + `fflate`.
- Поддерживает выбор отдельных файлов, выбор папок (`webkitdirectory`), распаковку ZIP и вложенных архивов.
- Дедупликация, фильтрация по расширениям, древовидный UI, лимиты (`maxEntries`, `maxEntryBytes`, `maxTotalBytes`, `maxDepth`).
- Возвращает чистый плоский массив выбранных объектов `File` через метод `picker.getFiles()`. Не зависит от способа передачи данных.
2. **Слой 2 (Транзитная загрузка файлов через ВМ-буфер)** — **ТЕКУЩАЯ ЗАДАЧА**:
- Задача слоя: доставить файлы из браузера в память сессии бэкенд-приложения (Flask).
- **Почему нужен буфер**: шлюз ingress managed-кластера Kubernetes (`pythonk8s`) обрывает входящие HTTP-запросы с телом более 64 КБ. Прямой `POST /upload` больших файлов в кластер невозможен. При этом исходящий трафик (egress) из кластера не ограничен.
- **Решение**: Браузер загружает файлы во внешний буфер на ВМ, а бэкенд вытягивает их исходящими `GET`-запросами (pull) в оперативную память своей сессии.
- **Потребители модуля**: сервис анонимизации `drhider` и сервис сверки договоров (`contractor`). Оба сервиса встраивают Слой 1 + Слой 2 как подпапку `upload/` без изменения исходного кода модулей (только собственный конфиг) и поверх накладывают свою бизнес-логику (Слой 3: LLM, обфускация, парсинг, diff).
---
## 2. ФАКТИЧЕСКОЕ СОСТОЯНИЕ ИНФРАСТРУКТУРЫ (ПРОВЕРЕНО НА ВМ И В K8S)
Проверено 2026-09-06 прямыми командами на ВМ и в кластере:
1. **ВМ `5.172.178.213` (contracts.kube5s.ru)**:
- Доступ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`.
- В `nginx` настроены WebDAV-буферы:
- `/drhider-upload/` $\rightarrow$ `alias /var/www/drhider-upload/;`, CORS: `https://drhider.pythonk8s.dev.nubes.ru`.
- `/contracts-upload/` $\rightarrow$ `alias /var/www/contracts-upload/;`, CORS: `https://contractor.pythonk8s.dev.nubes.ru`.
- **Проблема текущей реализации на ВМ**:
- Nginx WebDAV пишет файлы на диск (корневой раздел).
- Cron каждые 5 минут удаляет файлы старше 30 минут: `find /var/www/drhider-upload -type f -mmin +30 -delete`.
- По требованию безопасности файлы не должны оседать на диске — обработка и буферизация должны быть строго в оперативной памяти (RAM).
2. **Kubernetes-кластер (`iot-naeel`)**:
- Под `drhider`: namespace `20a75175-a58c-49cb-b8fa-e86367b1a8dc`, хост `drhider.pythonk8s.dev.nubes.ru`.
- Под `contractor`: namespace `b4523aba-b5e6-40f1-be56-bb4d2509357c`, хост `contractor.pythonk8s.dev.nubes.ru`.
- Под `uploader-dev` (полигон): namespace `0e108526-5bb2-4757-9499-3f0bac4f0e83`, хост `uploader-dev.pythonk8s.dev.nubes.ru` (на нём крутится `upload-platform` 0.1.13).
---
## 3. ПРИНЦИПИАЛЬНЫЕ АРХИТЕКТУРНЫЕ ИЗМЕНЕНИЯ СЛОЯ 2
### Изменение 1: Пофайловый транзит (Streaming / Per-File Transit) вместо батча
- **Как было раньше**: Браузер в цикле заливал все файлы пачки в буфер на ВМ, и только в самом конце отправлял во Flask один общий запрос `/api/upload_refs` со списком всех ссылок. Буфер был вынужден одновременно хранить все файлы пачки (до сотен мегабайт).
- **Как должно быть**:
1. Браузер берет **один файл** $\rightarrow$ делает `PUT` в буфер на ВМ.
2. Браузер сразу делает `POST /api/upload_refs` для **этого одного файла**.
3. Flask вытягивает его исходящим потоковым `GET` прямо в память сессии (`site/session.py`) и сразу отправляет `DELETE` на ВМ.
4. Файл на ВМ удален. Браузер переходит к следующему файлу.
- **Результат**: В буфере на ВМ в любой момент времени находится максимум один файл. Общий объем памяти буфера минимален.
### Изменение 2: Хранение строго в памяти (RAM)
- Никаких временных файлов на диске.
- На бэкенде Flask файлы хранятся в RAM-структуре сессии (`upload/backend/session/state.py`).
- На ВМ для буфера не должен использоваться сброс на диск (на стороне Nginx требуется проксирование в память или легковесный in-memory микросервис).
### Изменение 3: Полная параметризация (Zero Hardcode)
- Прямой IP из браузера поверх HTTPS невозможен (Mixed Content, отсутствие доверенного TLS-сертификата).
- В коде не должно быть зашитых хостов (`contracts.kube5s.ru` и т.д.).
- Все адреса передаются через конфигурацию:
- Фронтенд: `vmUploadUrl` (например, `https://contracts.kube5s.ru/drhider-upload/` или локальный URL).
- Бэкенд: `vmUploadPrefix` (для обязательной SSRF-валидации входящих URL перед pull).
### Изменение 4: Автономный mock-буфер для тестов в `upload-platform`
- Тесты в [upload-platform](upload-platform) не должны зависеть от внешней сети или чужих боевых ВМ.
- В тестовый стенд и dev-сервер [site/app.py](upload-platform/site/app.py) встраивается локальный легковесный in-memory mock WebDAV (обработка `PUT`, `GET`, `DELETE`), что позволяет гонять Playwright- и Python-тесты полностью оффлайн.
---
## 4. СТРУКТУРА МОДУЛЯ В `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Точка входа Слоя 1 (initFilePicker)
│ │ ├── table/ # Логика и UI Слоя 1
│ │ ├── zip/ # Распаковка архивов (fflate)
│ │ └── upload/ # СЛОЙ 2 (ФРОНТЕНД):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # СЛОЙ 2 (БЭКЕНД):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен (защита от path traversal)
│ │ └── config.py # Дефолтные параметры (retries, delay)
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий (_sessions, lock, TTL)
│ ├── create_session.py # Создание сессии (UUID)
│ ├── add_file.py # Добавление файла с проверкой лимита памяти
│ ├── get_files.py # Получение списка файлов
│ ├── store_result.py # Сохранение ZIP-результата
│ ├── store_csv.py # Сохранение CSV-результата
│ ├── ttl.py # touch, pause_ttl, resume_ttl
│ ├── cancel.py # Мягкая отмена сессии (Event)
│ └── cleanup.py # Очистка сессии
├── site/
│ ├── app.py # Flask demo (Слой 1 + Слой 2 + local mock buffer)
│ └── templates/index.html # Demo UI со сквозным сценарием выбора и загрузки
├── tests/ # Тесты Слоя 2 (pytest + browser smoke)
├── build.mjs # Сборка бандлов (ESM / IIFE)
├── package.json # Версионирование платформы
└── requirements.txt # Зависимости Python (Flask, httpx, pytest)
```
---
## 5. ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ В НОВОМ ЧАТЕ
1. **Шаг 1. Перенос и адаптация бэкенда Слоя 2**:
- Перенести `backend/session/` и `backend/upload_refs/` из [upload/backend](upload/backend) в [upload-platform/upload/backend](upload-platform/upload/backend).
- В `upload-platform/requirements.txt` зафиксировать `httpx>=0.27.0`.
- Проверить чистоту импортов и типизацию.
2. **Шаг 2. Перенос и адаптация фронтенда Слоя 2 на пофайловый транзит**:
- Создать [upload-platform/upload/frontend/upload/put_to_vm.js](upload-platform/upload/frontend/upload/put_to_vm.js) (XHR PUT с прогрессом и возможностью `abort`).
- Создать [upload-platform/upload/frontend/upload/upload_via_vm.js](upload-platform/upload/frontend/upload/upload_via_vm.js) с реализацией **пофайловой передачи**:
- цикл по файлам;
- загрузка файла $k$ на буфер;
- немедленный вызов `/api/upload_refs` для файла $k$;
- обновление прогресса в UI;
- переход к файлу $k+1$.
3. **Шаг 3. Локальный mock-буфер и интеграция в `site/app.py`**:
- Встроить в [upload-platform/site/app.py](upload-platform/site/app.py) тестовый Blueprint mock-буфера (`PUT`, `GET`, `DELETE` в оперативной памяти).
- Подключить `create_upload_refs_blueprint` к приложению.
- Обновить [upload-platform/site/templates/index.html](upload-platform/site/templates/index.html), добавив кнопку «Загрузить» и отображение прогресса пофайловой передачи.
4. **Шаг 4. Написание автоматических тестов**:
- Python unit-тесты (`pytest`): `safe_name`, `session` (TTL, лимиты, add/get), `upload_refs` (SSRF-блокировка, pull с mock-буфера, удаление после pull).
- Playwright browser-тесты: сквозной сценарий от выбора файлов в `initFilePicker` до их успешного появления в сессии Flask.
5. **Шаг 5. Сборка, bump версии и документация**:
- Обновить `build.mjs` и `package.json` (bump версии).
- Написать подробный [upload-platform/README.md](upload-platform/README.md) с инструкцией для агентов, как встроить оба слоя в сторонний сервис за 2 минуты.
- Фиксация коммитом и пуш.
+13 -11
View File
@@ -1,5 +1,18 @@
# Upload Platform — подробный план реализации
> **LEGACY — документ устарел и не является планом текущей реализации.**
> Этот файл описывает отменённую архитектуру с VM upload, backend sessions,
> Flask API, лимитами загрузки и слоем `/api/process`. Текущий проект работает
> как browser-only file-picker: выбор файлов, папок и ZIP выполняется в браузере,
> а backend не принимает и не хранит выбранные файлы.
>
> Актуальное описание: [README.md](README.md) и
> [upload/README.md](upload/README.md). История перехода на picker-only и
> исправлений находится в [HISTORY/2026-09-05-file-picker.md](HISTORY/2026-09-05-file-picker.md).
>
> Весь текст ниже сохранён только как исторический план и не должен использоваться
> для новых изменений без отдельного пересмотра требований.
> Целевой исполнитель: Flash / GPT-5.6 Luna. План самодостаточен: читай и кодируй по нему.
> Источник-референс (паттерн уже отлажен): drhider v0.0.75 (`site/templates/index.html`,
> `site/routes/api_bp.py`, `site/session.py`, `upload/`). Здесь — **standalone** проект:
@@ -64,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
+103 -39
View File
@@ -1,51 +1,83 @@
# Upload Platform
Переиспользуемый сервис загрузки файлов через ВМ-буфер. Выносит два слоя —
**выбор файлов** (фронт) и **закачку через ВМ** (фронт + бэк) — в отдельный
проект, который копируется в любой другой проект.
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы,
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
загружает его на VM, в Flask или в другое хранилище.
Паттерн: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` (egress pull) → сессия.
## Карта файлов
## Слои
### Корень проекта
| Слой | Ответственность |
| Путь | Назначение | Статус |
|---|---|---|
| `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-а
| Путь | Назначение |
|---|---|
| 0. Конфиг | `vmUploadUrl`, `allowedExt`, лимиты, ретраи |
| 1. Выбор файлов | таблица, дедуп, раскрытие ZIP, рекурсивный обход папки |
| 2. Закачка через ВМ | PUT на ВМ → pull в сессию |
| 3. Логика приложения | здесь — стаб-заглушка (в каждом проекте своя) |
| `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 (рабочий конфиг этого демо)
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
│ ├── README.md # инструкция интеграции
│ ├── config.example.json
│ ├── frontend/ # слои 1+2 (vanilla JS, ES-модули)
│ │ ├── zip/
│ │ ├── table/
│ │ └── upload/
│ └── backend/ # слой 2 (Python)
│ ├── upload_refs/
│ └── session/
└── 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`.
## Локальный запуск
```bash
pip install -r requirements.txt
npm install
npm run build
python site/app.py
# → http://127.0.0.1:5000/
```
Для запуска уже собранного demo достаточно иметь зависимости Python и
содержимое `dist/`. `npm run build` требуется после изменения исходников
`upload/frontend/`.
## Деплой на Nubes
- Точка входа: `site/app.py` (платформа запускает `python site/app.py`).
@@ -53,13 +85,45 @@ python site/app.py
- Маршрут `/health` → `200` (иначе liveness-проба платформы убивает под).
- Без `site/__init__.py` и factory pattern.
## Как интегрировать слои в другой проект
## Как интегрировать picker в другой проект
1. Скопировать папку `upload/` в свой проект.
2. Задать свой конфиг (по образцу `upload/config.example.json`).
3. Фронт: подключить ES-модули из `upload/frontend/` через `<script type="module">`,
вызвать `initUploadTable(cfg)` и `uploadViaVM(...)`.
4. Бэк: зарегистрировать `create_upload_refs_blueprint(cfg)`, импортировать `upload.backend.session`.
5. Слой 3 (обработка файлов) — свой; сюда приходит список файлов сессии после закачки.
1. Скопировать `dist/file-picker.iife.js` или `dist/file-picker.esm.js` в свой проект.
2. Подключить IIFE через `<script>` и вызвать `FilePicker.initFilePicker`, либо импортировать ESM-бандл.
3. Передать `mount`, `allowedExt`, а при необходимости `labels`, `layout`, `limits` и `onChange`.
4. Получить выбранные leaf-файлы через `picker.getFiles()`; каждый элемент содержит
`path`, `name`, `size` и исходный `file`.
5. После удаления компонента вызвать `picker.destroy()`.
Подробнее — `PLAN.md`.
Исходники `upload/frontend/` нужны только для разработки и пересборки bundle;
встроенная библиотека `fflate` уже включена в готовые bundle.
Разрешённые расширения демо: `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
ZIP-файлы используются как контейнеры и раскрываются в браузере; сам ZIP не
возвращается как leaf-файл. По умолчанию действуют ограничения: 1000 entries,
100 MiB суммарно, 50 MiB на entry и глубина 20.
## Legacy
### Удалено и не должно восстанавливаться
- `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).
История решений и проверок находится в [`HISTORY/`](HISTORY/).
+25
View File
@@ -0,0 +1,25 @@
import { build } from 'esbuild';
import { rm } from 'node:fs/promises';
await rm('dist', { recursive: true, force: true });
const shared = {
entryPoints: ['upload/frontend/index.js'],
bundle: true,
minify: false,
sourcemap: false,
logLevel: 'info',
};
await build({
...shared,
format: 'esm',
outfile: 'dist/file-picker.esm.js',
});
await build({
...shared,
format: 'iife',
globalName: 'FilePicker',
outfile: 'dist/file-picker.iife.js',
});
+3
View File
@@ -0,0 +1,3 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
+995
View File
@@ -0,0 +1,995 @@
// node_modules/fflate/esm/browser.js
var u8 = Uint8Array;
var u16 = Uint16Array;
var i32 = Int32Array;
var fleb = new u8([
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2,
2,
2,
2,
3,
3,
3,
3,
4,
4,
4,
4,
5,
5,
5,
5,
0,
/* unused */
0,
0,
/* impossible */
0
]);
var fdeb = new u8([
0,
0,
0,
0,
1,
1,
2,
2,
3,
3,
4,
4,
5,
5,
6,
6,
7,
7,
8,
8,
9,
9,
10,
10,
11,
11,
12,
12,
13,
13,
/* unused */
0,
0
]);
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
var freb = function(eb, start) {
var b = new u16(31);
for (var i = 0; i < 31; ++i) {
b[i] = start += 1 << eb[i - 1];
}
var r = new i32(b[30]);
for (var i = 1; i < 30; ++i) {
for (var j = b[i]; j < b[i + 1]; ++j) {
r[j] = j - b[i] << 5 | i;
}
}
return { b, r };
};
var _a = freb(fleb, 2);
var fl = _a.b;
var revfl = _a.r;
fl[28] = 258, revfl[258] = 28;
var _b = freb(fdeb, 0);
var fd = _b.b;
var revfd = _b.r;
var rev = new u16(32768);
for (i = 0; i < 32768; ++i) {
x = (i & 43690) >> 1 | (i & 21845) << 1;
x = (x & 52428) >> 2 | (x & 13107) << 2;
x = (x & 61680) >> 4 | (x & 3855) << 4;
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
}
var x;
var i;
var hMap = (function(cd, mb, r) {
var s = cd.length;
var i = 0;
var l = new u16(mb);
for (; i < s; ++i) {
if (cd[i])
++l[cd[i] - 1];
}
var le = new u16(mb);
for (i = 1; i < mb; ++i) {
le[i] = le[i - 1] + l[i - 1] << 1;
}
var co;
if (r) {
co = new u16(1 << mb);
var rvb = 15 - mb;
for (i = 0; i < s; ++i) {
if (cd[i]) {
var sv = i << 4 | cd[i];
var r_1 = mb - cd[i];
var v = le[cd[i] - 1]++ << r_1;
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
co[rev[v] >> rvb] = sv;
}
}
}
} else {
co = new u16(s);
for (i = 0; i < s; ++i) {
if (cd[i]) {
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
}
}
}
return co;
});
var flt = new u8(288);
for (i = 0; i < 144; ++i)
flt[i] = 8;
var i;
for (i = 144; i < 256; ++i)
flt[i] = 9;
var i;
for (i = 256; i < 280; ++i)
flt[i] = 7;
var i;
for (i = 280; i < 288; ++i)
flt[i] = 8;
var i;
var fdt = new u8(32);
for (i = 0; i < 32; ++i)
fdt[i] = 5;
var i;
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
var max = function(a) {
var m = a[0];
for (var i = 1; i < a.length; ++i) {
if (a[i] > m)
m = a[i];
}
return m;
};
var bits = function(d, p, m) {
var o = p / 8 | 0;
return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
};
var bits16 = function(d, p) {
var o = p / 8 | 0;
return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
};
var shft = function(p) {
return (p + 7) / 8 | 0;
};
var slc = function(v, s, e) {
if (s == null || s < 0)
s = 0;
if (e == null || e > v.length)
e = v.length;
return new u8(v.subarray(s, e));
};
var ec = [
"unexpected EOF",
"invalid block type",
"invalid length/literal",
"invalid distance",
"stream finished",
"no stream handler",
,
// determined by compression function
"no callback",
"invalid UTF-8 data",
"extra field too long",
"date not in range 1980-2099",
"filename too long",
"stream finishing",
"invalid zip data"
// determined by unknown compression method
];
var err = function(ind, msg, nt) {
var e = new Error(msg || ec[ind]);
e.code = ind;
if (Error.captureStackTrace)
Error.captureStackTrace(e, err);
if (!nt)
throw e;
return e;
};
var inflt = function(dat, st, buf, dict) {
var sl = dat.length, dl = dict ? dict.length : 0;
if (!sl || st.f && !st.l)
return buf || new u8(0);
var noBuf = !buf;
var resize = noBuf || st.i != 2;
var noSt = st.i;
if (noBuf)
buf = new u8(sl * 3);
var cbuf = function(l2) {
var bl = buf.length;
if (l2 > bl) {
var nbuf = new u8(Math.max(bl * 2, l2));
nbuf.set(buf);
buf = nbuf;
}
};
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
var tbts = sl * 8;
do {
if (!lm) {
final = bits(dat, pos, 1);
var type = bits(dat, pos + 1, 3);
pos += 3;
if (!type) {
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
if (t > sl) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + l);
buf.set(dat.subarray(s, t), bt);
st.b = bt += l, st.p = pos = t * 8, st.f = final;
continue;
} else if (type == 1)
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
else if (type == 2) {
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
var tl = hLit + bits(dat, pos + 5, 31) + 1;
pos += 14;
var ldt = new u8(tl);
var clt = new u8(19);
for (var i = 0; i < hcLen; ++i) {
clt[clim[i]] = bits(dat, pos + i * 3, 7);
}
pos += hcLen * 3;
var clb = max(clt), clbmsk = (1 << clb) - 1;
var clm = hMap(clt, clb, 1);
for (var i = 0; i < tl; ) {
var r = clm[bits(dat, pos, clbmsk)];
pos += r & 15;
var s = r >> 4;
if (s < 16) {
ldt[i++] = s;
} else {
var c = 0, n = 0;
if (s == 16)
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
else if (s == 17)
n = 3 + bits(dat, pos, 7), pos += 3;
else if (s == 18)
n = 11 + bits(dat, pos, 127), pos += 7;
while (n--)
ldt[i++] = c;
}
}
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
lbt = max(lt);
dbt = max(dt);
lm = hMap(lt, lbt, 1);
dm = hMap(dt, dbt, 1);
} else
err(1);
if (pos > tbts) {
if (noSt)
err(0);
break;
}
}
if (resize)
cbuf(bt + 131072);
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
var lpos = pos;
for (; ; lpos = pos) {
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
pos += c & 15;
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (!c)
err(2);
if (sym < 256)
buf[bt++] = sym;
else if (sym == 256) {
lpos = pos, lm = null;
break;
} else {
var add = sym - 254;
if (sym > 264) {
var i = sym - 257, b = fleb[i];
add = bits(dat, pos, (1 << b) - 1) + fl[i];
pos += b;
}
var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
if (!d)
err(3);
pos += d & 15;
var dt = fd[dsym];
if (dsym > 3) {
var b = fdeb[dsym];
dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
}
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + 131072);
var end = bt + add;
if (bt < dt) {
var shift = dl - dt, dend = Math.min(dt, end);
if (shift + bt < 0)
err(3);
for (; bt < dend; ++bt)
buf[bt] = dict[shift + bt];
}
for (; bt < end; ++bt)
buf[bt] = buf[bt - dt];
}
}
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
if (lm)
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
} while (!final);
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
};
var et = /* @__PURE__ */ new u8(0);
var b2 = function(d, b) {
return d[b] | d[b + 1] << 8;
};
var b4 = function(d, b) {
return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
};
var b8 = function(d, b) {
return b4(d, b) + b4(d, b + 4) * 4294967296;
};
function inflateSync(data, opts) {
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
}
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
var tds = 0;
try {
td.decode(et, { stream: true });
tds = 1;
} catch (e) {
}
var dutf8 = function(d) {
for (var r = "", i = 0; ; ) {
var c = d[i++];
var eb = (c > 127) + (c > 223) + (c > 239);
if (i + eb > d.length)
return { s: r, r: slc(d, i - 1) };
if (!eb)
r += String.fromCharCode(c);
else if (eb == 3) {
c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
} else if (eb & 1)
r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
else
r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
}
};
function strFromU8(dat, latin1) {
if (latin1) {
var r = "";
for (var i = 0; i < dat.length; i += 16384)
r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
return r;
} else if (td) {
return td.decode(dat);
} else {
var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
if (r.length)
err(8);
return s;
}
}
var slzh = function(d, b) {
return b + 30 + b2(d, b + 26) + b2(d, b + 28);
};
var zh = function(d, b, z) {
var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
};
var z64hs = function(d, b, l, z, sc, su, off) {
var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
var nf = nsc + nsu + noff;
if (z && nf) {
for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
if (b2(d, b) == 1) {
return [
nsc ? b8(d, b + 4 + 8 * nsu) : sc,
nsu ? b8(d, b + 4) : su,
noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
1
];
}
}
if (z < 2)
err(13);
}
return [sc, su, off, 0];
};
function unzipSync(data, opts) {
var files = {};
var e = data.length - 22;
for (; b4(data, e) != 101010256; --e) {
if (!e || data.length - e > 65558)
err(13);
}
;
var c = b2(data, e + 8);
if (!c)
return {};
var o = b4(data, e + 16);
var z = b4(data, e - 20) == 117853008;
if (z) {
var ze = b4(data, e - 12);
z = b4(data, ze) == 101075792;
if (z) {
c = b4(data, ze + 32);
o = b4(data, ze + 48);
}
}
var fltr = opts && opts.filter;
for (var i = 0; i < c; ++i) {
var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
o = no;
if (!fltr || fltr({
name: fn,
size: sc,
originalSize: su,
compression: c_2
})) {
if (!c_2)
files[fn] = slc(data, b, b + sc);
else if (c_2 == 8)
files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
else
err(14, "unknown compression type " + c_2);
}
}
return files;
}
// 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.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
// upload/frontend/zip/list_zip_files.js
var DEFAULT_LIMITS = {
maxEntries: 1e3,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20
};
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
function makeFile(data, name) {
return new File([data], name.split("/").at(-1));
}
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
const parts = entryName.split("/");
if (parts.some((part) => !part || part === "." || part === "..")) return null;
return parts;
}
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
}
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? fileNode : node("folder", part, `${current.path}/${part}`);
current.children.push(child);
}
current = child;
});
}
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP");
const entries = unzipSync(data, {
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");
}
budget.totalBytes += entry.originalSize;
return true;
}
});
const root = node("zip", zipName, zipName);
for (const [entryName, entryData] of Object.entries(entries)) {
if (entryName.endsWith("/")) continue;
const parts = safeEntryParts(entryName);
if (!parts) continue;
const normalizedName = parts.join("/");
if (normalizedName.toLowerCase().endsWith(".zip")) {
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
if (nested) {
rebaseTree(nested, zipName);
nested.name = parts.at(-1);
addPath(root, parts, nested);
}
} else if (extensionAllowed(normalizedName, allowedExt)) {
const path = `${zipName}/${normalizedName}`;
const file = makeFile(entryData, path);
addPath(root, parts, node("file", parts.at(-1), path, [], file));
}
}
return root.children.length ? root : null;
}
async function listZipFiles(file, allowedExt, customLimits = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const limits = { ...DEFAULT_LIMITS, ...customLimits };
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
}
// upload/frontend/table/add_file_with_dedup.js
function addFileWithDedup(state, fileNode) {
const accept = (node2) => {
if (node2.kind === "file") {
const key = `${node2.path}\0${node2.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node2;
}
node2.children = node2.children.map(accept).filter(Boolean);
return node2.children.length ? node2 : null;
};
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) {
return String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character]);
}
// upload/frontend/table/fs.js
function fs(bytes) {
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
}
// upload/frontend/table/render.js
var DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: "\u0433\u043E\u0442\u043E\u0432",
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${fs(bytes)}`
},
layout: { columns: ["path", "size", "status"] }
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...cfg.labels || {},
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...cfg.labels?.columns || {}
}
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...cfg.layout || {} }
};
}
function renderNode(node2, depth, cfg) {
const isGroup = node2.kind !== "file";
const padding = depth * 4;
const name = esc(node2.name);
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
const cells = {
path: action,
size: node2.kind === "file" ? fs(node2.file.size) : "",
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
};
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
if (!isGroup || !node2.expanded) return row;
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
}
function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length ? normalized.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length ? state.nodes.map((node2) => renderNode(node2, 0, normalized)).join("") : `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
const visit = (node2) => {
if (node2.kind === "file") files.push(node2);
else node2.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
function findNode(nodes, id) {
for (const node2 of nodes) {
if (node2.id === id) return { node: node2, nodes };
const found = findNode(node2.children || [], id);
if (found) return found;
}
return null;
}
function flattenFiles(nodes, result = []) {
nodes.forEach((node2) => {
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
else flattenFiles(node2.children, result);
});
return result;
}
// upload/frontend/table/on_files_change.js
async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith(".zip")) {
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
addFileWithDedup(state, {
id: crypto.randomUUID(),
kind: "file",
name: file.name,
path: file.name,
file,
children: [],
expanded: true
});
continue;
}
try {
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);
} 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;
}
}
render(state, elements, cfg);
}
function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
elements.fileInputEl.value = "";
state.busy = false;
}
};
}
// upload/frontend/table/on_folder_change.js
function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = /* @__PURE__ */ new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
const parts = (file.webkitRelativePath || file.name).split("/");
const relativePath = parts.slice(1).join("/") || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
roots.set(rootName, {
id: crypto.randomUUID(),
kind: "folder",
name: rootName,
path: rootName,
children: [],
expanded: true
});
}
const root = roots.get(rootName);
const addToFolder = (node2) => {
let current = root;
const nodeParts = node2.path.split("/").slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node2 : {
id: crypto.randomUUID(),
kind: "folder",
name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join("/")}`,
children: [],
expanded: true
};
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith(".zip")) {
try {
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
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))) {
addToFolder({
id: crypto.randomUUID(),
kind: "file",
name: parts.at(-1),
path: `${rootName}/${relativePath}`,
file,
children: [],
expanded: true
});
}
}
roots.forEach((root) => addFileWithDedup(state, root));
elements.folderInputEl.value = "";
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
// upload/frontend/index.js
var DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B",
pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C",
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
statusReady: "\u0433\u043E\u0442\u043E\u0432",
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B`
},
layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] },
limits: {
maxEntries: 1e3,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20
}
};
function resolveMount(mount) {
const element = typeof mount === "string" ? document.querySelector(mount) : mount;
if (!element) throw new Error("File picker mount element \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D");
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...config.labels || {},
columns: { ...DEFAULTS.labels.columns, ...config.labels?.columns || {} }
},
layout: { ...DEFAULTS.layout, ...config.layout || {} },
limits: { ...DEFAULTS.limits, ...config.limits || {} }
};
}
function injectStyles() {
if (document.querySelector("style[data-file-picker]")) return;
const style = document.createElement("style");
style.dataset.filePicker = "";
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = "") => controls.includes(name) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
${control("files", "fp-files-btn", cfg.labels.pickFiles)}
${control("folder", "fp-folder-btn", cfg.labels.pickFolder, "secondary")}
${control("clear", "fp-clear-btn", cfg.labels.clear, "quiet")}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === "function") cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node2) {
if (node2.kind === "file") {
state.fileKeys.delete(`${node2.path}\0${node2.file.size}`);
return;
}
node2.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
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")
};
const state = { nodes: [], fileKeys: /* @__PURE__ */ new Set(), busy: false };
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, "change", async () => {
await filesChange();
notify(cfg, state);
});
listen(elements.folderInputEl, "change", async () => {
await folderChange();
notify(cfg, state);
});
listen(elements.tableBodyEl, "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;
redraw();
return;
}
const remove = event.target.closest("[data-remove]");
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector("#fp-files-btn")) listen(root.querySelector("#fp-files-btn"), "click", () => elements.fileInputEl.click());
if (root.querySelector("#fp-folder-btn")) listen(root.querySelector("#fp-folder-btn"), "click", () => elements.folderInputEl.click());
if (root.querySelector("#fp-clear-btn")) listen(root.querySelector("#fp-clear-btn"), "click", () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => {
await addFiles(state, cfg, files, elements);
notify(cfg, state);
},
getFiles: () => [...flattenFiles(state.nodes)],
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
mount.replaceChildren();
}
};
}
export {
DEFAULTS,
initFilePicker
};
+1019
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
# Справочник кода Upload Platform
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
и API загрузки относятся к отменённой архитектуре и в текущем коде отсутствуют.
## Архитектура
```text
site/app.py
-> site/templates/index.html
-> dist/file-picker.iife.js
-> upload/frontend/index.js
-> table/on_files_change.js
-> table/on_folder_change.js
-> table/add_file_with_dedup.js
-> table/render.js
-> zip/list_zip_files.js
```
Flask отдаёт страницу и готовый bundle. Все выбранные файлы остаются в памяти браузера;
сервер не принимает содержимое файлов.
## Модель данных
Каждый узел дерева имеет поля:
| Поле | Тип | Назначение |
|---|---|---|
| `id` | `string` | Уникальный идентификатор для DOM-кнопок и поиска. |
| `kind` | `file`, `folder`, `zip` | Тип узла. `file` является leaf. |
| `name` | `string` | Имя отображения в дереве. |
| `path` | `string` | Полный логический путь внутри выбора/архива. |
| `file` | `File \| null` | Исходный или созданный из ZIP browser File для leaf. |
| `children` | `Array` | Дочерние узлы группы. |
| `expanded` | `boolean` | Видимость дочерних строк в таблице. |
Внутреннее состояние picker-а:
| Поле | Назначение |
|---|---|
| `nodes` | Корневые узлы дерева. |
| `fileKeys` | Set ключей `path + NUL + size` для дедупликации. |
| `busy` | Защита от параллельной обработки async change-событий. |
## `upload/frontend/table`
### `add_file_with_dedup.js`
| Функция | Вход | Выход | Побочные эффекты |
|---|---|---|---|
| `addFileWithDedup(state, fileNode)` | state и корневой file/folder/zip-узел | `boolean` | Фильтрует дубли и пустые группы, добавляет узел и ключи в state. |
Дедупликация выполняется рекурсивно. Один и тот же путь с другим размером не
считается дублем.
### `esc.js`
| Функция | Вход | Выход |
|---|---|---|
| `esc(value)` | Любое значение | HTML-экранированная строка |
Используется перед `innerHTML`, `data-path`, `aria-label` и текстом имени.
### `fs.js`
| Функция | Вход | Выход |
|---|---|---|
| `fs(bytes)` | Размер в байтах | Строка в `B`, `KB` или `MB` |
### `rebase_tree.js`
| Функция | Вход | Выход | Побочные эффекты |
|---|---|---|---|
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf сохраняет базовое имя `File.name`. |
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
и для nested ZIP.
### `on_files_change.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `addFiles(state, cfg, files, elements)` | state, `allowedExt`, FileList/Array, DOM | `Promise<void>` | Фильтрует обычные файлы, раскрывает ZIP, вызывает render. |
| `onFilesChange(state, cfg, elements)` | state, config, DOM | async callback | Блокирует повторные события через `busy` и вызывает `addFiles`. |
Ошибка отдельного ZIP пропускается и передаётся в `cfg.onError(error, file)`, если
callback задан. `finally` обязательно освобождает `busy`.
### `on_folder_change.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `onFolderChange(state, cfg, elements)` | state, `allowedExt`, folder input и DOM | async callback | Строит дерево по `webkitRelativePath`, раскрывает ZIP и рендерит. |
Корень пути становится folder-узлом. Вложенные папки создаются по сегментам
пути, затем корень проходит общую дедупликацию.
### `render.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `renderNode(node, depth, cfg)` | Узел, глубина и render config | HTML | Рекурсивно создаёт строки дерева. Внутренняя функция. |
| `render(state, elements, cfg)` | state, DOM и render config | `void` | Перерисовывает таблицу и счётчик. |
| `findNode(nodes, id)` | Массив узлов и id | `{node, nodes}` или `null` | Ищет узел и его родительский массив. |
| `flattenFiles(nodes, result)` | Дерево и аккумулятор | Массив leaf-файлов | Преобразует дерево для API приложения. |
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
дочерние строки только при `expanded === true`.
## `upload/frontend/zip`
### `list_zip_files.js`
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. |
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
| `listEntries(data, zipName, allowedExt, depth)` | ZIP bytes и контекст | `Promise<node|null>` | Рекурсивно обрабатывает ZIP. Внутренняя. |
| `listZipFiles(file, allowedExt, limits)` | browser File, расширения и limits | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
Безопасность: отклоняются абсолютные пути, backslash, пустые сегменты, `.` и
`..`; применяются лимиты `maxEntries`, `maxTotalBytes`, `maxEntryBytes` и
`maxDepth` с безопасными значениями по умолчанию. ZIP без разрешённых leaf-файлов
возвращает `null` и не создаёт пустую группу.
## `upload/frontend/index.js`
`initFilePicker({ mount, allowedExt, labels, layout, limits, onChange, onError })`
строит весь DOM внутри HTMLElement или CSS-селектора `mount`, подключает inputs,
таблицу и обработчики. Возвращаемый API: `pickFiles`, `pickFolder`, `addFiles`,
`getFiles`, `remove`, `clear`, `render`, `destroy`. `getFiles` и `onChange`
возвращают копии массива leaf-файлов. `destroy` снимает слушатели и очищает mount.
IIFE-бандл экспортирует этот API как `FilePicker.initFilePicker`; ESM-бандл
экспортирует функцию напрямую. `fflate` включён в оба bundle, поэтому глобальный
vendor script не требуется.
## Flask и HTML
### `site/app.py`
| Маршрут/объект | Вход | Выход |
|---|---|---|
| `CONFIG` | `config.json` | JSON-конфигурация picker |
| `VERSION` | константа | Версия страницы |
| `index()` / `/` | HTTP GET | HTML с version и config |
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
| `upload_frontend(filename)` | HTTP GET и относительный путь | Совместимый маршрут для исходных frontend-модулей |
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
### `site/templates/index.html`
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
сброса browser cache и вызывает `FilePicker.initFilePicker`.
Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это
внешняя библиотека, используемая как готовый runtime dependency.
+206
View File
@@ -0,0 +1,206 @@
# План: picker → универсальный встраиваемый компонент — LEGACY
> Переход завершён. Этот исторический план не является инструкцией или
> спецификацией текущего кода. Актуальные документы: `README.md`,
> `upload/README.md` и `docs/CODE-REFERENCE.md`.
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
не вносить изменений за рамками перечисленных пунктов.
## Цель
Превратить слой выбора файлов в переиспользуемый компонент/АПИ, который встраивается
в любое приложение (включая managed Flask `drhider`) одной строкой. На входе задаются:
типы файлов, порядок/скрытие колонок, все надписи, тема. Контент файлов на сервер не уходит.
## Верифицированное текущее состояние
- Язык: vanilla JS, ES-модули. Flask — только статический сервер.
- Структура `upload/frontend/`:
- `table/init_upload_table.js` — `initUploadTable(cfg)` ТРЕБУЕТ готовые DOM-элементы
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
- `table/on_files_change.js` — `addFiles`, `onFilesChange` (фильтр расширений, ZIP).
- `table/on_folder_change.js` — `onFolderChange` (webkitdirectory).
- `table/add_file_with_dedup.js` — `addFileWithDedup` (дедуп `path + NUL + size`).
- `table/render.js` — `renderNode`, `render`, `findNode`, `flattenFiles`.
ЗАШИТЫ строки: `'готов'`, `'Удалить ${name}'`, `'Нет выбранных файлов'`,
`'${n} файлов · ${bytes}'`.
- `table/rebase_tree.js`, `table/esc.js` (HTML-экранирование), `table/fs.js` (байты).
- `table/set_status.js` — LEGACY, не используется, НЕ трогать.
- `zip/list_zip_files.js` — `listZipFiles`; на строке 65 вызывает ГЛОБАЛЬНУЮ
`fflate.unzipSync(data)`. Содержит `safeEntryParts` (path traversal), лимит глубины 20.
- `site/app.py` — маршруты `/`, `/health`, `/upload-frontend/<path:filename>`
(`send_from_directory(ROOT/"upload"/"frontend", filename)`). VERSION `0.1.10`.
- `site/templates/index.html` — demo: глобально грузит `vendor/fflate.min.js`,
импортирует модуль по АБСОЛЮТНОМУ пути `/upload-frontend/table/init_upload_table.js`,
содержит зашитые `<thead>` «Путь/Размер/Статус» и кнопки.
- `site/static/vendor/fflate.min.js` — локальная глобальная `fflate`.
- `node_modules/fflate` есть, но корневого `package.json` НЕТ.
- `requirements.txt` — Flask>=3.0, gunicorn, requests.
## Жёсткие связи (что убираем)
1. Глобальная зависимость `fflate` (реальная — `list_zip_files.js:65`).
2. Абсолютный путь импорта `/upload-frontend/...` (в `index.html` + маршрут в `app.py`).
3. Зашитые надписи/колонки в `render.js` и `<thead>`.
4. Принудительный DOM-контракт `initUploadTable`.
5. Нет сборки/единой точки входа.
## Целевая архитектура
Единая точка входа `initFilePicker(config)`:
- сама строит DOM внутри `config.mount` (не требует готовых элементов);
- переиспользует существующие модули (дедуп, ZIP, рендер, обход папок);
- возвращает API с `destroy()`.
Один бандл (ESM + IIFE) с вшитым `fflate` и инжектом CSS. Без Shadow DOM, без системы тем,
без npm-пайплайна (dist коммитится в git).
## Схема конфига `initFilePicker(config)`
```js
initFilePicker({
mount, // HTMLElement | CSS-селектор (обязательно)
allowedExt: ['.pdf', '.txt'],// обязательно; сопоставление регистронезависимое
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить', // подставляется в aria-label и title
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (n, bytes) => `${n} файлов · ${formatBytes(bytes)}`, // необязательно
},
layout: {
columns: ['path', 'size', 'status'], // порядок + скрытие (пустой массив = все)
controls: ['files', 'folder', 'clear'],
theme: null, // строка-класс для кастомизации (не система тем)
},
limits: { // защита ZIP (обязательные значения по умолчанию)
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024, // суммарный распакованный размер
maxEntryBytes: 50 * 1024 * 1024, // размер одного entry
maxDepth: 20,
},
onChange(files) {}, // вызывается после каждого изменения дерева
onError(err) {}, // необязательно
});
// Возвращает:
// { pickFiles, pickFolder, addFiles, getFiles, remove, clear, render, destroy }
// getFiles() возвращает копию массива (не внутренний state).
// destroy() снимает слушатели, чистит DOM и state.
```
## Пошаговые изменения по файлам
### 1. `upload/frontend/zip/list_zip_files.js`
- Заменить глобальный `fflate.unzipSync(data)` на импорт: `import { unzipSync } from 'fflate'`.
- Убрать вызов глобальной `fflate`; использовать `unzipSync(data, { filter })`.
`filter` получает entry-метаданные `{ name, size, originalSize, compression }`
и возвращает `false` для пропуска ДО распаковки. Через `filter` реализовать:
- счётчик обработанных entries > `limits.maxEntries` → выбросить ошибку/остановиться;
- `originalSize > limits.maxEntryBytes` → пропустить entry;
- накопительный распакованный размер > `limits.maxTotalBytes` → выбросить ошибку
(защита от zip-бомбы).
- `listZipFiles(file, allowedExt, limits)` — принять `limits`, прокинуть в `listEntries`.
- Лимит `depth > limits.maxDepth` вместо зашитого `20`.
- Сохранить `safeEntryParts` без изменений.
### 2. `upload/frontend/table/render.js`
- `renderNode(node, depth, cfg)` и `render(state, elements, cfg)` принимают `cfg`
(labels + layout). Заменить зашитые строки на `cfg.labels.*`:
- `'готов'` → `cfg.labels.statusReady`;
- `'Удалить ${name}'` → `${cfg.labels.remove} ${name}`;
- `'Нет выбранных файлов'` → `cfg.labels.empty`;
- счётчик → `cfg.labels.count(files.length, totalBytes)` или дефолт.
- Колонки: рендерить `<td>` только для колонок из `layout.columns` в заданном порядке;
при скрытой колонке не выводить её ячейку и соответствующий `<th>`.
- `flattenFiles` оставить как есть (он уже возвращает новые объекты), но в
`getFiles()` дополнительно отдавать `[...flattenFiles(state.nodes)]` (копия массива).
### 3. `upload/frontend/table/on_files_change.js` и `on_folder_change.js`
- Прокинуть `limits` в `listZipFiles(file, cfg.allowedExt, cfg.limits)`.
- Остальное (фильтр, busy, ZIP-catch) без изменений.
### 4. НОВЫЙ `upload/frontend/index.js` (единая точка входа)
- Экспортировать `initFilePicker(config)`.
- Внутри: построить разметку (кнопки, скрытые `<input type=file>`, таблица, счётчик)
внутри `config.mount`, с подписями из `config.labels` и набором кнопок из
`config.layout.controls`.
- Создать state `{ nodes, fileKeys: new Set(), busy }`.
- Переиспользовать существующие функции: навесить `onFilesChange`/`onFolderChange`
на созданные inputs, делегирование кликов (toggle/remove) как в `init_upload_table.js`.
- Вызывать `config.onChange` после каждой перерисовки с копией `getFiles()`.
- Реализовать `destroy()`: снять все слушатели, очистить `mount.innerHTML`, обнулить state.
- Оставить `initUploadTable` рабочим (обратная совместимость demo), но demo перевести на `initFilePicker`.
- Инжектировать CSS: `import cssText from './style.css'` (через esbuild loader `text`),
при первом вызове вставить `<style data-file-picker>` в `<head>` один раз.
### 5. НОВЫЙ `upload/frontend/style.css`
- Перенести релевантные стили из `site/static/style.css`, ВСЕ селекторы обернуть
под корневой класс `.file-picker` (префикс-изоляция, без Shadow DOM).
- Классы: `.file-picker`, `.fp-toolbar`, `.fp-table`, `.fp-tree-*`, `.fp-remove-btn`,
`.fp-status`, `.fp-count`. Поддержать `config.layout.theme` как доп. класс на контейнере.
### 6. Сборка
- Добавить `package.json` (devDependency `esbuild`).
- `build`-скрипт собирает из `upload/frontend/index.js`:
- `dist/file-picker.esm.js` (format=esm, fflate inline);
- `dist/file-picker.iife.js` (format=iife, глобал `FilePicker`, fflate inline);
- CSS инлайнится через loader `text`.
- `dist/` коммитится в git.
### 7. Demo `site/`
- `site/templates/index.html`: убрать глобальный `<script fflate>` и абсолютный import;
подключить собранный бандл (`dist/file-picker.iife.js` или ESM) и вызвать
`initFilePicker({ mount, allowedExt: {{ config|tojson }}.allowedExt, ... })`.
- `site/app.py`: убрать или оставить маршрут `/upload-frontend/...` (рекомендуется
оставить для совместимости, но demo использует бандл); добавить раздачу `dist/`
при необходимости. VERSION поднять (см. ниже).
### 8. Документация
- Обновить `README.md`, `upload/README.md`, `docs/CODE-REFERENCE.md` под новый API
`initFilePicker` и бандл. Пометить `initUploadTable`/`set_status.js` как legacy.
## Требования безопасности (обязательно сохранить)
- Экранирование всех имён/путей через `esc` перед `innerHTML` (уже есть — не сломать).
- Path traversal в ZIP (`safeEntryParts`) — без изменений.
- Новые лимиты ZIP из п.1 — обязательны (zip-бомба).
- `getFiles()`/`onChange` отдают копию, а не внутренний state.
- Не полагаться на абсолютные URL; пути бандла/ассетов задаются интегратором.
## Критерии приёмки
1. `initFilePicker({ mount, allowedExt, labels, layout, onChange })` встраивается
в пустую страницу одной строкой и работает (файл/папка/ZIP, дедуп, удаление, очистка).
2. Глобальной `fflate` больше нет (только внутри бандла).
3. Нет ни одного абсолютного импорта `/upload-frontend/...`.
4. Надписи и порядок/скрытие колонок меняются через `labels`/`layout` без правки кода.
5. `destroy()` полностью чистит DOM и слушатели; повторный `initFilePicker` в тот же
`mount` работает.
6. ZIP-бомба/превышение лимитов не вешает браузер (срабатывают `limits`).
7. `getFiles()` возвращает копию; мутация результата не ломает state.
8. Demo в `site/` работает через бандл; `/health` → 200.
## Что НЕ делать
- Не вводить Shadow DOM, не делать систему тем/плагинов, не публиковать в npm/CDN.
- Не трогать `set_status.js`, `site/static/vendor/fflate.min.js` (после перехода на
бандл тег можно удалить из index.html, но файл vendor можно оставить).
- Не менять формат возвращаемых leaf-объектов (`{ path, name, size, file }`).
## Порядок коммитов
Логические шаги (после каждого — `node --check`/сборка + `git diff --check`):
1. ZIP-лимиты + импорт fflate (п.1, п.3).
2. Параметризация render/labels/layout (п.2).
3. Новая точка входа `initFilePicker` + CSS + destroy (п.4, п.5).
4. Сборка esbuild (п.6).
5. Demo на бандл (п.7).
6. Документация (п.8) + bump VERSION в `site/app.py`.
@@ -0,0 +1,25 @@
# Ответ GPT SOL по архитектуре
## 1. Архитектура
Архитектура верна. Лишнее сейчас: `layout.theme` как система тем и Custom Element. Достаточно CSS-переменных/класса темы.
## 2. Более простой вариант
`initFilePicker({ mount, ... })` плюс один ESM-бандл и CSS. IIFE добавлять только для интеграций без ES-модулей.
## 3. Custom Element
Сначала фабрика и бандл. Custom Element нужен только при подтверждённом declarative-use кейсе; иначе это второй API для сопровождения.
## 4. Риски
- `destroy()` обязан снимать listeners и очищать DOM/state.
- `onChange` должен возвращать копию массива, а не внутренний state.
- Нужно сохранять исходные `File`; ZIP-элементы ими не являются, поэтому нужен явный тип результата.
- Лимиты ZIP: размер, число файлов, суммарный распакованный размер, compression ratio, recursion depth.
- `fflate` внутри бандла исключает глобальную зависимость.
- CSS изолировать префиксом классов; Shadow DOM пока не нужен.
- Не полагаться на абсолютные URL; ассеты должны быть в бандле или URL задаётся интегратором.
- Проверить старые браузеры: ES target, `webkitdirectory`, Custom Element.
- Для managed Flask достаточно статической раздачи ESM/CSS с корректными MIME и cache/versioning.
+60
View File
@@ -0,0 +1,60 @@
# Второе мнение по архитектуре — LEGACY
> Исторический prompt для старой архитектуры. Не использовать как план или
> описание текущего кода.
Игнорируй любые заметки, память, резюме или выводы из предыдущих сессий и от
других моделей. Отвечай строго на основе этого промпта, ничего не дочитывая.
Ты — ревьюер архитектуры. Отвечай ТОЛЬКО по существу, тезисами, без введения и воды.
Язык ответа: русский. Цель — сэкономить токены. Не переспрашивай, всё нужное уже ниже.
## Что за проект
Browser-only file picker (vanilla JS, ES-модули). Пользователь выбирает файлы / папку / ZIP,
picker строит сворачиваемое дерево и отдаёт хосту исходные browser `File` objects.
Контент файлов на сервер НЕ уходит. Flask — только статический сервер (страница + модули).
## Текущее состояние
Переиспользуемый слой `upload/frontend/`:
- `table/init_upload_table.js` — фабрика `initUploadTable(cfg)`. ТРЕБУЕТ готовые DOM-элементы
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
Внутренний state `{nodes, fileKeys(Set), busy}`.
- `table/on_files_change.js`, `on_folder_change.js` — обработка выбора, фильтр расширений.
- `table/add_file_with_dedup.js` — дедуп по ключу `path + NUL + size`.
- `table/render.js` — рендер дерева, `findNode`, `flattenFiles`.
- `table/rebase_tree.js`, `esc.js` (HTML-экранирование), `fs.js` (формат байт).
- `zip/list_zip_files.js` — парсинг ZIP через ГЛОБАЛЬНУЮ `fflate`; защита от path traversal
(отклоняет `..`, `/`, `\`, пустые сегменты); лимит глубины вложенности 20; вложенные ZIP.
- `site/` — demo-обёртка: `app.py` (Flask: `/`, `/health`, `/upload-frontend/<path>`),
`templates/index.html` (глобально подключает vendor `fflate`, импортирует модуль по
АБСОЛЮТНОМУ пути `/upload-frontend/...`, содержит `<thead>` с «Путь/Размер/Статус»),
`static/style.css`, `static/vendor/fflate.min.js`.
## Жёсткие связи (мешают переиспользованию)
1. Абсолютный путь импорта `/upload-frontend/...` — привязка к конкретному Flask-маршруту.
2. Глобальная зависимость `fflate` (интегратор сам подключает и следит за порядком).
3. Надписи и структура таблицы зашиты (thead, статус «готов», пустое состояние).
4. Принудительный DOM-контракт — интегратор строит разметку сам, «вставить без изменений» нельзя.
5. Нет сборки/дистрибутива — только исходники ES-модулей.
## Цель
Универсальный встраиваемый компонент/АПИ, который вставляется в любое приложение (включая
managed Flask — drhider) одной строкой. На входе задаются: типы файлов (`allowedExt`),
порядок/скрытие колонок, все надписи, тема. Без излишней сложности.
## Моё предложение
1. Единая точка входа `initFilePicker(config)` — сама строит DOM (не требует готовых элементов).
Config: `mount`, `allowedExt`, `labels{...все надписи...}`, `layout{columns, controls, theme}`,
`onChange`. Возвращает API + `destroy`.
2. Сборка esbuild/rollup в ОДИН бандл с вшитым `fflate`, форматы ESM + IIFE.
3. Опционально тонкий Custom Element `<file-picker allowed-ext="...">` поверх фабрики (~30 строк).
4. Без iframe/postMessage, без системы тем/плагинов, без npm-пайплайна (dist коммитится в git).
## Вопрос
1. Архитектура верна? Есть ли ошибка или переусложнение?
2. Есть ли вариант проще, но столь же универсальный?
3. Custom Element делать сразу или хватит фабрики + бандла?
4. Критичные риски / что упущено (безопасность, совместимость, managed Flask)?
Ответь по пунктам 1–4, кратко, только по делу.
@@ -0,0 +1,45 @@
# Ответ Sonnet по архитектуре
## Важная поправка к вводным
Промпт устарел относительно реального кода.
- `zip/list_zip_files.js` не использует глобальную `fflate` — реально вызывает `parseZip()` из `parse_zip.js`, собственный ZIP-парсер без внешних зависимостей.
- Защита от path traversal и лимит вложенности 20 реальны, но находятся в `list_zip_files.js` (`safeEntryParts`, `depth`), а не в парсинге через `fflate`.
- `index.html:37` всё ещё грузит `static/vendor/fflate.min.js` с комментарием «ZIP-парсер использует global fflate» — это мёртвый код и ложный комментарий, `fflate` фактически не импортируется.
- В `add_file_with_dedup.js` есть логика `maxFileBytes`/`maxSessionBytes`, но `config.json` содержит только `allowedExt`; лимиты сейчас нигде не задаются, код неактивен.
## 1. Архитектура
Направление верное: `initFilePicker(config)` с построением DOM внутри, конфигом для `allowedExt/labels/layout`, `onChange` и `destroy` соответствует цели «одна строка встраивания».
Переусложнение: бандл esbuild/rollup с вшитым `fflate` решает несуществующую проблему. Внешней зависимости `fflate` в JS-коде нет, есть только мёртвый script-тег. Нужен клинап, а не сборка ради вшивания отсутствующей зависимости.
Реальные жёсткие связи:
- абсолютный путь импорта `/upload-frontend/...`;
- захардкоженные подписи в `<thead>`.
## 2. Более простой вариант
Оставить ES-модули без сборщика. Добавить в конфиг `basePath` или import map вместо хардкода `/upload-frontend/...`, чтобы интегратор (`drhider`) мог использовать произвольный route.
Подписи и колонки вынести в `labels/layout`.
Отдельно удалить мёртвый `fflate` script-тег и неактивные `maxFileBytes/maxSessionBytes`, если они не используются picker-only конфигом.
## 3. Custom Element
Бандл не нужен. Custom Element пока не делать: для единственного известного интегратора (`drhider`) достаточно фабричной функции.
Custom Element имеет смысл добавить при появлении второго реального потребителя, которому нужен декларативный `<file-picker>`.
## 4. Риски
- Главный риск — план основан на неверной предпосылке о глобальной `fflate`-зависимости.
- Нужно проверить защиту от ZIP-бомб: `parse_zip.js` разбирает central directory и делает `inflateRaw` без очевидной проверки итогового размера entry.
- Совместимость с managed Flask (`drhider`) реально блокируется абсолютным route `/upload-frontend/...`; это главная связь для исправления.
## Краткий вывод
Начать с `basePath`/import map, конфигурируемых `labels/layout` и удаления мёртвого `fflate`/неактивных лимитов. Не добавлять пока сборщик и Custom Element.
+480
View File
@@ -0,0 +1,480 @@
{
"name": "upload-platform-file-picker",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "upload-platform-file-picker",
"version": "0.1.10",
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "upload-platform-file-picker",
"private": true,
"version": "0.1.13",
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
+31 -3
View File
@@ -1,20 +1,48 @@
from flask import Flask, render_template
import json
from pathlib import Path
from flask import Flask, render_template, send_from_directory
VERSION = "0.1.1"
ROOT = Path(__file__).resolve().parent.parent
# Конфигурация демо содержит только разрешённые расширения для browser picker.
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.13"
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
app = Flask(__name__, template_folder="templates", static_folder="static")
@app.route("/")
def index():
return render_template("index.html", version=VERSION)
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию."""
return render_template("index.html", version=VERSION, config=CONFIG)
@app.route("/health")
def health():
"""Возвращает 200 для liveness-проверки платформы."""
return "ok", 200
@app.get("/upload-frontend/<path:filename>")
def upload_frontend(filename):
"""Отдаёт один frontend-файл из изолированного каталога upload/frontend."""
return send_from_directory(ROOT / "upload" / "frontend", filename)
@app.get("/file-picker/<path:filename>")
def file_picker_dist(filename):
"""Отдаёт собранный универсальный picker-бандл из каталога dist."""
return send_from_directory(ROOT / "dist", filename)
if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=5000)
+49 -2
View File
@@ -1,8 +1,55 @@
/* Базовые цвета и типографика всего demo-picker. */
:root {
color-scheme: light;
font-family: sans-serif;
font-family: Georgia, 'Times New Roman', serif;
color: #17211b;
background: #e9eee8;
}
/* Фон страницы намеренно отделён от карточной логики: picker не требует backend UI. */
body {
margin: 2rem;
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 10% 0%, #f8fbf4, transparent 38%), #e9eee8;
}
/* Ограничивает ширину рабочей области и сохраняет адаптивные боковые поля. */
.page { max-width: 980px; margin: 0 auto; padding: 64px 24px; }
/* Служебный идентификатор версии приложения. */
.eyebrow { color: #56745f; font: 700 12px/1.2 sans-serif; letter-spacing: 2px; }
/* Заголовок и пояснение вводят пользователя в сценарий выбора документов. */
h1 { max-width: 620px; margin: 12px 0; font-size: clamp(38px, 7vw, 72px); line-height: .95; font-weight: 400; }
.lede { max-width: 560px; color: #5d6a61; font: 16px/1.6 sans-serif; }
/* Группа действий: выбор файлов, папки и очистка состояния. */
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 36px 0 16px; }
button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
button.secondary { color: #1f5a3b; background: #cbdcc9; }
button.quiet { color: #5d6a61; background: transparent; }
button:disabled { opacity: .45; cursor: not-allowed; }
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
th, td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
td:nth-child(2) { width: 140px; color: #68766c; }
.tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.tree-group { padding-left: 0; cursor: pointer; }
.tree-folder { color: #1f5a3b; }
.tree-zip { color: #8a5a20; }
.tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
/* Удаление работает для leaf и групп и обрабатывается делегированием в initUploadTable. */
.remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.remove-btn:hover { color: #b32f22; }
.tree-row td:first-child { white-space: nowrap; }
.count { color: #5d6a61; font: 13px sans-serif; }
/* Legacy-стили результата сохранены для совместимости, текущий picker их не использует. */
.result { margin-top: 44px; padding-top: 18px; border-top: 2px solid #1f5a3b; }
.result h2 { font-size: 26px; font-weight: 400; }
.result li { margin: 8px 0; font: 14px sans-serif; }
@media (max-width: 600px) {
/* На мобильном экране кнопки получают равномерную ширину. */
.page { padding: 36px 16px; }
.toolbar button { flex: 1 1 42%; }
}
+1
View File
@@ -0,0 +1 @@
!function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";_e.deflate=zt,_e.deflateSync=kt,_e.inflate=At,_e.inflateSync=Tt,_e.gzip=It,_e.compress=It,_e.gzipSync=Ut,_e.compressSync=Ut,_e.gunzip=Zt,_e.gunzipSync=qt,_e.zlib=Lt,_e.zlibSync=Bt,_e.unzlib=Nt,_e.unzlibSync=Pt,_e.gzip=It,_e.compress=It,_e.decompress=Jt,_e.decompressSync=Kt,_e.strToU8=nn,_e.strFromU8=rn,_e.zip=dn,_e.zipSync=gn,_e.unzip=zn,_e.unzipSync=kn;var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,r,t,n=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads"),r=e.Worker,t=e.isMarkedAsUntransferable}catch(e){}exports.default=r?function(e,o,a,s,u){var i=!1,l=new r(e+n,{eval:!0}).on("error",function(e){return u(e,null)}).on("message",function(e){return u(null,e)}).on("exit",function(e){e&&!i&&u(Error("exited with code "+e),null)});return t&&(s=s.filter(function(e){return!t(e)})),l.postMessage(a,s),l.terminate=function(){return i=!0,r.prototype.terminate.call(l)},l}:function(e,r,t,n,o){setImmediate(function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)});var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,i=Int32Array,e=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),s=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=function(t,n){for(var e=new r(31),o=0;o<31;++o)e[o]=n+=1<<t[o-1];var s=new i(e[30]);for(o=1;o<30;++o)for(var a=e[o];a<e[o+1];++a)s[a]=a-e[o]<<5|o;return{b:e,r:s}},u=a(e,2),h=u.b,f=u.r;h[28]=258,f[258]=28;for(var c=a(o,0),l=c.b,p=c.r,v=new r(32768),d=0;d<32768;++d){var g=(43690&d)>>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>2|(13107&g)<<2))>>4|(3855&g)<<4))>>8|(255&g)<<8)>>1}var y=function(t,n,i){for(var e=t.length,o=0,s=new r(n);o<e;++o)t[o]&&++s[t[o]-1];var a,u=new r(n);for(o=1;o<n;++o)u[o]=u[o-1]+s[o-1]<<1;if(i){a=new r(1<<n);var h=15-n;for(o=0;o<e;++o)if(t[o])for(var f=o<<4|t[o],c=n-t[o],l=u[t[o]-1]++<<c,p=l|(1<<c)-1;l<=p;++l)a[v[l]>>h]=f}else for(a=new r(e),o=0;o<e;++o)t[o]&&(a[o]=v[u[t[o]-1]++]>>15-t[o]);return a},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var b=new n(32);for(d=0;d<32;++d)b[d]=5;var w=y(m,9,0),x=y(m,9,1),z=y(b,5,0),k=y(b,5,1),M=function(t){for(var n=t[0],r=1;r<t.length;++r)t[r]>n&&(n=t[r]);return n},S=function(t,n,r){var i=n/8|0;return(t[i]|t[i+1]<<8)>>(7&n)&r},A=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},T=function(t){return(t+7)/8|0},D=function(t,r,i){return(null==r||r<0)&&(r=0),(null==i||i>t.length)&&(i=t.length),new n(t.subarray(r,i))};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var C=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],I=function(t,n,r){var i=Error(n||C[t]);if(i.code=t,Error.captureStackTrace&&Error.captureStackTrace(i,I),!r)throw i;return i},U=function(t,r,i,a){var u=t.length,f=a?a.length:0;if(!u||r.f&&!r.l)return i||new n(0);var c=!i,p=c||2!=r.i,v=r.i;c&&(i=new n(3*u));var d=function(t){var r=i.length;if(t>r){var e=new n(Math.max(2*r,t));e.set(i),i=e}},g=r.f||0,m=r.p||0,b=r.b||0,w=r.l,z=r.d,C=r.m,U=r.n,F=8*u;do{if(!w){g=S(t,m,1);var E=S(t,m+1,3);if(m+=3,!E){var Z=t[(Y=T(m)+4)-4]|t[Y-3]<<8,q=Y+Z;if(q>u){v&&I(0);break}p&&d(b+Z),i.set(t.subarray(Y,q),b),r.b=b+=Z,r.p=m=8*q,r.f=g;continue}if(1==E)w=x,z=k,C=9,U=5;else if(2==E){var O=S(t,m,31)+257,G=S(t,m+10,15)+4,L=O+S(t,m+5,31)+1;m+=14;for(var B=new n(L),H=new n(19),j=0;j<G;++j)H[s[j]]=S(t,m+3*j,7);m+=3*G;var N=M(H),P=(1<<N)-1,V=y(H,N,1);for(j=0;j<L;){var Y,J=V[S(t,m,P)];if(m+=15&J,(Y=J>>4)<16)B[j++]=Y;else{var K=0,Q=0;for(16==Y?(Q=3+S(t,m,3),m+=2,K=B[j-1]):17==Y?(Q=3+S(t,m,7),m+=3):18==Y&&(Q=11+S(t,m,127),m+=7);Q--;)B[j++]=K}}var R=B.subarray(0,O),W=B.subarray(O);C=M(R),U=M(W),w=y(R,C,1),z=y(W,U,1)}else I(1);if(m>F){v&&I(0);break}}p&&d(b+131072);for(var X=(1<<C)-1,$=(1<<U)-1,_=m;;_=m){var tt=(K=w[A(t,m)&X])>>4;if((m+=15&K)>F){v&&I(0);break}if(K||I(2),tt<256)i[b++]=tt;else{if(256==tt){_=m,w=null;break}var nt=tt-254;tt>264&&(nt=S(t,m,(1<<(et=e[j=tt-257]))-1)+h[j],m+=et);var rt=z[A(t,m)&$],it=rt>>4;if(rt||I(3),m+=15&rt,W=l[it],it>3){var et=o[it];W+=A(t,m)&(1<<et)-1,m+=et}if(m>F){v&&I(0);break}p&&d(b+131072);var ot=b+nt;if(b<W){var st=f-W,at=Math.min(W,ot);for(st+b<0&&I(3);b<at;++b)i[b]=a[st+b]}for(;b<ot;++b)i[b]=i[b-W]}}r.l=w,r.p=_,r.b=b,r.f=g,w&&(g=1,r.m=C,r.d=z,r.n=U)}while(!g);return b!=i.length&&c?D(i,0,b):i.subarray(0,b)},F=function(t,n,r){var i=n/8|0;t[i]|=r<<=7&n,t[i+1]|=r>>8},E=function(t,n,r){var i=n/8|0;t[i]|=r<<=7&n,t[i+1]|=r>>8,t[i+2]|=r>>16},Z=function(t,i){for(var e=[],o=0;o<t.length;++o)t[o]&&e.push({s:o,f:t[o]});var s=e.length,a=e.slice();if(!s)return{t:j,l:0};if(1==s){var u=new n(e[0].s+1);return u[e[0].s]=1,{t:u,l:1}}e.sort(function(t,n){return t.f-n.f}),e.push({s:-1,f:25001});var h=e[0],f=e[1],c=0,l=1,p=2;for(e[0]={s:-1,f:h.f+f.f,l:h,r:f};l!=s-1;)h=e[e[c].f<e[p].f?c++:p++],f=e[c!=l&&e[c].f<e[p].f?c++:p++],e[l++]={s:-1,f:h.f+f.f,l:h,r:f};var v=a[0].s;for(o=1;o<s;++o)a[o].s>v&&(v=a[o].s);var d=new r(v+1),g=q(e[l-1],d,0);if(g>i){o=0;var y=0,m=g-i,b=1<<m;for(a.sort(function(t,n){return d[n.s]-d[t.s]||t.f-n.f});o<s;++o){var w=a[o].s;if(!(d[w]>i))break;y+=b-(1<<g-d[w]),d[w]=i}for(y>>=m;y>0;){var x=a[o].s;d[x]<i?y-=1<<i-d[x]++-1:++o}for(;o>=0&&y;--o){var z=a[o].s;d[z]==i&&(--d[z],++y)}g=i}return{t:new n(d),l:g}},q=function(t,n,r){return-1==t.s?Math.max(q(t.l,n,r+1),q(t.r,n,r+1)):n[t.s]=r},O=function(t){for(var n=t.length;n&&!t[--n];);for(var i=new r(++n),e=0,o=t[0],s=1,a=function(t){i[e++]=t},u=1;u<=n;++u)if(t[u]==o&&u!=n)++s;else{if(!o&&s>2){for(;s>138;s-=138)a(32754);s>2&&(a(s>10?s-11<<5|28690:s-3<<5|12305),s=0)}else if(s>3){for(a(o),--s;s>6;s-=6)a(8304);s>2&&(a(s-3<<5|8208),s=0)}for(;s--;)a(o);s=1,o=t[u]}return{c:i.subarray(0,e),n:n}},G=function(t,n){for(var r=0,i=0;i<n.length;++i)r+=t[i]*n[i];return r},L=function(t,n,r){var i=r.length,e=T(n+2);t[e]=255&i,t[e+1]=i>>8,t[e+2]=255^t[e],t[e+3]=255^t[e+1];for(var o=0;o<i;++o)t[e+o+4]=r[o];return 8*(e+4+i)},B=function(t,n,i,a,u,h,f,c,l,p,v){F(n,v++,i),++u[256];for(var d=Z(u,15),g=d.t,x=d.l,k=Z(h,15),M=k.t,S=k.l,A=O(g),T=A.c,D=A.n,C=O(M),I=C.c,U=C.n,q=new r(19),B=0;B<T.length;++B)++q[31&T[B]];for(B=0;B<I.length;++B)++q[31&I[B]];for(var H=Z(q,7),j=H.t,N=H.l,P=19;P>4&&!j[s[P-1]];--P);var V,Y,J,K,Q=p+5<<3,R=G(u,m)+G(h,b)+f,W=G(u,g)+G(h,M)+f+14+3*P+G(q,j)+2*q[16]+3*q[17]+7*q[18];if(l>=0&&Q<=R&&Q<=W)return L(n,v,t.subarray(l,l+p));if(F(n,v,1+(W<R)),v+=2,W<R){V=y(g,x,0),Y=g,J=y(M,S,0),K=M;var X=y(j,N,0);for(F(n,v,D-257),F(n,v+5,U-1),F(n,v+10,P-4),v+=14,B=0;B<P;++B)F(n,v+3*B,j[s[B]]);v+=3*P;for(var $=[T,I],_=0;_<2;++_){var tt=$[_];for(B=0;B<tt.length;++B)F(n,v,X[rt=31&tt[B]]),v+=j[rt],rt>15&&(F(n,v,tt[B]>>5&127),v+=tt[B]>>12)}}else V=w,Y=m,J=z,K=b;for(B=0;B<c;++B){var nt=a[B];if(nt>255){var rt;E(n,v,V[257+(rt=nt>>18&31)]),v+=Y[rt+257],rt>7&&(F(n,v,nt>>23&31),v+=e[rt]);var it=31&nt;E(n,v,J[it]),v+=K[it],it>3&&(E(n,v,nt>>5&8191),v+=o[it])}else E(n,v,V[nt]),v+=Y[nt]}return E(n,v,V[256]),v+Y[256]},H=new i([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),j=new n(0),N=function(t,s,a,u,h,c){var l=c.z||t.length,v=new n(u+l+5*(1+Math.ceil(l/7e3))+h),d=v.subarray(u,v.length-h),g=c.l,y=7&(c.r||0);if(s){y&&(d[0]=c.r>>3);for(var m=H[s-1],b=m>>13,w=8191&m,x=(1<<a)-1,z=c.p||new r(32768),k=c.h||new r(x+1),M=Math.ceil(a/3),S=2*M,A=function(n){return(t[n]^t[n+1]<<M^t[n+2]<<S)&x},C=new i(25e3),I=new r(288),U=new r(32),F=0,E=0,Z=c.i||0,q=0,O=c.w||0,G=0;Z+2<l;++Z){var j=A(Z),N=32767&Z,P=k[j];if(z[N]=P,k[j]=N,O<=Z){var V=l-Z;if((F>7e3||q>24576)&&(V>423||!g)){y=B(t,d,0,C,I,U,E,q,G,Z-G,y),q=F=E=0,G=Z;for(var Y=0;Y<286;++Y)I[Y]=0;for(Y=0;Y<30;++Y)U[Y]=0}var J=2,K=0,Q=w,R=N-P&32767;if(V>2&&j==A(Z-R))for(var W=Math.min(b,V)-1,X=Math.min(32767,Z),$=Math.min(258,V);R<=X&&--Q&&N!=P;){if(t[Z+J]==t[Z+J-R]){for(var _=0;_<$&&t[Z+_]==t[Z+_-R];++_);if(_>J){if(J=_,K=R,_>W)break;var tt=Math.min(R,_-2),nt=0;for(Y=0;Y<tt;++Y){var rt=Z-R+Y&32767,it=rt-z[rt]&32767;it>nt&&(nt=it,P=rt)}}}R+=(N=P)-(P=z[N])&32767}if(K){C[q++]=268435456|f[J]<<18|p[K];var et=31&f[J],ot=31&p[K];E+=e[et]+o[ot],++I[257+et],++U[ot],O=Z+J,++F}else C[q++]=t[Z],++I[t[Z]]}}for(Z=Math.max(Z,O);Z<l;++Z)C[q++]=t[Z],++I[t[Z]];y=B(t,d,g,C,I,U,E,q,G,Z-G,y),g||(c.r=7&y|d[y/8|0]<<3,y-=7,c.h=k,c.p=z,c.i=Z,c.w=O)}else{for(Z=c.w||0;Z<l+g;Z+=65535){var st=Z+65535;st>=l&&(d[y/8|0]=g,st=l),y=L(d,y+1,t.subarray(Z,st))}c.i=l}return D(v,0,u+T(y)+h)},P=function(){for(var t=new Int32Array(256),n=0;n<256;++n){for(var r=n,i=9;--i;)r=(1&r&&-306674912)^r>>>1;t[n]=r}return t}(),V=function(){var t=-1;return{p:function(n){for(var r=t,i=0;i<n.length;++i)r=P[255&r^n[i]]^r>>>8;t=r},d:function(){return~t}}},Y=function(){var t=1,n=0;return{p:function(r){for(var i=t,e=n,o=0|r.length,s=0;s!=o;){for(var a=Math.min(s+2655,o);s<a;++s)e+=i+=r[s];i=(65535&i)+15*(i>>16),e=(65535&e)+15*(e>>16)}t=i,n=e},d:function(){return(255&(t%=65521))<<24|(65280&t)<<8|(255&(n%=65521))<<8|n>>8}}},J=function(t,r,i,e,o){if(!o&&(o={l:1},r.dictionary)){var s=r.dictionary.subarray(-32768),a=new n(s.length+t.length);a.set(s),a.set(t,s.length),t=a,o.w=s.length}return N(t,null==r.level?6:r.level,null==r.mem?o.l?Math.ceil(1.5*Math.max(8,Math.min(13,Math.log(t.length)))):20:12+r.mem,i,e,o)},K=function(t,n){var r={};for(var i in t)r[i]=t[i];for(var i in n)r[i]=n[i];return r},Q=function(t,n,r){for(var i=t(),e=""+t,o=e.slice(e.indexOf("[")+1,e.lastIndexOf("]")).replace(/\s+/g,"").split(","),s=0;s<i.length;++s){var a=i[s],u=o[s];if("function"==typeof a){n+=";"+u+"=";var h=""+a;if(a.prototype)if(-1!=h.indexOf("[native code]")){var f=h.indexOf(" ",8)+1;n+=h.slice(f,h.indexOf("(",f))}else for(var c in n+=h,a.prototype)n+=";"+u+".prototype."+c+"="+a.prototype[c];else n+=h}else r[u]=a}return n},R=[],W=function(t){var n=[];for(var r in t)t[r].buffer&&n.push((t[r]=new t[r].constructor(t[r])).buffer);return n},X=function(n,r,i,e){if(!R[i]){for(var o="",s={},a=n.length-1,u=0;u<a;++u)o=Q(n[u],o,s);R[i]={c:Q(n[a],o,s),e:s}}var h=K({},R[i].e);return(0,t.default)(R[i].c+";onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage="+r+"}",i,h,W(h),e)},$=function(){return[n,r,i,e,o,s,h,l,x,k,v,C,y,M,S,A,T,D,I,U,Tt,et,ot]},_=function(){return[n,r,i,e,o,s,f,p,w,m,z,b,v,H,j,y,F,E,Z,q,O,G,L,B,T,D,N,J,kt,et]},tt=function(){return[pt,gt,lt,V,P]},nt=function(){return[vt,dt]},rt=function(){return[yt,lt,Y]},it=function(){return[mt]},et=function(t){return postMessage(t,[t.buffer])},ot=function(t){return t&&{out:t.size&&new n(t.size),dictionary:t.dictionary}},st=function(t,n,r,i,e,o){var s=X(r,i,e,function(t,n){s.terminate(),o(t,n)});return s.postMessage([t,n],n.consume?[t.buffer]:[]),function(){s.terminate()}},at=function(t){return t.ondata=function(t,n){return postMessage([t,n],[t.buffer])},function(n){n.data[0]?(t.push(n.data[0],n.data[1]),postMessage([n.data[0].length])):t.flush(n.data[1])}},ut=function(t,n,r,i,e,o,s){var a,u=X(t,i,e,function(t,r){t?(u.terminate(),n.ondata.call(n,t)):Array.isArray(r)?1==r.length?(n.queuedSize-=r[0],n.ondrain&&n.ondrain(r[0])):(r[1]&&u.terminate(),n.ondata.call(n,t,r[0],r[1])):s(r)});u.postMessage(r),n.queuedSize=0,n.push=function(t,r){n.ondata||I(5),a&&n.ondata(I(4,0,1),null,!!r),n.queuedSize+=t.length,u.postMessage([t,a=r],t.buffer instanceof ArrayBuffer?[t.buffer]:[])},n.terminate=function(){u.terminate()},o&&(n.flush=function(t){u.postMessage([0,t])})},ht=function(t,n){return t[n]|t[n+1]<<8},ft=function(t,n){return(t[n]|t[n+1]<<8|t[n+2]<<16|t[n+3]<<24)>>>0},ct=function(t,n){return ft(t,n)+4294967296*ft(t,n+4)},lt=function(t,n,r){for(;r;++n)t[n]=r,r>>>=8},pt=function(t,n){var r=n.filename;if(t[0]=31,t[1]=139,t[2]=8,t[8]=n.level<2?4:9==n.level?2:0,t[9]=3,0!=n.mtime&&lt(t,4,Math.floor(new Date(n.mtime||Date.now())/1e3)),r){t[3]=8;for(var i=0;i<=r.length;++i)t[i+10]=r.charCodeAt(i)}},vt=function(t){31==t[0]&&139==t[1]&&8==t[2]||I(6,"invalid gzip data");var n=t[3],r=10;4&n&&(r+=2+(t[10]|t[11]<<8));for(var i=(n>>3&1)+(n>>4&1);i>0;i-=!t[r++]);return r+(2&n)},dt=function(t){var n=t.length;return(t[n-4]|t[n-3]<<8|t[n-2]<<16|t[n-1]<<24)>>>0},gt=function(t){return 10+(t.filename?t.filename.length+1:0)},yt=function(t,n){var r=n.level,i=0==r?0:r<6?1:9==r?3:2;if(t[0]=120,t[1]=i<<6|(n.dictionary&&32),t[1]|=31-(t[0]<<8|t[1])%31,n.dictionary){var e=Y();e.p(n.dictionary),lt(t,2,e.d())}},mt=function(t,n){return(8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31)&&I(6,"invalid zlib data"),(t[1]>>5&1)==+!n&&I(6,"invalid zlib data: "+(32&t[1]?"need":"unexpected")+" dictionary"),2+(t[1]>>3&4)};function bt(t,n){return"function"==typeof t&&(n=t,t={}),this.ondata=n,t}var wt=function(){function t(t,r){if("function"==typeof t&&(r=t,t={}),this.ondata=r,this.o=t||{},this.s={l:0,i:32768,w:32768,z:32768},this.b=new n(98304),this.o.dictionary){var i=this.o.dictionary.subarray(-32768);this.b.set(i,32768-i.length),this.s.i=32768-i.length}}return t.prototype.p=function(t,n){this.ondata(J(t,this.o,0,0,this.s),n)},t.prototype.push=function(t,r){this.ondata||I(5),this.s.l&&I(4);var i=t.length+this.s.z;if(i>this.b.length){if(i>2*this.b.length-32768){var e=new n(-32768&i);e.set(this.b.subarray(0,this.s.z)),this.b=e}var o=this.b.length-this.s.z;this.b.set(t.subarray(0,o),this.s.z),this.s.z=this.b.length,this.p(this.b,!1),this.b.set(this.b.subarray(-32768)),this.b.set(t.subarray(o),32768),this.s.z=t.length-o+32768,this.s.i=32766,this.s.w=32768}else this.b.set(t,this.s.z),this.s.z+=t.length;this.s.l=1&r,(this.s.z>this.s.w+8191||r)&&(this.p(this.b,r||!1),this.s.w=this.s.i,this.s.i-=2),r&&(this.s=this.o={},this.b=j)},t.prototype.flush=function(t){if(this.ondata||I(5),this.s.l&&I(4),this.p(this.b,!1),this.s.w=this.s.i,this.s.i-=2,t){var r=new n(6);r[0]=this.s.r>>3;var i=L(r,this.s.r,j);this.s.r=0,this.ondata(r.subarray(0,i>>3),!1)}},t}();_e.Deflate=wt;var xt=function(){return function(t,n){ut([_,function(){return[at,wt]}],this,bt.call(this,t,n),function(t){var n=new wt(t.data);onmessage=at(n)},6,1)}}();function zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_],function(t){return et(kt(t.data[0],t.data[1]))},0,r)}function kt(t,n){return J(t,n||{},0,0)}_e.AsyncDeflate=xt;var Mt=function(){function t(t,r){"function"==typeof t&&(r=t,t={}),this.ondata=r;var i=t&&t.dictionary&&t.dictionary.subarray(-32768);this.s={i:0,b:i?i.length:0},this.o=new n(32768),this.p=new n(0),i&&this.o.set(i)}return t.prototype.e=function(t){if(this.ondata||I(5),this.d&&I(4),this.p.length){if(t.length){var r=new n(this.p.length+t.length);r.set(this.p),r.set(t,this.p.length),this.p=r}}else this.p=t},t.prototype.c=function(t){this.s.i=+(this.d=t||!1);var n=this.s.b,r=U(this.p,this.s,this.o);this.ondata(D(r,n,this.s.b),this.d),this.o=D(r,this.s.b-32768),this.s.b=this.o.length,this.p=D(this.p,this.s.p/8|0),this.s.p&=7},t.prototype.push=function(t,n){this.e(t),this.c(n)},t}();_e.Inflate=Mt;var St=function(){return function(t,n){ut([$,function(){return[at,Mt]}],this,bt.call(this,t,n),function(t){var n=new Mt(t.data);onmessage=at(n)},7,0)}}();function At(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$],function(t){return et(Tt(t.data[0],ot(t.data[1])))},1,r)}function Tt(t,n){return U(t,{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncInflate=St;var Dt=function(){function t(t,n){this.c=V(),this.l=0,this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),this.l+=t.length,wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=J(t,this.o,this.v&&gt(this.o),n&&8,this.s);this.v&&(pt(r,this.o),this.v=0),n&&(lt(r,r.length-8,this.c.d()),lt(r,r.length-4,this.l)),this.ondata(r,n)},t.prototype.flush=function(t){wt.prototype.flush.call(this,t)},t}();_e.Gzip=Dt,_e.Compress=Dt;var Ct=function(){return function(t,n){ut([_,tt,function(){return[at,wt,Dt]}],this,bt.call(this,t,n),function(t){var n=new Dt(t.data);onmessage=at(n)},8,1)}}();function It(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,tt,function(){return[Ut]}],function(t){return et(Ut(t.data[0],t.data[1]))},2,r)}function Ut(t,n){n||(n={});var r=V(),i=t.length;r.p(t);var e=J(t,n,gt(n),8),o=e.length;return pt(e,n),lt(e,o-8,r.d()),lt(e,o-4,i),e}_e.AsyncGzip=Ct,_e.AsyncCompress=Ct;var Ft=function(){function t(t,n){this.v=1,this.r=0,Mt.call(this,t,n)}return t.prototype.push=function(t,r){if(Mt.prototype.e.call(this,t),this.r+=t.length,this.v){var i=this.p.subarray(this.v-1),e=i.length>3?vt(i):4;if(e>i.length){if(!r)return}else this.v>1&&this.onmember&&this.onmember(this.r-i.length);this.p=i.subarray(e),this.v=0}Mt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=T(this.s.p)+9,this.s={i:0},this.o=new n(0),this.push(new n(0),r)):r&&Mt.prototype.c.call(this,r)},t}();_e.Gunzip=Ft;var Et=function(){return function(t,n){var r=this;ut([$,nt,function(){return[at,Mt,Ft]}],this,bt.call(this,t,n),function(t){var n=new Ft(t.data);n.onmember=function(t){return postMessage(t)},onmessage=at(n)},9,0,function(t){return r.onmember&&r.onmember(t)})}}();function Zt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,nt,function(){return[qt]}],function(t){return et(qt(t.data[0],t.data[1]))},3,r)}function qt(t,r){var i=vt(t);return i+8>t.length&&I(6,"invalid gzip data"),U(t.subarray(i,-8),{i:2},r&&r.out||new n(dt(t)),r&&r.dictionary)}_e.AsyncGunzip=Et;var Ot=function(){function t(t,n){this.c=Y(),this.v=1,wt.call(this,t,n)}return t.prototype.push=function(t,n){this.c.p(t),wt.prototype.push.call(this,t,n)},t.prototype.p=function(t,n){var r=J(t,this.o,this.v&&(this.o.dictionary?6:2),n&&4,this.s);this.v&&(yt(r,this.o),this.v=0),n&&lt(r,r.length-4,this.c.d()),this.ondata(r,n)},t.prototype.flush=function(t){wt.prototype.flush.call(this,t)},t}();_e.Zlib=Ot;var Gt=function(){return function(t,n){ut([_,rt,function(){return[at,wt,Ot]}],this,bt.call(this,t,n),function(t){var n=new Ot(t.data);onmessage=at(n)},10,1)}}();function Lt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[_,rt,function(){return[Bt]}],function(t){return et(Bt(t.data[0],t.data[1]))},4,r)}function Bt(t,n){n||(n={});var r=Y();r.p(t);var i=J(t,n,n.dictionary?6:2,4);return yt(i,n),lt(i,i.length-4,r.d()),i}_e.AsyncZlib=Gt;var Ht=function(){function t(t,n){Mt.call(this,t,n),this.v=t&&t.dictionary?2:1}return t.prototype.push=function(t,n){if(Mt.prototype.e.call(this,t),this.v){if(this.p.length<6&&!n)return;this.p=this.p.subarray(mt(this.p,this.v-1)),this.v=0}n&&(this.p.length<4&&I(6,"invalid zlib data"),this.p=this.p.subarray(0,-4)),Mt.prototype.c.call(this,n)},t}();_e.Unzlib=Ht;var jt=function(){return function(t,n){ut([$,it,function(){return[at,Mt,Ht]}],this,bt.call(this,t,n),function(t){var n=new Ht(t.data);onmessage=at(n)},11,0)}}();function Nt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),st(t,n,[$,it,function(){return[Pt]}],function(t){return et(Pt(t.data[0],ot(t.data[1])))},5,r)}function Pt(t,n){return U(t.subarray(mt(t,n&&n.dictionary),-4),{i:2},n&&n.out,n&&n.dictionary)}_e.AsyncUnzlib=jt;var Vt=function(){function t(t,n){this.o=bt.call(this,t,n)||{},this.G=Ft,this.I=Mt,this.Z=Ht}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r){t.ondata(n,r)}},t.prototype.push=function(t,r){if(this.ondata||I(5),this.s)this.s.push(t,r);else{if(this.p&&this.p.length){var i=new n(this.p.length+t.length);i.set(this.p),i.set(t,this.p.length)}else this.p=t;this.p.length>2&&(this.s=31==this.p[0]&&139==this.p[1]&&8==this.p[2]?new this.G(this.o):8!=(15&this.p[0])||this.p[0]>>4>7||(this.p[0]<<8|this.p[1])%31?new this.I(this.o):new this.Z(this.o),this.i(),this.s.push(this.p,r),this.p=null)}},t}();_e.Decompress=Vt;var Yt=function(){function t(t,n){Vt.call(this,t,n),this.queuedSize=0,this.G=Et,this.I=St,this.Z=jt}return t.prototype.i=function(){var t=this;this.s.ondata=function(n,r,i){t.ondata(n,r,i)},this.s.ondrain=function(n){t.queuedSize-=n,t.ondrain&&t.ondrain(n)}},t.prototype.push=function(t,n){this.queuedSize+=t.length,Vt.prototype.push.call(this,t,n)},t}();function Jt(t,n,r){return r||(r=n,n={}),"function"!=typeof r&&I(7),31==t[0]&&139==t[1]&&8==t[2]?Zt(t,n,r):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?At(t,n,r):Nt(t,n,r)}function Kt(t,n){return 31==t[0]&&139==t[1]&&8==t[2]?qt(t,n):8!=(15&t[0])||t[0]>>4>7||(t[0]<<8|t[1])%31?Tt(t,n):Pt(t,n)}_e.AsyncDecompress=Yt;var Qt=function(t,r,i,e){for(var o in t){var s=t[o],a=r+o,u=e;Array.isArray(s)&&(u=K(e,s[1]),s=s[0]),ArrayBuffer.isView(s)?i[a]=[s,u]:(i[a+="/"]=[new n(0),u],Qt(s,a,i,e))}},Rt="undefined"!=typeof TextEncoder&&new TextEncoder,Wt="undefined"!=typeof TextDecoder&&new TextDecoder,Xt=0;try{Wt.decode(j,{stream:!0}),Xt=1}catch(t){}var $t=function(t){for(var n="",r=0;;){var i=t[r++],e=(i>127)+(i>223)+(i>239);if(r+e>t.length)return{s:n,r:D(t,r-1)};e?3==e?(i=((15&i)<<18|(63&t[r++])<<12|(63&t[r++])<<6|63&t[r++])-65536,n+=String.fromCharCode(55296|i>>10,56320|1023&i)):n+=String.fromCharCode(1&e?(31&i)<<6|63&t[r++]:(15&i)<<12|(63&t[r++])<<6|63&t[r++]):n+=String.fromCharCode(i)}},_t=function(){function t(t){this.ondata=t,Xt?this.t=new TextDecoder:this.p=j}return t.prototype.push=function(t,r){if(this.ondata||I(5),r=!!r,this.t)return this.ondata(this.t.decode(t,{stream:!0}),r),void(r&&(this.t.decode().length&&I(8),this.t=null));this.p||I(4);var i=new n(this.p.length+t.length);i.set(this.p),i.set(t,this.p.length);var e=$t(i),o=e.s,s=e.r;r?(s.length&&I(8),this.p=null):this.p=s,this.ondata(o,r)},t}();_e.DecodeUTF8=_t;var tn=function(){function t(t){this.ondata=t}return t.prototype.push=function(t,n){this.ondata||I(5),this.d&&I(4),this.ondata(nn(t),this.d=n||!1)},t}();function nn(t,r){if(r){for(var i=new n(t.length),e=0;e<t.length;++e)i[e]=t.charCodeAt(e);return i}if(Rt)return Rt.encode(t);var o=t.length,s=new n(t.length+(t.length>>1)),a=0,u=function(t){s[a++]=t};for(e=0;e<o;++e){if(a+5>s.length){var h=new n(a+8+(o-e<<1));h.set(s),s=h}var f=t.charCodeAt(e);f<128||r?u(f):f<2048?(u(192|f>>6),u(128|63&f)):f>55295&&f<57344?(u(240|(f=65536+(1047552&f)|1023&t.charCodeAt(++e))>>18),u(128|f>>12&63),u(128|f>>6&63),u(128|63&f)):(u(224|f>>12),u(128|f>>6&63),u(128|63&f))}return D(s,0,a)}function rn(t,n){if(n){for(var r="",i=0;i<t.length;i+=16384)r+=String.fromCharCode.apply(null,t.subarray(i,i+16384));return r}if(Wt)return Wt.decode(t);var e=$t(t),o=e.s;return(r=e.r).length&&I(8),o}_e.EncodeUTF8=tn;var en=function(t){return 1==t?3:t<6?2:9==t?1:0},on=function(t,n){return n+30+ht(t,n+26)+ht(t,n+28)},sn=function(t,n,r){var i=ht(t,n+28),e=ht(t,n+30),o=rn(t.subarray(n+46,n+46+i),!(2048&ht(t,n+8))),s=n+46+i,a=an(t,s,e,r,ft(t,n+20),ft(t,n+24),ft(t,n+42)),u=a[0],h=a[1],f=a[2];return[ht(t,n+10),u,h,o,s+e+ht(t,n+32),f]},an=function(t,n,r,i,e,o,s){var a=4294967295==e,u=4294967295==o,h=4294967295==s,f=n+r;if(i&&a+u+h){for(;n+4<f;n+=4+ht(t,n+2))if(1==ht(t,n))return[a?ct(t,n+4+8*u):e,u?ct(t,n+4):o,h?ct(t,n+4+8*(u+a)):s,1];i<2&&I(13)}return[e,o,s,0]},un=function(t){var n=0;if(t)for(var r in t){var i=t[r].length;i>65535&&I(9),n+=i+4}return n},hn=function(t,n,r,i,e,o,s,a){var u=i.length,h=r.extra,f=a&&a.length,c=un(h);lt(t,n,null!=s?33639248:67324752),n+=4,null!=s&&(t[n++]=20,t[n++]=r.os),t[n]=20,n+=2,t[n++]=r.flag<<1|(o<0&&8),t[n++]=e&&8,t[n++]=255&r.compression,t[n++]=r.compression>>8;var l=new Date(null==r.mtime?Date.now():r.mtime),p=l.getFullYear()-1980;if((p<0||p>119)&&I(10),lt(t,n,p<<25|l.getMonth()+1<<21|l.getDate()<<16|l.getHours()<<11|l.getMinutes()<<5|l.getSeconds()>>1),n+=4,-1!=o&&(lt(t,n,r.crc),lt(t,n+4,o<0?-o-2:o),lt(t,n+8,r.size)),lt(t,n+12,u),lt(t,n+14,c),n+=16,null!=s&&(lt(t,n,f),lt(t,n+6,r.attrs),lt(t,n+10,s),n+=14),t.set(i,n),n+=u,c)for(var v in h){var d=h[v],g=d.length;lt(t,n,+v),lt(t,n+2,g),t.set(d,n+4),n+=4+g}return f&&(t.set(a,n),n+=f),n},fn=function(t,n,r,i,e){lt(t,n,101010256),lt(t,n+8,r),lt(t,n+10,r),lt(t,n+12,i),lt(t,n+16,e)},cn=function(){function t(t){this.filename=t,this.c=V(),this.size=0,this.compression=0}return t.prototype.process=function(t,n){this.ondata(null,t,n)},t.prototype.push=function(t,n){this.ondata||I(5),this.c.p(t),this.size+=t.length,n&&(this.crc=this.c.d()),this.process(t,n||!1)},t}();_e.ZipPassThrough=cn;var ln=function(){function t(t,n){var r=this;n||(n={}),cn.call(this,t),this.d=new wt(n,function(t,n){r.ondata(null,t,n)}),this.compression=8,this.flag=en(n.level)}return t.prototype.process=function(t,n){try{this.d.push(t,n)}catch(t){this.ondata(t,null,n)}},t.prototype.push=function(t,n){cn.prototype.push.call(this,t,n)},t}();_e.ZipDeflate=ln;var pn=function(){function t(t,n){var r=this;n||(n={}),cn.call(this,t),this.d=new xt(n,function(t,n,i){r.ondata(t,n,i)}),this.compression=8,this.flag=en(n.level),this.terminate=this.d.terminate}return t.prototype.process=function(t,n){this.d.push(t,n)},t.prototype.push=function(t,n){cn.prototype.push.call(this,t,n)},t}();_e.AsyncZipDeflate=pn;var vn=function(){function t(t){this.ondata=t,this.u=[],this.d=1}return t.prototype.add=function(t){var r=this;if(this.ondata||I(5),2&this.d)this.ondata(I(4+8*(1&this.d),0,1),null,!1);else{var i=nn(t.filename),e=i.length,o=t.comment,s=o&&nn(o),a=e!=t.filename.length||s&&o.length!=s.length,u=e+un(t.extra)+30;e>65535&&this.ondata(I(11,0,1),null,!1);var h=new n(u);hn(h,0,t,i,a,-1);var f=[h],c=function(){for(var t=0,n=f;t<n.length;t++)r.ondata(null,n[t],!1);f=[]},l=this.d;this.d=0;var p=this.u.length,v=K(t,{f:i,u:a,o:s,t:function(){t.terminate&&t.terminate()},r:function(){if(c(),l){var t=r.u[p+1];t?t.r():r.d=1}l=1}}),d=0;t.ondata=function(i,e,o){if(i)r.ondata(i,e,o),r.terminate();else if(d+=e.length,f.push(e),o){var s=new n(16);lt(s,0,134695760),lt(s,4,t.crc),lt(s,8,d),lt(s,12,t.size),f.push(s),v.c=d,v.b=u+d+16,v.crc=t.crc,v.size=t.size,l&&v.r(),l=1}else l&&c()},this.u.push(v)}},t.prototype.end=function(){var t=this;2&this.d?this.ondata(I(4+8*(1&this.d),0,1),null,!0):(this.d?this.e():this.u.push({r:function(){1&t.d&&(t.u.splice(-1,1),t.e())},t:function(){}}),this.d=3)},t.prototype.e=function(){for(var t=0,r=0,i=0,e=0,o=this.u;e<o.length;e++)i+=46+(h=o[e]).f.length+un(h.extra)+(h.o?h.o.length:0);for(var s=new n(i+22),a=0,u=this.u;a<u.length;a++){var h;hn(s,t,h=u[a],h.f,h.u,-h.c-2,r,h.o),t+=46+h.f.length+un(h.extra)+(h.o?h.o.length:0),r+=h.b}fn(s,t,this.u.length,i,r),this.ondata(null,s,!0),this.d=2},t.prototype.terminate=function(){for(var t=0,n=this.u;t<n.length;t++)n[t].t();this.d=2},t}();function dn(t,r,i){i||(i=r,r={}),"function"!=typeof i&&I(7);var e={};Qt(t,"",e,r);var o=Object.keys(e),s=o.length,a=0,u=0,h=s,f=Array(s),c=[],l=function(){for(var t=0;t<c.length;++t)c[t]()},p=function(t,n){xn(function(){i(t,n)})};xn(function(){p=i});var v=function(){var t=new n(u+22),r=a,i=u-a;u=0;for(var e=0;e<h;++e){var o=f[e];try{var s=o.c.length;hn(t,u,o,o.f,o.u,s);var c=30+o.f.length+un(o.extra),l=u+c;t.set(o.c,l),hn(t,a,o,o.f,o.u,s,u,o.m),a+=16+c+(o.m?o.m.length:0),u=l+s}catch(t){return p(t,null)}}fn(t,a,f.length,i,r),p(null,t)};s||v();for(var d=function(t){var n=o[t],r=e[n],i=r[0],h=r[1],d=V(),g=i.length;d.p(i);var y=nn(n),m=y.length,b=h.comment,w=b&&nn(b),x=w&&w.length,z=un(h.extra),k=0==h.level?0:8,M=function(r,i){if(r)l(),p(r,null);else{var e=i.length;f[t]=K(h,{size:g,crc:d.d(),c:i,f:y,m:w,u:m!=n.length||w&&b.length!=x,compression:k}),a+=30+m+z+e,u+=76+2*(m+z)+(x||0)+e,--s||v()}};if(m>65535&&M(I(11,0,1),null),k)if(g<16e4)try{M(null,kt(i,h))}catch(t){M(t,null)}else c.push(zt(i,h,M));else M(null,i)},g=0;g<h;++g)d(g);return l}function gn(t,r){r||(r={});var i={},e=[];Qt(t,"",i,r);var o=0,s=0;for(var a in i){var u=i[a],h=u[0],f=u[1],c=0==f.level?0:8,l=(M=nn(a)).length,p=f.comment,v=p&&nn(p),d=v&&v.length,g=un(f.extra);l>65535&&I(11);var y=c?kt(h,f):h,m=y.length,b=V();b.p(h),e.push(K(f,{size:h.length,crc:b.d(),c:y,f:M,m:v,u:l!=a.length||v&&p.length!=d,o:o,compression:c})),o+=30+l+g+m,s+=76+2*(l+g)+(d||0)+m}for(var w=new n(s+22),x=o,z=s-o,k=0;k<e.length;++k){var M;hn(w,(M=e[k]).o,M,M.f,M.u,M.c.length);var S=30+M.f.length+un(M.extra);w.set(M.c,M.o+S),hn(w,o,M,M.f,M.u,M.c.length,M.o,M.m),o+=16+S+(M.m?M.m.length:0)}return fn(w,o,e.length,z,x),w}_e.Zip=vn;var yn=function(){function t(){}return t.prototype.push=function(t,n){this.ondata(null,t,n)},t.compression=0,t}();_e.UnzipPassThrough=yn;var mn=function(){function t(){var t=this;this.i=new Mt(function(n,r){t.ondata(null,n,r)})}return t.prototype.push=function(t,n){try{this.i.push(t,n)}catch(t){this.ondata(t,null,n)}},t.compression=8,t}();_e.UnzipInflate=mn;var bn=function(){function t(t,n){var r=this;n<32e4?this.i=new Mt(function(t,n){r.ondata(null,t,n)}):(this.i=new St(function(t,n,i){r.ondata(t,n,i)}),this.terminate=this.i.terminate)}return t.prototype.push=function(t,n){this.i.terminate&&(t=D(t,0)),this.i.push(t,n)},t.compression=8,t}();_e.AsyncUnzipInflate=bn;var wn=function(){function t(t){this.onfile=t,this.k=[],this.o={0:yn},this.p=j}return t.prototype.push=function(t,r){var i=this;if(this.onfile||I(5),this.p||I(4),this.c>0){var e=Math.min(this.c,t.length),o=t.subarray(0,e);if(this.c-=e,this.d?this.d.push(o,!this.c):this.k[0].push(o),(t=t.subarray(e)).length)return this.push(t,r)}else{var s=0,a=0,u=void 0,h=void 0;this.p.length?t.length?((h=new n(this.p.length+t.length)).set(this.p),h.set(t,this.p.length)):h=this.p:h=t;for(var f=h.length,c=this.c,l=c&&this.d,p=function(){var t=ft(h,a);if(67324752==t){s=1,u=a,v.d=null,v.c=0;var n=ht(h,a+6),r=ht(h,a+8),e=2048&n,o=8&n,l=ht(h,a+26),p=ht(h,a+28);if(f>a+30+l+p){var d=[];v.k.unshift(d),s=2;var g,y=ft(h,a+18),m=ft(h,a+22),b=rn(h.subarray(a+30,a+=30+l),!e),w=an(h,a,p,2,y,m,0),x=w[0],z=w[1];o&&(x=-1-w[3]),a+=p,v.c=x;var k={name:b,compression:r,start:function(){if(k.ondata||I(5),x){var t=i.o[r];t||k.ondata(I(14,"unknown compression type "+r,1),null,!1),(g=x<0?new t(b):new t(b,x,z)).ondata=function(t,n,r){k.ondata(t,n,r)};for(var n=0,e=d;n<e.length;n++)g.push(e[n],!1);i.k[0]==d&&i.c?i.d=g:g.push(j,!0)}else k.ondata(null,j,!0)},terminate:function(){g&&g.terminate&&g.terminate()}};x>=0&&(k.size=x,k.originalSize=z),v.onfile(k)}return"break"}if(c){if(134695760==t)return u=a+=12+(-2==c&&8),s=3,v.c=0,"break";if(33639248==t)return u=a-=4,s=3,v.c=0,"break"}},v=this;a<f-4&&"break"!==p();++a);if(this.p=j,c<0){var d=h.subarray(0,s?u-12-(-2==c&&8)-(134695760==ft(h,u-16)&&4):a);l?l.push(d,!!s):this.k[+(2==s)].push(d)}if(2&s)return this.push(h.subarray(a),r);this.p=h.subarray(a)}r&&(this.c&&I(13),this.p=null)},t.prototype.register=function(t){this.o[t.compression]=t},t}();_e.Unzip=wn;var xn="function"==typeof queueMicrotask?queueMicrotask:"function"==typeof setTimeout?setTimeout:function(t){t()};function zn(t,r,i){i||(i=r,r={}),"function"!=typeof i&&I(7);var e=[],o=function(){for(var t=0;t<e.length;++t)e[t]()},s={},a=function(t,n){xn(function(){i(t,n)})};xn(function(){a=i});for(var u=t.length-22;101010256!=ft(t,u);--u)if(!u||t.length-u>65558)return a(I(13,0,1),null),o;var h=ht(t,u+8);if(h){var f=h,c=ft(t,u+16),l=117853008==ft(t,u-20);if(l){var p=ft(t,u-12);(l=101075792==ft(t,p))&&(f=h=ft(t,p+32),c=ft(t,p+48))}for(var v=r&&r.filter,d=function(r){var i=sn(t,c,l),u=i[0],f=i[1],p=i[2],d=i[3],g=i[4],y=on(t,i[5]);c=g;var m=function(t,n){t?(o(),a(t,null)):(n&&(s[d]=n),--h||a(null,s))};if(!v||v({name:d,size:f,originalSize:p,compression:u}))if(u)if(8==u){var b=t.subarray(y,y+f);if(p<524288||f>.8*p)try{m(null,Tt(b,{out:new n(p)}))}catch(t){m(t,null)}else e.push(At(b,{size:p},m))}else m(I(14,"unknown compression type "+u,1),null);else m(null,D(t,y,y+f));else m(null,null)},g=0;g<f;++g)d()}else a(null,{});return o}function kn(t,r){for(var i={},e=t.length-22;101010256!=ft(t,e);--e)(!e||t.length-e>65558)&&I(13);var o=ht(t,e+8);if(!o)return{};var s=ft(t,e+16),a=117853008==ft(t,e-20);if(a){var u=ft(t,e-12);(a=101075792==ft(t,u))&&(o=ft(t,u+32),s=ft(t,u+48))}for(var h=r&&r.filter,f=0;f<o;++f){var c=sn(t,s,a),l=c[0],p=c[1],v=c[2],d=c[3],g=c[4],y=on(t,c[5]);s=g,h&&!h({name:d,size:p,originalSize:v,compression:l})||(l?8==l?i[d]=Tt(t.subarray(y,y+p),{out:new n(v)}):I(14,"unknown compression type "+l):i[d]=D(t,y,y+p))}return i}return _e});
+20 -4
View File
@@ -3,13 +3,29 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Upload Platform</title>
<title>Upload Platform {{ version }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main>
<h1>Upload Platform</h1>
<p>Version: {{ version }}</p>
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. -->
<main class="page">
<!-- Заголовок показывает назначение picker и текущую версию сборки. -->
<header>
<p class="eyebrow">FILE INTAKE / {{ version }}</p>
<h1>Загрузка документов</h1>
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
</header>
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
<section class="toolbar" aria-label="Выбор файлов">
<div id="file-picker"></div>
</section>
</main>
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
<script>
FilePicker.initFilePicker({
mount: '#file-picker',
allowedExt: {{ config.allowedExt|tojson }},
});
</script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
# Upload module
Для встраивания скопируйте один готовый bundle из `dist/`:
- IIFE: `FilePicker.initFilePicker(config)`;
- ESM: `import { initFilePicker } from './file-picker.esm.js'`.
`config` должен содержать `mount` как DOM-элемент или CSS-селектор. Основные
параметры: `allowedExt`, `labels`, `layout`, `limits`, `onChange(files)` и
`onError(error, file)`. `onError` вызывается для ошибок разбора ZIP; обычные
файлы с неподдерживаемым расширением просто пропускаются.
Публичный API picker-а:
- `pickFiles()` и `pickFolder()` — открыть системный выбор;
- `getFiles()` — получить leaf-файлы с `path`, `name`, `size` и `file`;
- `addFiles(files)` — добавить файлы программно;
- `remove(id)` и `clear()` — удалить узел или очистить дерево;
- `render()` и `destroy()` — перерисовать или удалить экземпляр.
Picker поддерживает разрешённые документы, папки и вложенные ZIP, хранит
исходные browser `File` objects и выполняет дедупликацию по пути и размеру.
Загрузка на VM, backend sessions и upload API не используются. ZIP обрабатывается
локально через встроенный в bundle `fflate`; отдельный vendor script не требуется.
## Legacy
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
Подробная таблица функций, входов, выходов и побочных эффектов находится в
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
## Сборка из исходников
Исходники находятся в `frontend/`. В корне проекта:
```bash
npm install
npm run build
```
Результат сборки: `dist/file-picker.esm.js` и `dist/file-picker.iife.js`.
+5
View File
@@ -0,0 +1,5 @@
"""Переиспользуемые слои загрузки файлов.
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
"""
+3
View File
@@ -0,0 +1,3 @@
{
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
}
+190
View File
@@ -0,0 +1,190 @@
import { addFiles, onFilesChange } from './table/on_files_change.js';
import { onFolderChange } from './table/on_folder_change.js';
import { findNode, flattenFiles, render } from './table/render.js';
import { esc } from './table/esc.js';
const DEFAULTS = {
allowedExt: [],
labels: {
pickFiles: 'Выбрать файлы',
pickFolder: 'Выбрать папку',
clear: 'Очистить',
empty: 'Нет выбранных файлов',
statusReady: 'готов',
remove: 'Удалить',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${bytes} B`,
},
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
limits: {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
},
};
function resolveMount(mount) {
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
if (!element) throw new Error('File picker mount element не найден');
return element;
}
function normalizeConfig(config) {
return {
...config,
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
labels: {
...DEFAULTS.labels,
...(config.labels || {}),
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
},
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
};
}
function injectStyles() {
if (document.querySelector('style[data-file-picker]')) return;
const style = document.createElement('style');
style.dataset.filePicker = '';
style.textContent = `
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
.file-picker .fp-count { min-height: 22px; }
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
.file-picker .tree-row td:first-child { white-space: nowrap; }
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
`;
document.head.append(style);
}
function buildMarkup(root, cfg) {
const controls = cfg.layout.controls || [];
const control = (name, id, label, className = '') => controls.includes(name)
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
root.innerHTML = `
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
</div>
<p class="fp-status" role="status"></p>
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
<p class="fp-count"></p>
</div>`;
}
function notify(cfg, state) {
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
}
function removeMetadata(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
return;
}
node.children.forEach((child) => removeMetadata(state, child));
}
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return false;
removeMetadata(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
return true;
}
export function initFilePicker(config) {
const cfg = normalizeConfig(config);
const mount = resolveMount(cfg.mount);
injectStyles();
buildMarkup(mount, cfg);
const root = mount.firstElementChild;
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'),
};
const state = { nodes: [], fileKeys: new Set(), busy: false };
const listeners = [];
const listen = (target, event, handler) => {
target.addEventListener(event, handler);
listeners.push(() => target.removeEventListener(event, handler));
};
const redraw = () => render(state, elements, cfg);
const filesChange = onFilesChange(state, cfg, elements);
const folderChange = onFolderChange(state, cfg, elements);
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
listen(elements.tableBodyEl, '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;
redraw();
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
if (removeNode(state, remove.dataset.remove)) {
redraw();
notify(cfg, state);
}
}
});
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
});
redraw();
return {
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
getFiles: () => [...flattenFiles(state.nodes)],
remove: (id) => {
if (removeNode(state, id)) {
redraw();
notify(cfg, state);
}
},
clear: () => {
state.nodes = [];
state.fileKeys.clear();
redraw();
notify(cfg, state);
},
render: redraw,
destroy: () => {
listeners.forEach((removeListener) => removeListener());
state.nodes = [];
state.fileKeys.clear();
mount.replaceChildren();
},
};
}
export { DEFAULTS };
@@ -0,0 +1,54 @@
/**
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
* Корневой узел файла, папки или ZIP-архива.
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
*
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
* же путём, но другим размером.
*/
export function addFileWithDedup(state, fileNode) {
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
const accept = (node) => {
if (node.kind === 'file') {
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
const key = `${node.path}\u0000${node.file.size}`;
if (state.fileKeys.has(key)) return null;
state.fileKeys.add(key);
return node;
}
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
node.children = node.children.map(accept).filter(Boolean);
return node.children.length ? node : null;
};
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);
});
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
*
* @param {*} value Имя файла, путь или сообщение статуса.
* @returns {string} Строка с заменёнными HTML-значимыми символами.
*
* Экранируются и кавычки, потому что значение может попасть не только в текст
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
*/
export function esc(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character]));
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Преобразует размер файла из байтов в короткую строку для таблицы.
*
* @param {number} bytes Размер в байтах.
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
*/
export function fs(bytes) {
return bytes < 1024 ? `${bytes} B`
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1048576).toFixed(1)} MB`;
}
+72
View File
@@ -0,0 +1,72 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { render } from './render.js';
/**
* Обрабатывает список файлов из обычного file input.
*
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
* @param {FileList|File[]} files Выбранные browser File objects.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
*
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
*/
export async function addFiles(state, cfg, files, elements) {
for (const file of Array.from(files)) {
if (!file.name.toLowerCase().endsWith('.zip')) {
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
continue;
}
// Обычный файл получает имя как путь: у file input нет относительного пути.
addFileWithDedup(state, {
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
file, children: [], expanded: true,
});
continue;
}
try {
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
render(state, elements, cfg);
}
/**
* Создаёт обработчик change для обычного выбора файлов.
*
* @param {object} state Состояние picker, включая флаг busy.
* @param {object} cfg Конфигурация picker.
* @param {object} elements DOM-элементы picker.
* @returns {Function} Async-обработчик для addEventListener('change', ...).
*
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
*/
export function onFilesChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
}
+84
View File
@@ -0,0 +1,84 @@
import { listZipFiles } from '../zip/list_zip_files.js';
import { addFileWithDedup } from './add_file_with_dedup.js';
import { rebaseTree } from './rebase_tree.js';
import { render } from './render.js';
/**
* Создаёт async-обработчик выбора каталога через webkitdirectory.
*
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
* @returns {Function} Async-обработчик события change.
*
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
* что и ZIP из обычного file input.
*/
export function onFolderChange(state, cfg, elements) {
return async () => {
if (state.busy) return;
state.busy = true;
try {
const roots = new Map();
for (const file of Array.from(elements.folderInputEl.files)) {
// Для webkitdirectory путь начинается с выбранной корневой папки.
const parts = (file.webkitRelativePath || file.name).split('/');
const relativePath = parts.slice(1).join('/') || file.name;
const lowerPath = relativePath.toLowerCase();
const rootName = parts[0] || file.name;
if (!roots.has(rootName)) {
// Один root на выбранный каталог позволяет сохранить дерево целиком.
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
path: rootName, children: [], expanded: true });
}
const root = roots.get(rootName);
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
const addToFolder = (node) => {
let current = root;
const nodeParts = node.path.split('/').slice(1);
nodeParts.forEach((part, index) => {
const last = index === nodeParts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
children: [], expanded: true };
current.children.push(child);
}
current = child;
});
};
if (lowerPath.endsWith('.zip')) {
try {
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zip) {
rebaseTree(zip, rootName);
addToFolder(zip);
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
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))) {
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
}
}
// Дедупликация выполняется после сборки каждого корня каталога.
roots.forEach((root) => addFileWithDedup(state, root));
// Сброс value позволяет выбрать тот же каталог повторно.
elements.folderInputEl.value = '';
render(state, elements, cfg);
} finally {
state.busy = false;
}
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Добавляет префикс корневой папки к каждому пути дерева.
*
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
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;
}
+124
View File
@@ -0,0 +1,124 @@
import { esc } from './esc.js';
import { fs } from './fs.js';
const DEFAULT_RENDER_CONFIG = {
labels: {
statusReady: 'готов',
remove: 'Удалить',
empty: 'Нет выбранных файлов',
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
},
layout: { columns: ['path', 'size', 'status'] },
};
function renderConfig(cfg = {}) {
return {
labels: {
...DEFAULT_RENDER_CONFIG.labels,
...(cfg.labels || {}),
columns: {
...DEFAULT_RENDER_CONFIG.labels.columns,
...(cfg.labels?.columns || {}),
},
},
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
};
}
/**
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
*
* @param {{kind: string, id: string, name: string, path: string, file?: File,
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
* @param {number} depth Глубина узла; используется для визуального отступа.
* @returns {string} HTML одной строки или поддерева строк.
*
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
*/
function renderNode(node, depth, cfg) {
const isGroup = node.kind !== 'file';
const padding = depth * 4;
const name = esc(node.name);
const action = isGroup
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
const cells = {
path: action,
size: node.kind === 'file' ? fs(node.file.size) : '',
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
};
const columns = cfg.layout.columns?.length
? cfg.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
+ `<td>${remove}</td></tr>`;
if (!isGroup || !node.expanded) return row;
// Дочерние строки добавляются только для раскрытой группы.
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
}
/**
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
*
* @param {{nodes: Array}} state Состояние дерева.
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
* @returns {void}
*
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
* в количество leaf-файлов и суммарный размер.
*/
export function render(state, elements, cfg) {
const normalized = renderConfig(cfg);
const columns = normalized.layout.columns?.length
? normalized.layout.columns
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
elements.tableBodyEl.innerHTML = state.nodes.length
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
const files = [];
// Собирает все leaf-файлы, включая содержимое свернутых групп.
const visit = (node) => {
if (node.kind === 'file') files.push(node);
else node.children.forEach(visit);
};
state.nodes.forEach(visit);
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
}
/**
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
*
* @param {Array} nodes Массив узлов текущего уровня.
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
*/
export function findNode(nodes, id) {
for (const node of nodes) {
if (node.id === id) return { node, nodes };
const found = findNode(node.children || [], id);
if (found) return found;
}
return null;
}
/**
* Преобразует иерархическое дерево в плоский список leaf-файлов.
*
* @param {Array} nodes Узлы любого уровня.
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
*/
export function flattenFiles(nodes, result = []) {
nodes.forEach((node) => {
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
else flattenFiles(node.children, result);
});
return result;
}
+125
View File
@@ -0,0 +1,125 @@
// Построить дерево ZIP с файлами только разрешённых расширений.
import { unzipSync } from 'fflate';
import { rebaseTree } from '../table/rebase_tree.js';
const DEFAULT_LIMITS = {
maxEntries: 1000,
maxTotalBytes: 100 * 1024 * 1024,
maxEntryBytes: 50 * 1024 * 1024,
maxDepth: 20,
};
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
function extensionAllowed(name, allowedExt) {
const lowerName = name.toLowerCase();
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
function makeFile(data, name) {
return new File([data], name.split('/').at(-1));
}
/**
* Валидирует путь ZIP entry до его использования в дереве.
*
* @param {string} entryName Сырой путь из центрального каталога ZIP.
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
*
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
* Это предотвращает traversal и появление ложных групп в UI.
*/
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
const parts = entryName.split('/');
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
return parts;
}
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
function node(kind, name, path, children = [], file = null) {
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
}
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
function addPath(root, parts, fileNode) {
let current = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
let child = current.children.find((item) => item.name === part);
if (!child) {
child = last
? fileNode
: node('folder', part, `${current.path}/${part}`);
current.children.push(child);
}
current = child;
});
}
/**
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
*
* @param {Uint8Array} data Распаковываемые байты ZIP.
* @param {string} zipName Имя текущего архива для корневого узла и пути.
* @param {string[]} allowedExt Разрешённые расширения документов.
* @param {number} depth Текущая глубина вложенных ZIP.
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
* @throws {Error} При превышении глубины или повреждённом ZIP.
*/
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
const entries = unzipSync(data, {
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');
}
budget.totalBytes += entry.originalSize;
return true;
},
});
const root = node('zip', zipName, zipName);
for (const [entryName, entryData] of Object.entries(entries)) {
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
if (entryName.endsWith('/')) continue;
const parts = safeEntryParts(entryName);
if (!parts) continue;
const normalizedName = parts.join('/');
if (normalizedName.toLowerCase().endsWith('.zip')) {
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
if (nested) {
rebaseTree(nested, zipName);
nested.name = parts.at(-1);
addPath(root, parts, nested);
}
} else if (extensionAllowed(normalizedName, allowedExt)) {
// Запрещённые документы отбрасываются до создания browser File.
const path = `${zipName}/${normalizedName}`;
const file = makeFile(entryData, path);
addPath(root, parts, node('file', parts.at(-1), path, [], file));
}
}
return root.children.length ? root : null;
}
/**
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
*
* @param {File} file Browser File с ZIP-содержимым.
* @param {string[]} allowedExt Разрешённые расширения.
* @param {object} customLimits Ограничения распаковки.
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
*/
export async function listZipFiles(file, allowedExt, customLimits = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const limits = { ...DEFAULT_LIMITS, ...customLimits };
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
}