Document picker code and architecture
This commit is contained in:
@@ -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.
|
||||||
@@ -67,3 +67,6 @@ ZIP-файлы используются как контейнеры и раск
|
|||||||
совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их
|
совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их
|
||||||
не импортирует и не требует Flask API для обработки файлов.
|
не импортирует и не требует Flask API для обработки файлов.
|
||||||
Переиспользуемая инструкция находится в `upload/README.md`.
|
Переиспользуемая инструкция находится в `upload/README.md`.
|
||||||
|
|
||||||
|
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
|
||||||
|
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Справочник кода Upload Platform
|
||||||
|
|
||||||
|
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
|
||||||
|
и API загрузки являются legacy и в текущем коде не используются.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```text
|
||||||
|
site/app.py
|
||||||
|
-> site/templates/index.html
|
||||||
|
-> upload/frontend/table/init_upload_table.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 отдаёт страницу и ES-модули. Все выбранные файлы остаются в памяти браузера;
|
||||||
|
сервер не принимает содержимое файлов.
|
||||||
|
|
||||||
|
## Модель данных
|
||||||
|
|
||||||
|
Каждый узел дерева имеет поля:
|
||||||
|
|
||||||
|
| Поле | Тип | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Уникальный идентификатор для DOM-кнопок и поиска. |
|
||||||
|
| `kind` | `file`, `folder`, `zip` | Тип узла. `file` является leaf. |
|
||||||
|
| `name` | `string` | Имя отображения в дереве. |
|
||||||
|
| `path` | `string` | Полный логический путь внутри выбора/архива. |
|
||||||
|
| `file` | `File \| null` | Исходный или созданный из ZIP browser File для leaf. |
|
||||||
|
| `children` | `Array` | Дочерние узлы группы. |
|
||||||
|
| `expanded` | `boolean` | Видимость дочерних строк в таблице. |
|
||||||
|
|
||||||
|
Внутреннее состояние `initUploadTable`:
|
||||||
|
|
||||||
|
| Поле | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `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 с новым именем. |
|
||||||
|
|
||||||
|
Модуль является единственной реализацией 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 пропускается. `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)` | Узел и глубина | HTML | Рекурсивно создаёт строки дерева. Внутренняя функция. |
|
||||||
|
| `render(state, elements)` | state и DOM | `void` | Перерисовывает таблицу и счётчик. |
|
||||||
|
| `findNode(nodes, id)` | Массив узлов и id | `{node, nodes}` или `null` | Ищет узел и его родительский массив. |
|
||||||
|
| `flattenFiles(nodes, result)` | Дерево и аккумулятор | Массив leaf-файлов | Преобразует дерево для API приложения. |
|
||||||
|
|
||||||
|
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
||||||
|
дочерние строки только при `expanded === true`.
|
||||||
|
|
||||||
|
### `init_upload_table.js`
|
||||||
|
|
||||||
|
| Функция | Вход | Выход | Назначение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `removeFileMeta(state, node)` | state и удаляемое поддерево | `void` | Рекурсивно удаляет dedup-ключи. Внутренняя функция. |
|
||||||
|
| `removeNode(state, id)` | state и id | `void` | Удаляет узел из родительского массива. Внутренняя функция. |
|
||||||
|
| `initUploadTable(cfg)` | `allowedExt` и четыре DOM-элемента | API-объект | Создаёт state, события, render и публичные операции. |
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
|
||||||
|
| Метод | Вход | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `pickFiles()` | нет | Открывает обычный file input. |
|
||||||
|
| `pickFolder()` | нет | Открывает folder input. |
|
||||||
|
| `addFiles(files)` | FileList/Array | Добавляет файлы с фильтрацией и ZIP-разбором. |
|
||||||
|
| `getFiles()` | нет | `{path, name, size, file}[]` только для leaf. |
|
||||||
|
| `remove(id)` | id узла | Удаляет узел и его dedup-ключи. |
|
||||||
|
| `render()` | нет | Перерисовывает текущий state. |
|
||||||
|
| `clear()` | нет | Очищает дерево и dedup Set. |
|
||||||
|
|
||||||
|
## `upload/frontend/zip`
|
||||||
|
|
||||||
|
### `list_zip_files.js`
|
||||||
|
|
||||||
|
| Функция | Вход | Выход | Назначение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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)` | browser File и расширения | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
|
||||||
|
|
||||||
|
Безопасность: отклоняются абсолютные пути, backslash, пустые сегменты, `.` и
|
||||||
|
`..`; глубина nested ZIP ограничена 20. ZIP без разрешённых leaf-файлов возвращает
|
||||||
|
`null` и не создаёт пустую группу.
|
||||||
|
|
||||||
|
## 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 и относительный путь | ES/CSS-файл из `upload/frontend` |
|
||||||
|
|
||||||
|
### `site/templates/index.html`
|
||||||
|
|
||||||
|
Шаблон создаёт два скрытых input: обычный file input и `webkitdirectory`,
|
||||||
|
передаёт четыре DOM-элемента в `initUploadTable`, подключает локальный `fflate`
|
||||||
|
до ES-модуля и связывает три кнопки с `pickFiles`, `pickFolder` и `clear`.
|
||||||
|
|
||||||
|
## Legacy
|
||||||
|
|
||||||
|
`upload/frontend/table/set_status.js` не импортируется актуальной страницей и
|
||||||
|
сохранён как legacy-заготовка. Vendor `site/static/vendor/fflate.min.js` не
|
||||||
|
документируется построчно: это внешняя библиотека, используемая как готовый
|
||||||
|
runtime dependency.
|
||||||
@@ -5,27 +5,32 @@ from flask import Flask, render_template, send_from_directory
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
||||||
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
||||||
CONFIG = json.load(config_file)
|
CONFIG = json.load(config_file)
|
||||||
|
|
||||||
|
|
||||||
VERSION = "0.1.9"
|
VERSION = "0.1.9"
|
||||||
|
|
||||||
|
# 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():
|
||||||
|
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию."""
|
||||||
return render_template("index.html", version=VERSION, config=CONFIG)
|
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>")
|
@app.get("/upload-frontend/<path:filename>")
|
||||||
def upload_frontend(filename):
|
def upload_frontend(filename):
|
||||||
|
"""Отдаёт один frontend-файл из изолированного каталога upload/frontend."""
|
||||||
return send_from_directory(ROOT / "upload" / "frontend", filename)
|
return send_from_directory(ROOT / "upload" / "frontend", filename)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* Базовые цвета и типографика всего demo-picker. */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
@@ -5,22 +6,29 @@
|
|||||||
background: #e9eee8;
|
background: #e9eee8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Фон страницы намеренно отделён от карточной логики: picker не требует backend UI. */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: radial-gradient(circle at 10% 0%, #f8fbf4, transparent 38%), #e9eee8;
|
background: radial-gradient(circle at 10% 0%, #f8fbf4, transparent 38%), #e9eee8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ограничивает ширину рабочей области и сохраняет адаптивные боковые поля. */
|
||||||
.page { max-width: 980px; margin: 0 auto; padding: 64px 24px; }
|
.page { max-width: 980px; margin: 0 auto; padding: 64px 24px; }
|
||||||
|
/* Служебный идентификатор версии приложения. */
|
||||||
.eyebrow { color: #56745f; font: 700 12px/1.2 sans-serif; letter-spacing: 2px; }
|
.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; }
|
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; }
|
.lede { max-width: 560px; color: #5d6a61; font: 16px/1.6 sans-serif; }
|
||||||
|
/* Группа действий: выбор файлов, папки и очистка состояния. */
|
||||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 36px 0 16px; }
|
.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 { 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.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||||
button.quiet { color: #5d6a61; background: transparent; }
|
button.quiet { color: #5d6a61; background: transparent; }
|
||||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
/* Резервная область сообщения интегратора сохраняет высоту и не двигает таблицу. */
|
||||||
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
|
.status { min-height: 22px; color: #56745f; font: 13px sans-serif; }
|
||||||
|
/* Горизонтальный scroll на узких экранах не ломает таблицу дерева. */
|
||||||
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
.table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||||
table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
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, td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||||
@@ -31,14 +39,17 @@ td:nth-child(2) { width: 140px; color: #68766c; }
|
|||||||
.tree-folder { color: #1f5a3b; }
|
.tree-folder { color: #1f5a3b; }
|
||||||
.tree-zip { color: #8a5a20; }
|
.tree-zip { color: #8a5a20; }
|
||||||
.tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
.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 { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||||
.remove-btn:hover { color: #b32f22; }
|
.remove-btn:hover { color: #b32f22; }
|
||||||
.tree-row td:first-child { white-space: nowrap; }
|
.tree-row td:first-child { white-space: nowrap; }
|
||||||
.count { color: #5d6a61; font: 13px sans-serif; }
|
.count { color: #5d6a61; font: 13px sans-serif; }
|
||||||
|
/* Legacy-стили результата сохранены для совместимости, текущий picker их не использует. */
|
||||||
.result { margin-top: 44px; padding-top: 18px; border-top: 2px solid #1f5a3b; }
|
.result { margin-top: 44px; padding-top: 18px; border-top: 2px solid #1f5a3b; }
|
||||||
.result h2 { font-size: 26px; font-weight: 400; }
|
.result h2 { font-size: 26px; font-weight: 400; }
|
||||||
.result li { margin: 8px 0; font: 14px sans-serif; }
|
.result li { margin: 8px 0; font: 14px sans-serif; }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
/* На мобильном экране кнопки получают равномерную ширину. */
|
||||||
.page { padding: 36px 16px; }
|
.page { padding: 36px 16px; }
|
||||||
.toolbar button { flex: 1 1 42%; }
|
.toolbar button { flex: 1 1 42%; }
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,15 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. -->
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
<!-- Заголовок показывает назначение picker и текущую версию сборки. -->
|
||||||
<header>
|
<header>
|
||||||
<p class="eyebrow">FILE INTAKE / {{ version }}</p>
|
<p class="eyebrow">FILE INTAKE / {{ version }}</p>
|
||||||
<h1>Загрузка документов</h1>
|
<h1>Загрузка документов</h1>
|
||||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
||||||
</header>
|
</header>
|
||||||
|
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
|
||||||
<section class="toolbar" aria-label="Выбор файлов">
|
<section class="toolbar" aria-label="Выбор файлов">
|
||||||
<input id="file-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" multiple hidden>
|
<input id="file-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" multiple hidden>
|
||||||
<input id="folder-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" webkitdirectory directory multiple hidden>
|
<input id="folder-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" webkitdirectory directory multiple hidden>
|
||||||
@@ -20,17 +23,22 @@
|
|||||||
<button id="folder-btn" type="button" class="secondary">Выбрать папку</button>
|
<button id="folder-btn" type="button" class="secondary">Выбрать папку</button>
|
||||||
<button id="clear-btn" type="button" class="quiet">Очистить</button>
|
<button id="clear-btn" type="button" class="quiet">Очистить</button>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Сюда могут выводиться сообщения интегратора; таблица использует отдельный render(). -->
|
||||||
<p id="status" class="status" role="status"></p>
|
<p id="status" class="status" role="status"></p>
|
||||||
|
<!-- tableBodyEl является обязательным DOM-контрактом initUploadTable. -->
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Путь</th><th>Размер</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>Путь</th><th>Размер</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody id="table-body"></tbody>
|
<tbody id="table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- countEl обновляется render() и включает leaf-файлы даже в свернутых группах. -->
|
||||||
<p id="count" class="count"></p>
|
<p id="count" class="count"></p>
|
||||||
</main>
|
</main>
|
||||||
|
<!-- fflate должен быть загружен до ES-модулей, потому что ZIP-парсер использует global fflate. -->
|
||||||
<script src="{{ url_for('static', filename='vendor/fflate.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/fflate.min.js') }}"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// Конфигурация приходит из Flask; DOM-ссылки передаются в переиспользуемый picker.
|
||||||
import { initUploadTable } from "/upload-frontend/table/init_upload_table.js";
|
import { initUploadTable } from "/upload-frontend/table/init_upload_table.js";
|
||||||
|
|
||||||
const cfg = {{ config|tojson }};
|
const cfg = {{ config|tojson }};
|
||||||
@@ -41,7 +49,9 @@
|
|||||||
tableBodyEl: document.querySelector('#table-body'),
|
tableBodyEl: document.querySelector('#table-body'),
|
||||||
countEl: document.querySelector('#count'),
|
countEl: document.querySelector('#count'),
|
||||||
});
|
});
|
||||||
|
// status зарезервирован для интегратора; picker сейчас обновляет только таблицу и count.
|
||||||
const status = document.querySelector('#status');
|
const status = document.querySelector('#status');
|
||||||
|
// Кнопки не содержат собственной логики выбора: весь state остаётся внутри table API.
|
||||||
document.querySelector('#files-btn').onclick = () => table.pickFiles();
|
document.querySelector('#files-btn').onclick = () => table.pickFiles();
|
||||||
document.querySelector('#folder-btn').onclick = () => table.pickFolder();
|
document.querySelector('#folder-btn').onclick = () => table.pickFolder();
|
||||||
document.querySelector('#clear-btn').onclick = () => table.clear();
|
document.querySelector('#clear-btn').onclick = () => table.clear();
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ Picker поддерживает разрешённые документы, па
|
|||||||
интеграции они не нужны.
|
интеграции они не нужны.
|
||||||
|
|
||||||
Для ZIP перед ES-модулями загрузите локальный `fflate` из `site/static/vendor/`.
|
Для ZIP перед ES-модулями загрузите локальный `fflate` из `site/static/vendor/`.
|
||||||
|
|
||||||
|
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||||
|
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
"""Переиспользуемые слои загрузки файлов."""
|
"""Переиспользуемые слои загрузки файлов.
|
||||||
|
|
||||||
|
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||||
|
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||||
|
"""
|
||||||
@@ -1,15 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Добавляет узел файла или группы в состояние 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) {
|
export function addFileWithDedup(state, fileNode) {
|
||||||
|
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
|
||||||
const accept = (node) => {
|
const accept = (node) => {
|
||||||
if (node.kind === 'file') {
|
if (node.kind === 'file') {
|
||||||
|
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
|
||||||
const key = `${node.path}\u0000${node.file.size}`;
|
const key = `${node.path}\u0000${node.file.size}`;
|
||||||
if (state.fileKeys.has(key)) return null;
|
if (state.fileKeys.has(key)) return null;
|
||||||
state.fileKeys.add(key);
|
state.fileKeys.add(key);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
|
||||||
node.children = node.children.map(accept).filter(Boolean);
|
node.children = node.children.map(accept).filter(Boolean);
|
||||||
return node.children.length ? node : null;
|
return node.children.length ? node : null;
|
||||||
};
|
};
|
||||||
const accepted = accept(fileNode);
|
const accepted = accept(fileNode);
|
||||||
|
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
||||||
state.nodes.push(accepted);
|
state.nodes.push(accepted);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||||
|
*
|
||||||
|
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||||
|
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||||
|
*
|
||||||
|
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||||
|
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||||
|
*/
|
||||||
export function esc(value) {
|
export function esc(value) {
|
||||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||||
'&': '&',
|
'&': '&',
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||||
|
*
|
||||||
|
* @param {number} bytes Размер в байтах.
|
||||||
|
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
|
||||||
|
*/
|
||||||
export function fs(bytes) {
|
export function fs(bytes) {
|
||||||
return bytes < 1024 ? `${bytes} B`
|
return bytes < 1024 ? `${bytes} B`
|
||||||
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { addFiles, onFilesChange } from './on_files_change.js';
|
|||||||
import { onFolderChange } from './on_folder_change.js';
|
import { onFolderChange } from './on_folder_change.js';
|
||||||
import { findNode, flattenFiles, render } from './render.js';
|
import { findNode, flattenFiles, render } from './render.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет ключи дедупликации для leaf-файлов внутри удаляемого поддерева.
|
||||||
|
*
|
||||||
|
* @param {{fileKeys: Set<string>}} state Состояние дедупликации.
|
||||||
|
* @param {{kind: string, path: string, file?: File, children?: Array}} node Удаляемый узел.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function removeFileMeta(state, node) {
|
function removeFileMeta(state, node) {
|
||||||
if (node.kind === 'file') {
|
if (node.kind === 'file') {
|
||||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
||||||
@@ -10,6 +17,13 @@ function removeFileMeta(state, node) {
|
|||||||
node.children.forEach((child) => removeFileMeta(state, child));
|
node.children.forEach((child) => removeFileMeta(state, child));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет узел из дерева и синхронно освобождает его dedup-ключи.
|
||||||
|
*
|
||||||
|
* @param {{nodes: Array, fileKeys: Set<string>}} state Состояние таблицы.
|
||||||
|
* @param {string} id Идентификатор узла.
|
||||||
|
* @returns {void} Ничего не делает, если id не найден.
|
||||||
|
*/
|
||||||
function removeNode(state, id) {
|
function removeNode(state, id) {
|
||||||
const found = findNode(state.nodes, id);
|
const found = findNode(state.nodes, id);
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
@@ -17,17 +31,34 @@ function removeNode(state, id) {
|
|||||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт picker и возвращает его публичный API.
|
||||||
|
*
|
||||||
|
* @param {{allowedExt: string[], fileInputEl: HTMLInputElement,
|
||||||
|
* folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} cfg
|
||||||
|
* Конфигурация и обязательные DOM-элементы интегратора.
|
||||||
|
* @returns {{pickFiles: Function, pickFolder: Function, addFiles: Function,
|
||||||
|
* getFiles: Function, remove: Function, render: Function, clear: Function}}
|
||||||
|
* Управляющий API без прямого доступа к внутреннему state.
|
||||||
|
*
|
||||||
|
* Функция регистрирует DOM-события один раз, хранит дерево и dedup Set внутри
|
||||||
|
* замыкания и сразу рисует пустое состояние. Внешнее приложение получает только
|
||||||
|
* операции выбора, добавления, чтения и удаления.
|
||||||
|
*/
|
||||||
export function initUploadTable(cfg) {
|
export function initUploadTable(cfg) {
|
||||||
|
// Нормализованный набор DOM-ссылок передаётся во все функции рендера.
|
||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: cfg.fileInputEl,
|
fileInputEl: cfg.fileInputEl,
|
||||||
folderInputEl: cfg.folderInputEl,
|
folderInputEl: cfg.folderInputEl,
|
||||||
tableBodyEl: cfg.tableBodyEl,
|
tableBodyEl: cfg.tableBodyEl,
|
||||||
countEl: cfg.countEl,
|
countEl: cfg.countEl,
|
||||||
};
|
};
|
||||||
|
// nodes — дерево; fileKeys — ключи path+NUL+size; busy защищает async change handlers.
|
||||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
||||||
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
|
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
|
||||||
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
|
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
|
||||||
elements.tableBodyEl.addEventListener('click', (event) => {
|
elements.tableBodyEl.addEventListener('click', (event) => {
|
||||||
|
// Делегирование событий позволяет обслуживать динамически созданные кнопки.
|
||||||
const toggle = event.target.closest('[data-toggle]');
|
const toggle = event.target.closest('[data-toggle]');
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||||
@@ -43,9 +74,12 @@ export function initUploadTable(cfg) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
|
// Открывают системные диалоги, не обходя браузерные ограничения File API.
|
||||||
pickFiles: () => elements.fileInputEl.click(),
|
pickFiles: () => elements.fileInputEl.click(),
|
||||||
pickFolder: () => elements.folderInputEl.click(),
|
pickFolder: () => elements.folderInputEl.click(),
|
||||||
|
// Программное добавление использует тот же фильтр и ZIP-парсер, что и input.
|
||||||
addFiles: (files) => addFiles(state, cfg, files, elements),
|
addFiles: (files) => addFiles(state, cfg, files, elements),
|
||||||
|
// Возвращает только leaf-файлы; группы и внутренний state наружу не выдаются.
|
||||||
getFiles: () => flattenFiles(state.nodes),
|
getFiles: () => flattenFiles(state.nodes),
|
||||||
remove: (id) => {
|
remove: (id) => {
|
||||||
removeNode(state, id);
|
removeNode(state, id);
|
||||||
@@ -53,6 +87,7 @@ export function initUploadTable(cfg) {
|
|||||||
},
|
},
|
||||||
render: () => render(state, elements),
|
render: () => render(state, elements),
|
||||||
clear: () => {
|
clear: () => {
|
||||||
|
// Очистка сбрасывает и дерево, и dedup Set, чтобы повторный выбор был возможен.
|
||||||
state.nodes = [];
|
state.nodes = [];
|
||||||
state.fileKeys.clear();
|
state.fileKeys.clear();
|
||||||
render(state, elements);
|
render(state, elements);
|
||||||
|
|||||||
@@ -2,12 +2,26 @@ import { listZipFiles } from '../zip/list_zip_files.js';
|
|||||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||||
import { render } from './render.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) {
|
export async function addFiles(state, cfg, files, elements) {
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
|
||||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Обычный файл получает имя как путь: у file input нет относительного пути.
|
||||||
addFileWithDedup(state, {
|
addFileWithDedup(state, {
|
||||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||||
file, children: [], expanded: true,
|
file, children: [], expanded: true,
|
||||||
@@ -15,15 +29,29 @@ export async function addFiles(state, cfg, files, elements) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||||
const zipTree = await listZipFiles(file, cfg.allowedExt);
|
const zipTree = await listZipFiles(file, cfg.allowedExt);
|
||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||||
render(state, elements);
|
render(state, elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт обработчик 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) {
|
export function onFilesChange(state, cfg, elements) {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
@@ -31,6 +59,7 @@ export function onFilesChange(state, cfg, elements) {
|
|||||||
try {
|
try {
|
||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ import { addFileWithDedup } from './add_file_with_dedup.js';
|
|||||||
import { rebaseTree } from './rebase_tree.js';
|
import { rebaseTree } from './rebase_tree.js';
|
||||||
import { render } from './render.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) {
|
export function onFolderChange(state, cfg, elements) {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
@@ -10,15 +23,18 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
try {
|
try {
|
||||||
const roots = new Map();
|
const roots = new Map();
|
||||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||||
|
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||||
const relativePath = parts.slice(1).join('/') || file.name;
|
const relativePath = parts.slice(1).join('/') || file.name;
|
||||||
const lowerPath = relativePath.toLowerCase();
|
const lowerPath = relativePath.toLowerCase();
|
||||||
const rootName = parts[0] || file.name;
|
const rootName = parts[0] || file.name;
|
||||||
if (!roots.has(rootName)) {
|
if (!roots.has(rootName)) {
|
||||||
|
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||||
path: rootName, children: [], expanded: true });
|
path: rootName, children: [], expanded: true });
|
||||||
}
|
}
|
||||||
const root = roots.get(rootName);
|
const root = roots.get(rootName);
|
||||||
|
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||||
const addToFolder = (node) => {
|
const addToFolder = (node) => {
|
||||||
let current = root;
|
let current = root;
|
||||||
const nodeParts = node.path.split('/').slice(1);
|
const nodeParts = node.path.split('/').slice(1);
|
||||||
@@ -36,12 +52,14 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
};
|
};
|
||||||
if (lowerPath.endsWith('.zip')) {
|
if (lowerPath.endsWith('.zip')) {
|
||||||
try {
|
try {
|
||||||
|
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||||
const zip = await listZipFiles(file, cfg.allowedExt);
|
const zip = await listZipFiles(file, cfg.allowedExt);
|
||||||
if (zip) {
|
if (zip) {
|
||||||
rebaseTree(zip, rootName);
|
rebaseTree(zip, rootName);
|
||||||
addToFolder(zip);
|
addToFolder(zip);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
@@ -49,7 +67,9 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||||
roots.forEach((root) => addFileWithDedup(state, root));
|
roots.forEach((root) => addFileWithDedup(state, root));
|
||||||
|
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||||
elements.folderInputEl.value = '';
|
elements.folderInputEl.value = '';
|
||||||
render(state, elements);
|
render(state, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Добавляет префикс корневой папки к каждому пути дерева.
|
||||||
|
*
|
||||||
|
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
|
||||||
|
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||||
|
* @returns {object} Тот же узел после изменения путей.
|
||||||
|
*
|
||||||
|
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
|
||||||
|
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
|
||||||
|
*/
|
||||||
export function rebaseTree(root, prefix) {
|
export function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
root.path = `${prefix}/${root.path}`;
|
||||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { esc } from './esc.js';
|
import { esc } from './esc.js';
|
||||||
import { fs } from './fs.js';
|
import { fs } from './fs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит один узел дерева и его раскрытых потомков в 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) {
|
function renderNode(node, depth) {
|
||||||
const isGroup = node.kind !== 'file';
|
const isGroup = node.kind !== 'file';
|
||||||
const padding = depth * 4;
|
const padding = depth * 4;
|
||||||
@@ -15,14 +26,26 @@ function renderNode(node, depth) {
|
|||||||
+ `<td>${node.kind === 'file' ? fs(node.file.size) : ''}</td>`
|
+ `<td>${node.kind === 'file' ? fs(node.file.size) : ''}</td>`
|
||||||
+ `<td>${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}</td><td>${remove}</td></tr>`;
|
+ `<td>${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}</td><td>${remove}</td></tr>`;
|
||||||
if (!isGroup || !node.expanded) return row;
|
if (!isGroup || !node.expanded) return row;
|
||||||
|
// Дочерние строки добавляются только для раскрытой группы.
|
||||||
return row + node.children.map((child) => renderNode(child, depth + 1)).join('');
|
return row + node.children.map((child) => renderNode(child, depth + 1)).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||||||
|
*
|
||||||
|
* @param {{nodes: Array}} state Состояние дерева.
|
||||||
|
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||||
|
* в количество leaf-файлов и суммарный размер.
|
||||||
|
*/
|
||||||
export function render(state, elements) {
|
export function render(state, elements) {
|
||||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||||
? state.nodes.map((node) => renderNode(node, 0)).join('')
|
? state.nodes.map((node) => renderNode(node, 0)).join('')
|
||||||
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
|
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||||
const files = [];
|
const files = [];
|
||||||
|
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||||
const visit = (node) => {
|
const visit = (node) => {
|
||||||
if (node.kind === 'file') files.push(node);
|
if (node.kind === 'file') files.push(node);
|
||||||
else node.children.forEach(visit);
|
else node.children.forEach(visit);
|
||||||
@@ -31,6 +54,13 @@ export function render(state, elements) {
|
|||||||
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
|
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||||||
|
*
|
||||||
|
* @param {Array} nodes Массив узлов текущего уровня.
|
||||||
|
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||||||
|
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||||||
|
*/
|
||||||
export function findNode(nodes, id) {
|
export function findNode(nodes, id) {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.id === id) return { node, nodes };
|
if (node.id === id) return { node, nodes };
|
||||||
@@ -40,6 +70,13 @@ export function findNode(nodes, id) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||||||
|
*
|
||||||
|
* @param {Array} nodes Узлы любого уровня.
|
||||||
|
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||||
|
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||||
|
*/
|
||||||
export function flattenFiles(nodes, result = []) {
|
export function flattenFiles(nodes, result = []) {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { flattenFiles } from './render.js';
|
import { flattenFiles } from './render.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy helper: заменяет HTML статуса у найденного leaf-файла.
|
||||||
|
*
|
||||||
|
* @param {string} path Полный логический путь файла в текущем дереве.
|
||||||
|
* @param {string} html Готовая HTML-строка статуса; вызывающая сторона отвечает
|
||||||
|
* за её безопасность.
|
||||||
|
* @param {{nodes: Array}} state Состояние дерева, из которого ищется файл.
|
||||||
|
* @param {{tableBodyEl: HTMLElement}} elements DOM-элементы старого renderer.
|
||||||
|
* @returns {void} Ничего не возвращает; при отсутствии файла или ячейки ничего
|
||||||
|
* не изменяет.
|
||||||
|
*/
|
||||||
export function setStatus(path, html, state, elements) {
|
export function setStatus(path, html, state, elements) {
|
||||||
const file = flattenFiles(state.nodes).find((item) => item.path === path);
|
const file = flattenFiles(state.nodes).find((item) => item.path === path);
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
// Построить дерево ZIP с файлами только разрешённых расширений.
|
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||||||
import { rebaseTree } from '../table/rebase_tree.js';
|
import { rebaseTree } from '../table/rebase_tree.js';
|
||||||
|
|
||||||
|
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
|
||||||
function extensionAllowed(name, allowedExt) {
|
function extensionAllowed(name, allowedExt) {
|
||||||
const lowerName = name.toLowerCase();
|
const lowerName = name.toLowerCase();
|
||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует путь ZIP entry до его использования в дереве.
|
||||||
|
*
|
||||||
|
* @param {string} entryName Сырой путь из центрального каталога ZIP.
|
||||||
|
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
|
||||||
|
*
|
||||||
|
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
|
||||||
|
* Это предотвращает traversal и появление ложных групп в UI.
|
||||||
|
*/
|
||||||
function safeEntryParts(entryName) {
|
function safeEntryParts(entryName) {
|
||||||
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
|
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
|
||||||
const parts = entryName.split('/');
|
const parts = entryName.split('/');
|
||||||
@@ -17,10 +28,12 @@ function safeEntryParts(entryName) {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Создаёт единый узел file, folder или zip с уникальным id и раскрытым состоянием. */
|
||||||
function node(kind, name, path, children = [], file = null) {
|
function node(kind, name, path, children = [], file = null) {
|
||||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
||||||
function addPath(root, parts, fileNode) {
|
function addPath(root, parts, fileNode) {
|
||||||
let current = root;
|
let current = root;
|
||||||
parts.forEach((part, index) => {
|
parts.forEach((part, index) => {
|
||||||
@@ -36,17 +49,30 @@ function addPath(root, parts, fileNode) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рекурсивно читает байтовый массив 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) {
|
async function listEntries(data, zipName, allowedExt, depth) {
|
||||||
|
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
|
||||||
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
|
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
|
||||||
const entries = fflate.unzipSync(data);
|
const entries = fflate.unzipSync(data);
|
||||||
const root = node('zip', zipName, zipName);
|
const root = node('zip', zipName, zipName);
|
||||||
|
|
||||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||||
|
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
|
||||||
if (entryName.endsWith('/')) continue;
|
if (entryName.endsWith('/')) continue;
|
||||||
const parts = safeEntryParts(entryName);
|
const parts = safeEntryParts(entryName);
|
||||||
if (!parts) continue;
|
if (!parts) continue;
|
||||||
const normalizedName = parts.join('/');
|
const normalizedName = parts.join('/');
|
||||||
if (normalizedName.toLowerCase().endsWith('.zip')) {
|
if (normalizedName.toLowerCase().endsWith('.zip')) {
|
||||||
|
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
|
||||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
||||||
if (nested) {
|
if (nested) {
|
||||||
rebaseTree(nested, zipName);
|
rebaseTree(nested, zipName);
|
||||||
@@ -54,6 +80,7 @@ async function listEntries(data, zipName, allowedExt, depth) {
|
|||||||
addPath(root, parts, nested);
|
addPath(root, parts, nested);
|
||||||
}
|
}
|
||||||
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
||||||
|
// Запрещённые документы отбрасываются до создания browser File.
|
||||||
const path = `${zipName}/${normalizedName}`;
|
const path = `${zipName}/${normalizedName}`;
|
||||||
const file = makeFile(entryData, path);
|
const file = makeFile(entryData, path);
|
||||||
addPath(root, parts, node('file', parts.at(-1), path, [], file));
|
addPath(root, parts, node('file', parts.at(-1), path, [], file));
|
||||||
@@ -62,6 +89,13 @@ async function listEntries(data, zipName, allowedExt, depth) {
|
|||||||
return root.children.length ? root : null;
|
return root.children.length ? root : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
|
||||||
|
*
|
||||||
|
* @param {File} file Browser File с ZIP-содержимым.
|
||||||
|
* @param {string[]} allowedExt Разрешённые расширения.
|
||||||
|
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
|
||||||
|
*/
|
||||||
export async function listZipFiles(file, allowedExt) {
|
export async function listZipFiles(file, allowedExt) {
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
return listEntries(data, file.name, allowedExt, 0);
|
return listEntries(data, file.name, allowedExt, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user