diff --git a/HISTORY/2026-09-05-file-picker.md b/HISTORY/2026-09-05-file-picker.md new file mode 100644 index 0000000..26b2dca --- /dev/null +++ b/HISTORY/2026-09-05-file-picker.md @@ -0,0 +1,78 @@ +# 2026-09-05 — browser file-picker и иерархическое дерево + +## Исходная задача + +- Требовался браузерный выбор документов в `upload-platform`. +- После уточнения требований сервис должен работать только как file-picker. +- Загрузка файлов на VM, pull-фаза, backend sessions и upload API не требовались. +- Интерфейс должен поддерживать выбор отдельных файлов, папок и ZIP. +- Выбранные данные должны отображаться в виде сворачиваемого иерархического дерева. +- Должна быть возможность удалить отдельный файл или целую группу. +- Последующий сервисный слой должен получать leaf-файлы и их полные пути. +- Разрешены только расширения `.pdf`, `.doc`, `.docx`, `.txt`, `.md`. +- Ограничения по размеру и количеству файлов не добавлялись. +- Должны поддерживаться вложенные структуры: `folder -> ZIP -> subfolder -> ZIP -> file`. + +## Реализовано + +- Удалён 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/`. +- Реализован интерфейс выбора файлов, выбора папки и очистки. +- Добавлена конфигурация разрешённых расширений. +- Реализована рекурсивная модель дерева с типами `file`, `folder`, `zip`. +- Для ZIP реализованы чтение через локальный `fflate`, сохранение полных путей, раскрытие вложенных ZIP и фильтрация расширений. +- Для выбранной папки реализованы чтение `webkitRelativePath`, построение вложенных каталогов, раскрытие ZIP внутри папки и удаление пустых групп. +- Добавлена дедупликация по `path + size`. +- Реализовано рекурсивное удаление дублей и пустых групп. +- В таблице реализованы рендер дерева, сворачивание/разворачивание групп, удаление leaf-файлов и удаление групп вместе со всем поддеревом. +- Счётчики количества файлов и общего размера пересчитываются после изменений. +- Состояние таблицы хранит исходные browser `File` objects и метаданные для последующего сервисного слоя. +- Версия приложения повышена до `0.1.6`. + +## Исправления по ходу работы + +- Первоначальная реализация отправляла файлы на 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. + +## Git + +- `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`. \ No newline at end of file diff --git a/site/app.py b/site/app.py index 3aa18fd..f475b66 100644 --- a/site/app.py +++ b/site/app.py @@ -9,7 +9,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file: CONFIG = json.load(config_file) -VERSION = "0.1.4" +VERSION = "0.1.6" app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/site/templates/index.html b/site/templates/index.html index 231029d..da73452 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -14,8 +14,8 @@

Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.

- - + + diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js index d99f7f4..6d36485 100644 --- a/upload/frontend/table/on_files_change.js +++ b/upload/frontend/table/on_files_change.js @@ -15,12 +15,10 @@ export async function addFiles(state, cfg, files, elements) { continue; } try { - addFileWithDedup(state, await listZipFiles(file, cfg.allowedExt)); + const zipTree = await listZipFiles(file, cfg.allowedExt); + if (zipTree) addFileWithDedup(state, zipTree); } catch (error) { - addFileWithDedup(state, { - id: crypto.randomUUID(), kind: 'zip', name: file.name, path: file.name, - file, children: [], expanded: true, error: 'Не удалось раскрыть ZIP', - }); + continue; } } render(state, elements); diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js index 98cfd48..a4aaa07 100644 --- a/upload/frontend/table/on_folder_change.js +++ b/upload/frontend/table/on_folder_change.js @@ -41,12 +41,12 @@ export function onFolderChange(state, cfg, elements) { if (lowerPath.endsWith('.zip')) { try { const zip = await listZipFiles(file, cfg.allowedExt); - rebaseTree(zip, rootName); - addToFolder(zip); + if (zip) { + rebaseTree(zip, rootName); + addToFolder(zip); + } } catch (error) { - addToFolder({ id: crypto.randomUUID(), kind: 'zip', name: parts.at(-1), - path: relativePath, file, children: [], expanded: true, - error: 'Не удалось раскрыть ZIP' }); + continue; } } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1), diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js index 89a29b0..513266e 100644 --- a/upload/frontend/zip/list_zip_files.js +++ b/upload/frontend/zip/list_zip_files.js @@ -44,16 +44,18 @@ async function listEntries(data, zipName, allowedExt, depth) { if (entryName.endsWith('/')) continue; if (entryName.toLowerCase().endsWith('.zip')) { const nested = await listEntries(entryData, entryName, allowedExt, depth + 1); - rebaseTree(nested, zipName); - nested.name = entryName.split('/').pop(); - addPath(root, entryName.split('/'), nested); + if (nested) { + rebaseTree(nested, zipName); + nested.name = entryName.split('/').pop(); + addPath(root, entryName.split('/'), nested); + } } else if (extensionAllowed(entryName, allowedExt)) { const path = `${zipName}/${entryName}`; const file = makeFile(entryData, path); addPath(root, entryName.split('/'), node('file', entryName.split('/').pop(), path, [], file)); } } - return root; + return root.children.length ? root : null; } export async function listZipFiles(file, allowedExt) {