Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea7ba058c | ||
|
|
6586b5d893 | ||
|
|
017bd8c354 | ||
|
|
f6b2f6d1b3 | ||
|
|
a0bfd9f0e3 | ||
|
|
3d227f1daa | ||
|
|
aec8ad4fbe | ||
|
|
3bd8ee2b23 | ||
|
|
e4b4dca53a | ||
|
|
13ea0efcd7 | ||
|
|
edf1de5aa2 | ||
|
|
5a0f82b75e | ||
|
|
23c1c86968 | ||
|
|
8df4d22cfb | ||
|
|
5d44c836e7 | ||
|
|
1e305485be | ||
|
|
eda5263d8b | ||
|
|
317526b246 | ||
|
|
8f775e34a9 | ||
|
|
6fea5bce49 | ||
|
|
a965f181d9 | ||
|
|
85605fe8c6 | ||
|
|
c0b87880ae | ||
|
|
82d9ca9136 | ||
|
|
39953c7c59 |
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
!dist/
|
||||||
|
!dist/file-picker.esm.js
|
||||||
|
!dist/file-picker.iife.js
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Flask / WSGI
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
.flaskenv
|
||||||
|
.flask_session/
|
||||||
|
|
||||||
|
# Byte-compiled / cache
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local frontend tooling
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs / temp
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
@@ -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.
|
||||||
@@ -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__` и любых каталогов не выполнялось.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Вопросы и ответы по ревью Gemini 3.8 Flash — 2026-09-05
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
После критического разбора ревью Gemini 3.8 Flash были заданы уточняющие
|
||||||
|
вопросы по версии исходников, дублированию API, семантике `File.name`, legacy
|
||||||
|
XSS, `build.mjs`, ZIP-фильтрации и приоритетам исправлений.
|
||||||
|
|
||||||
|
## Зафиксированные ответы
|
||||||
|
|
||||||
|
### Версия кода и номера строк
|
||||||
|
|
||||||
|
Ревью выполнялось по исходникам `upload-platform`; `dist/` отдельно не
|
||||||
|
исследовался. Номера строк в первой сводке были смещены из-за объединения
|
||||||
|
отчётов субагента. Фактические участки находятся в текущих исходниках
|
||||||
|
`on_files_change.js` и `list_zip_files.js`.
|
||||||
|
|
||||||
|
### `init_upload_table.js`
|
||||||
|
|
||||||
|
Буквального совпадения интерфейса с `initFilePicker` нет: один entry point
|
||||||
|
принимает готовые DOM-узлы, другой создаёт DOM внутри `mount`. Однако внутренняя
|
||||||
|
логика жизненного цикла таблицы параллельна: состояние `nodes/fileKeys/busy`,
|
||||||
|
удаление узлов, делегирование кликов и похожий API. Файл не входит в граф сборки
|
||||||
|
`build.mjs`, поэтому это legacy-альтернатива, создающая риск рассинхронизации.
|
||||||
|
|
||||||
|
### `File.name` и логический путь
|
||||||
|
|
||||||
|
`flattenFiles()` возвращает `name`, `path` и `file` раздельно, но при `rebaseTree`
|
||||||
|
вложенный browser `File` получает полный путь в `file.name`. При прямом
|
||||||
|
`FormData.append('files', file)` сервер получает filename с разделителями пути.
|
||||||
|
Это может конфликтовать с серверными `basename`/`secure_filename` и нарушает
|
||||||
|
обычный контракт browser `File`, где `name` является базовым именем. При явной
|
||||||
|
передаче третьего аргумента `FormData.append('files', file, path)` проблема не
|
||||||
|
возникает.
|
||||||
|
|
||||||
|
### XSS в `set_status.js`
|
||||||
|
|
||||||
|
Замечание относится только к legacy-файлу. `set_status.js` не входит в активный
|
||||||
|
граф сборки и не используется боевым рендерером. В активном `render.js` значения
|
||||||
|
статуса экранируются через `esc()`. Реальная уязвимость в текущем активном
|
||||||
|
рендеринге не подтверждена; пункт относится к гигиене мёртвого кода.
|
||||||
|
|
||||||
|
### `build.mjs`
|
||||||
|
|
||||||
|
Для top-level `await` необработанная ошибка сборки сама приводит Node.js к
|
||||||
|
завершению с ненулевым кодом. Явные `try/catch` и `process.exit(1)` технически
|
||||||
|
избыточны. Реальное возможное улучшение этого пункта — определить поведение
|
||||||
|
каталога `dist/` перед сборкой, например его предварительную очистку.
|
||||||
|
|
||||||
|
### Проверка фильтра `fflate`
|
||||||
|
|
||||||
|
Проверка исходника `fflate` подтвердила, что `filter` вызывается после чтения
|
||||||
|
метаданных ZIP entry, но до копирования или декомпрессии данных. Если фильтр
|
||||||
|
возвращает `false`, `inflateSync` для этой записи не вызывается и буфер для
|
||||||
|
распакованных данных не создаётся. Поэтому добавление проверки расширения в
|
||||||
|
`filter` является корректным способом не распаковывать тяжёлые неразрешённые
|
||||||
|
entries.
|
||||||
|
|
||||||
|
### Приоритет до прода
|
||||||
|
|
||||||
|
Согласован обязательный приоритет:
|
||||||
|
|
||||||
|
1. **№2:** отбрасывать неразрешённые расширения внутри ZIP `filter` до
|
||||||
|
декомпрессии, чтобы снизить риск OOM и лишнего расхода бюджета.
|
||||||
|
2. **№1:** сбрасывать `elements.fileInputEl.value`, чтобы повторный выбор того
|
||||||
|
же файла после удаления снова генерировал `change`.
|
||||||
|
3. **№5:** объединять корневые папки с одинаковыми путями, чтобы избежать
|
||||||
|
дублирующихся деревьев при последовательном выборе.
|
||||||
|
|
||||||
|
Пункты `File.name`, `onError`, fallback-конфигурация, legacy-код и очистка
|
||||||
|
служебных каталогов отнесены ко второй очереди.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Уточнение подтвердило, что главным техническим finding является ZIP-фильтрация
|
||||||
|
до декомпрессии. Пункты про `set_status.js` и явный `process.exit(1)` не являются
|
||||||
|
активными production-дефектами. Исправления кода в рамках этой записи не
|
||||||
|
выполнялись.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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-а не изменялся.
|
||||||
@@ -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`, сохранение имён и размеров, массовую загрузку и
|
||||||
|
отображение документов на русском, английском и смешанном наборе имён.
|
||||||
@@ -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`).
|
||||||
@@ -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 минуты.
|
||||||
|
- Фиксация коммитом и пуш.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# 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** проект:
|
||||||
|
> те же 2 слоя, но вынесены в отдельный репозиторий и демонстрируются на минимальной обёртке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Цель и границы
|
||||||
|
|
||||||
|
**Что делаем:** отдельный переиспользуемый сервис загрузки файлов через ВМ-буфер.
|
||||||
|
Паттерн: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` (egress pull) → сессия.
|
||||||
|
|
||||||
|
**Что входит (переиспользуемое):**
|
||||||
|
- Слой 1 — выбор файлов (фронт): таблица/дерево, дедуп, раскрытие ZIP, рекурсивный обход папки.
|
||||||
|
- Слой 2 — закачка через ВМ (фронт + бэк): PUT, pull, сессия, лимиты, валидации.
|
||||||
|
|
||||||
|
**Что НЕ входит (у каждого приложения своё):**
|
||||||
|
- Слой 3 — логика приложения (обфускация/обработка/SSE-прогресс). Здесь — только **стаб-заглушка**,
|
||||||
|
которая показывает, что файлы доехали до сессии (список + размеры), без реальной обработки.
|
||||||
|
|
||||||
|
**Жёсткие требования:**
|
||||||
|
1. Vanilla JS, без React/сборщика. Единственная новая зависимость — `fflate` (CDN) для ZIP.
|
||||||
|
2. Каждая функция — отдельный файл (мелкие файлы).
|
||||||
|
3. Всё, что отличает один проект от другого — **в конфиге**, не в коде слоёв.
|
||||||
|
4. Безопасность сохранить 1:1: SSRF-валидация URL, `_safe_name` (path traversal), ретраи pull,
|
||||||
|
лимиты, `esc()` имён (self-XSS).
|
||||||
|
5. Запуск на Nubes: структура `site/app.py`, `python site/app.py`, `0.0.0.0:5000`,
|
||||||
|
**маршрут `/health` → 200** (иначе liveness-проба платформы убивает под), `debug=False`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Архитектура (слои)
|
||||||
|
|
||||||
|
| Слой | Где | Ответственность |
|
||||||
|
|---|---|---|
|
||||||
|
| **0. Конфиг** | общий | `vmUploadUrl`, `allowedExt`, лимиты, ретраи |
|
||||||
|
| **1. Выбор файлов** | фронт | таблица/дерево, дедуп, раскрытие ZIP, папка, путь, статусы, кнопки |
|
||||||
|
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия |
|
||||||
|
| **3. Логика приложения** | бэк | **стаб** (в реальном проекте своя — сюда приходит SSE/обработка) |
|
||||||
|
|
||||||
|
**Слой 3 здесь — заглушка.** Он НЕ переиспользуется. Его задача — доказать, что слой 2
|
||||||
|
корректно передаёт файлы в сессию и что в новом проекте к нему легко прицепить свою логику.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Структура проекта (обязательная)
|
||||||
|
|
||||||
|
```
|
||||||
|
upload-platform/
|
||||||
|
├── requirements.txt
|
||||||
|
├── README.md # что это + как интегрировать слои в другой проект
|
||||||
|
├── PLAN.md # этот план
|
||||||
|
├── config.json # слой 0 (рабочий конфиг ЭТОГО демо)
|
||||||
|
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
|
||||||
|
│ ├── README.md # короткая инструкция интеграции (для агента)
|
||||||
|
│ ├── config.example.json # образец конфига
|
||||||
|
│ ├── frontend/
|
||||||
|
│ │ ├── zip/
|
||||||
|
│ │ │ └── list_zip_files.js # listZipFiles(file, allowedExt) на fflate
|
||||||
|
│ │ ├── table/ # слой 1
|
||||||
|
│ │ │ ├── fs.js
|
||||||
|
│ │ │ ├── esc.js
|
||||||
|
│ │ │ ├── add_file_with_dedup.js
|
||||||
|
│ │ │ ├── render.js
|
||||||
|
│ │ │ ├── on_files_change.js
|
||||||
|
│ │ │ ├── on_folder_change.js
|
||||||
|
│ │ └── upload/ # слой 2 (фронт)
|
||||||
|
│ │ ├── put_to_vm.js
|
||||||
|
│ │ └── upload_via_vm.js
|
||||||
|
│ ├── state.py
|
||||||
|
│ ├── create_session.py
|
||||||
|
│ ├── add_file.py
|
||||||
|
│ ├── get_files.py
|
||||||
|
│ └── cleanup.py
|
||||||
|
└── site/ # демо-обёртка (НЕ переиспользуется)
|
||||||
|
├── app.py # точка входа, / и /health
|
||||||
|
├── routes/
|
||||||
|
│ └── api_bp.py # регистрирует blueprint из upload/ + стаб слоя 3
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # слой 1 + 2 (подключает ES-модули из upload/frontend)
|
||||||
|
└── static/
|
||||||
|
├── style.css
|
||||||
|
└── vendor/fflate.min.js # fflate (локально, не CDN — надёжнее)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⛔ `site/__init__.py` **НЕ создавать** — конфликтует со stdlib `site.py` (Nubes HowTo).
|
||||||
|
> ⛔ Импорты бэка — всегда без префикса `site.` (запуск `python site/app.py` добавляет `site/` в path).
|
||||||
|
> ⛔ Без factory pattern — платформа ждёт `app` на уровне модуля.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Слой 0 — конфиг (`config.json` / `config.example.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
|
||||||
|
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||||
|
"maxFileBytes": 52428800,
|
||||||
|
"maxSessionBytes": 524288000,
|
||||||
|
"apiPrefix": "/api",
|
||||||
|
"pullRetries": 3,
|
||||||
|
"pullRetryDelay": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждое поле:
|
||||||
|
- `vmUploadUrl` — префикс ВМ-буфера (для PUT и SSRF-валидации pull).
|
||||||
|
- `allowedExt` — допустимые расширения (фильтр и в ZIP, и в папке).
|
||||||
|
- `maxFileBytes` / `maxSessionBytes` — лимиты.
|
||||||
|
- `apiPrefix` — префикс маршрутов бэка.
|
||||||
|
- `pullRetries` / `pullRetryDelay` — ретраи pull (защита от разовых DNS/сетевых сбоев).
|
||||||
|
|
||||||
|
В бэке конфиг читается из `os.getenv` (на Nubes — через `jsonEnv`), значения по умолчанию —
|
||||||
|
из `config.example.json`. Никаких drhider-специфичных значений в коде слоёв.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Слой 1 — выбор файлов (фронт)
|
||||||
|
|
||||||
|
### Поведение (1:1 с drhider)
|
||||||
|
- Выбор файлов (`<input type="file" multiple>`).
|
||||||
|
- Выбор папки (`webkitdirectory`) → рекурсивный обход, в таблице виден относительный путь.
|
||||||
|
- `.zip` → раскрытие через `fflate.unzip` **с `filter` по `allowedExt`** (не распаковывать
|
||||||
|
не-doc), рекурсивно по вложенным `.zip`; путь в таблице — `Папка/архив.zip/Внутри/файл.txt`.
|
||||||
|
- Дедуп по «путь+размер»; имя совпало, размер другой → суффикс `_2`.
|
||||||
|
- Лимиты: файл > `maxFileBytes` → «не учитывается»; сумма учитываемых > `maxSessionBytes` → тоже.
|
||||||
|
- Имена экранируются через `esc()` (self-XSS).
|
||||||
|
- Кнопка загрузки disabled при 0 учитываемых файлов; UI замораживается во время загрузки.
|
||||||
|
|
||||||
|
### Интерфейс `initUploadTable(cfg) -> api`
|
||||||
|
|
||||||
|
```
|
||||||
|
cfg = {
|
||||||
|
allowedExt, maxFileBytes, maxSessionBytes,
|
||||||
|
fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
|
||||||
|
onFilesChanged(list)
|
||||||
|
}
|
||||||
|
api = {
|
||||||
|
pickFiles(), pickFolder(),
|
||||||
|
addFiles(File[]), // дедуп + раскрытие zip + фильтр + лимиты
|
||||||
|
getFiles() -> [{path,name,size,file}], // ТОЛЬКО учитываемые
|
||||||
|
getOverNames() -> Set,
|
||||||
|
setStatus(path, html),
|
||||||
|
render(), clear()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Состояние (`sf`, `fileMeta`, `overNames`) — внутри `init_upload_table.js`, наружу не торчит.
|
||||||
|
|
||||||
|
### Файлы и их функции
|
||||||
|
| Файл | Функция | Примечание |
|
||||||
|
|---|---|---|
|
||||||
|
| `zip/list_zip_files.js` | `listZipFiles(file, allowedExt)` | `fflate.unzip` + filter + рекурсия |
|
||||||
|
| `table/fs.js` | `fs(b)` | формат размера |
|
||||||
|
| `table/esc.js` | `esc(s)` | экранирование HTML |
|
||||||
|
| `table/add_file_with_dedup.js` | `addFileWithDedup(file)` | дедуп + лимиты |
|
||||||
|
| `table/render.js` | `render()` | обычная таблица |
|
||||||
|
| `table/set_status.js` | `setStatus(path, html)` | статус в ячейке |
|
||||||
|
| `table/on_files_change.js` | обработчик file input | |
|
||||||
|
| `table/on_folder_change.js` | обработчик webkitdirectory | |
|
||||||
|
| `table/init_upload_table.js` | `initUploadTable(cfg)` | сборка слоя 1 |
|
||||||
|
|
||||||
|
> Дерево (сворачивание по папкам/архивам) — **фаза 2**, см. `PLAN-file-picker.md` (в drhider).
|
||||||
|
> В первой итерации — плоская таблица с колонкой «путь», как сейчас в drhider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Слой 2 — закачка через ВМ
|
||||||
|
|
||||||
|
### Фронт: `uploadViaVM(files, vmUploadUrl) -> Promise<{ok, session, count, error?}>`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. token = crypto.randomUUID()
|
||||||
|
2. для каждого файла: PUT vmUploadUrl + token + '_' + k (XHR, progress -> setStatus)
|
||||||
|
refs.push({name, size, url})
|
||||||
|
3. POST /api/upload_refs {session, files: refs}
|
||||||
|
-> {ok, session, count} | {ok:false, error}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `put_to_vm.js` — `putToVm(file, url, onProgress)` (XHR, сырое тело, прогресс в ячейку).
|
||||||
|
- `upload_via_vm.js` — `uploadViaVM(files, vmUploadUrl)`.
|
||||||
|
- SSE-часть (слой 3) сюда НЕ входит — возвращаем `{ok, session, count}`, дальше приложение само.
|
||||||
|
|
||||||
|
### Бэк: `create_upload_refs_blueprint(cfg) -> Blueprint`
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/upload_refs {session?, files:[{name,size,url}]}
|
||||||
|
- name = _safe_name(name) (path traversal)
|
||||||
|
- url.startswith(vmUploadUrl) (SSRF)
|
||||||
|
- size > maxFileBytes -> delete+skip
|
||||||
|
- pull: pullRetries × (пауза pullRetryDelay), stream, лимит maxFileBytes
|
||||||
|
- len(content) > maxFileBytes -> delete+skip
|
||||||
|
- add_file: нет сессии -> 404; лимит сессии -> skip
|
||||||
|
- delete url (best-effort)
|
||||||
|
-> {ok, session, count}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `upload_refs/config.py` — ретраи/префикс из конфига.
|
||||||
|
- `upload_refs/safe_name.py` — `safe_name(name)` (path traversal, сохраняет подпапки).
|
||||||
|
- `upload_refs/pull_file.py` — `pull_file(client, url)` (ретраи, stream, лимит).
|
||||||
|
- `upload_refs/blueprint.py` — `create_upload_refs_blueprint(cfg)`.
|
||||||
|
|
||||||
|
### Сессия (`backend/session/`)
|
||||||
|
Самодостаточный модуль, дробится на файл-на-функцию. Общее состояние (`_sessions`, `_lock`) — в
|
||||||
|
`state.py`; операции импортируют `state`.
|
||||||
|
|
||||||
|
- `create_session.py` — `create_session()`
|
||||||
|
- `add_file.py` — `add_file(sid, name, content)` → bool (лимит)
|
||||||
|
- `get_files.py` — `get_files(sid)`, `file_count(sid)`
|
||||||
|
- `cleanup.py` — `cleanup(sid)`
|
||||||
|
- (при необходимости позже: `store_result`, `store_csv`, `ttl`, `cancel`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Слой 3 — стаб (демо-обёртка `site/`)
|
||||||
|
|
||||||
|
- `site/app.py` — `app = Flask(...)`, `@app.route("/")` → `index.html`, `@app.route("/health")` → `"ok", 200`,
|
||||||
|
`if __name__ == "__main__": app.run(debug=False, host="0.0.0.0", port=5000)`.
|
||||||
|
- `site/routes/api_bp.py` — регистрирует `create_upload_refs_blueprint(cfg)`, добавляет демо-маршрут
|
||||||
|
`POST /api/process` → возвращает список файлов сессии (имя + размер) — **доказательство**, что
|
||||||
|
слой 2 довёз файлы; в реальном проекте здесь будет своя обработка.
|
||||||
|
- `site/templates/index.html` — `<script type="module">` подключает `initUploadTable` + `uploadViaVM`,
|
||||||
|
по завершении закачки вызывает `/api/process` и показывает результат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Порядок реализации (по шагам, после каждого — проверка)
|
||||||
|
|
||||||
|
1. **`requirements.txt`** + `site/app.py` с `/` и `/health`, `debug=False`. Проверить локально.
|
||||||
|
2. **`backend/session/*`** — перенести 1:1 из drhider `session.py`, разбить на файлы.
|
||||||
|
3. **`backend/upload_refs/*`** — `safe_name`, `pull_file`, `config`, `blueprint`.
|
||||||
|
4. **`frontend/zip/list_zip_files.js`** — `fflate` (локально `static/vendor/fflate.min.js`),
|
||||||
|
`filter` по `allowedExt`, рекурсия по вложенным zip. Юнит: кириллица, вложенный zip, не-doc.
|
||||||
|
5. **`frontend/table/*`** — слой 1, состояние внутрь `init_upload_table.js`.
|
||||||
|
6. **`frontend/upload/*`** — `putToVm` + `uploadViaVM`.
|
||||||
|
7. **`site/templates/index.html`** — собрать слои 1+2, подключить стаб слоя 3.
|
||||||
|
8. **`upload/README.md` + `README.md`** — инструкция интеграции для агента нового проекта.
|
||||||
|
9. **Деплой на Nubes** — убедиться, что `/`, `/health`, закачка и `/api/process` работают.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Чек-лист приёмки (без этого не готово)
|
||||||
|
|
||||||
|
- [ ] `/health` → 200; `/` → 200 с версией; `debug=False`.
|
||||||
|
- [ ] дедуп путь+размер, суффикс `_2` при другом размере;
|
||||||
|
- [ ] рекурсивный обход папки, путь (в т.ч. из архива) в таблице;
|
||||||
|
- [ ] раскрытие ZIP только документов, вложенные ZIP;
|
||||||
|
- [ ] SSRF-валидация URL, `safe_name`, ретраи pull — на месте;
|
||||||
|
- [ ] лимиты файл/сессия, статусы «пропущен (лимит)» и «не извлечён»;
|
||||||
|
- [ ] `esc()` имён (нет XSS);
|
||||||
|
- [ ] кнопка загрузки disabled при 0 файлов; заморозка UI во время загрузки; прогресс PUT;
|
||||||
|
- [ ] после закачки `/api/process` возвращает список файлов сессии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Что НЕ делать
|
||||||
|
|
||||||
|
- React / сборщик / jQuery-деревья (jsTree, Fancytree).
|
||||||
|
- `site/__init__.py`, `from site.xxx import ...`, factory pattern.
|
||||||
|
- CDN для `fflate` (класть локально в `static/vendor/`).
|
||||||
|
- Реальную обработку файлов (слой 3) — только стаб.
|
||||||
|
- Менять drhider под этот проект — модуль копируется, не связывается.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Upload Platform
|
||||||
|
|
||||||
|
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы,
|
||||||
|
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
|
||||||
|
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
|
||||||
|
загружает его на VM, в Flask или в другое хранилище.
|
||||||
|
|
||||||
|
## Карта файлов
|
||||||
|
|
||||||
|
### Корень проекта
|
||||||
|
|
||||||
|
| Путь | Назначение | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| `package.json` | Версия пакета и команда `npm run build`. | Используется |
|
||||||
|
| `package-lock.json` | Зафиксированные npm-зависимости. | Используется |
|
||||||
|
| `build.mjs` | esbuild-сборка ESM и IIFE bundle. | Используется |
|
||||||
|
| `requirements.txt` | Python-зависимости demo-сервера. | Используется demo |
|
||||||
|
| `config.json` | Конфигурация demo: разрешённые расширения. | Используется demo |
|
||||||
|
| `dist/file-picker.esm.js` | Готовый ESM bundle для интеграции. | Используется |
|
||||||
|
| `dist/file-picker.iife.js` | Готовый IIFE bundle `FilePicker`. | Используется |
|
||||||
|
| `upload/` | Исходники переиспользуемого picker-модуля. | Используется |
|
||||||
|
| `site/` | Flask demo, который раздаёт страницу и bundle. | Используется только demo |
|
||||||
|
| `docs/` | Справочная и историческая документация. | См. раздел LEGACY |
|
||||||
|
| `HISTORY/` | Архив решений, ревью и результатов проверок. | Только история |
|
||||||
|
|
||||||
|
### `upload/` — исходники picker-а
|
||||||
|
|
||||||
|
| Путь | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `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-обёртка
|
||||||
|
|
||||||
|
| Путь | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `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`).
|
||||||
|
- `app.run(host="0.0.0.0", port=5000, debug=False)`.
|
||||||
|
- Маршрут `/health` → `200` (иначе liveness-проба платформы убивает под).
|
||||||
|
- Без `site/__init__.py` и factory pattern.
|
||||||
|
|
||||||
|
## Как интегрировать picker в другой проект
|
||||||
|
|
||||||
|
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()`.
|
||||||
|
|
||||||
|
Исходники `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/).
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||||
|
}
|
||||||
Vendored
+995
@@ -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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
})[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
|
||||||
|
};
|
||||||
Vendored
+1019
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Generated
+480
@@ -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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html", version=VERSION)
|
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию."""
|
||||||
|
return render_template("index.html", version=VERSION, config=CONFIG)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
|
"""Возвращает 200 для liveness-проверки платформы."""
|
||||||
return "ok", 200
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=False, host="0.0.0.0", port=5000)
|
app.run(debug=False, host="0.0.0.0", port=5000)
|
||||||
+49
-2
@@ -1,8 +1,55 @@
|
|||||||
|
/* Базовые цвета и типографика всего demo-picker. */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: sans-serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
color: #17211b;
|
||||||
|
background: #e9eee8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Фон страницы намеренно отделён от карточной логики: picker не требует backend UI. */
|
||||||
body {
|
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%; }
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -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&<(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&>(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&<(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});
|
||||||
@@ -3,13 +3,29 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. -->
|
||||||
<h1>Upload Platform</h1>
|
<main class="page">
|
||||||
<p>Version: {{ version }}</p>
|
<!-- Заголовок показывает назначение 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>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Переиспользуемые слои загрузки файлов.
|
||||||
|
|
||||||
|
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||||
|
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}[character]));
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user