Fix file picker extension filtering
This commit is contained in:
@@ -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/<path:filename>`.
|
||||||
|
- Реализован интерфейс выбора файлов, выбора папки и очистки.
|
||||||
|
- Добавлена конфигурация разрешённых расширений.
|
||||||
|
- Реализована рекурсивная модель дерева с типами `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`.
|
||||||
+1
-1
@@ -9,7 +9,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
|||||||
CONFIG = json.load(config_file)
|
CONFIG = json.load(config_file)
|
||||||
|
|
||||||
|
|
||||||
VERSION = "0.1.4"
|
VERSION = "0.1.6"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
||||||
</header>
|
</header>
|
||||||
<section class="toolbar" aria-label="Выбор файлов">
|
<section class="toolbar" aria-label="Выбор файлов">
|
||||||
<input id="file-input" type="file" multiple hidden>
|
<input id="file-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" multiple hidden>
|
||||||
<input id="folder-input" type="file" webkitdirectory directory multiple hidden>
|
<input id="folder-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" webkitdirectory directory multiple hidden>
|
||||||
<button id="files-btn" type="button">Выбрать файлы</button>
|
<button id="files-btn" type="button">Выбрать файлы</button>
|
||||||
<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>
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ export async function addFiles(state, cfg, files, elements) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
addFileWithDedup(state, await listZipFiles(file, cfg.allowedExt));
|
const zipTree = await listZipFiles(file, cfg.allowedExt);
|
||||||
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addFileWithDedup(state, {
|
continue;
|
||||||
id: crypto.randomUUID(), kind: 'zip', name: file.name, path: file.name,
|
|
||||||
file, children: [], expanded: true, error: 'Не удалось раскрыть ZIP',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render(state, elements);
|
render(state, elements);
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
if (lowerPath.endsWith('.zip')) {
|
if (lowerPath.endsWith('.zip')) {
|
||||||
try {
|
try {
|
||||||
const zip = await listZipFiles(file, cfg.allowedExt);
|
const zip = await listZipFiles(file, cfg.allowedExt);
|
||||||
rebaseTree(zip, rootName);
|
if (zip) {
|
||||||
addToFolder(zip);
|
rebaseTree(zip, rootName);
|
||||||
|
addToFolder(zip);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToFolder({ id: crypto.randomUUID(), kind: 'zip', name: parts.at(-1),
|
continue;
|
||||||
path: relativePath, file, children: [], expanded: true,
|
|
||||||
error: 'Не удалось раскрыть ZIP' });
|
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||||
|
|||||||
@@ -44,16 +44,18 @@ async function listEntries(data, zipName, allowedExt, depth) {
|
|||||||
if (entryName.endsWith('/')) continue;
|
if (entryName.endsWith('/')) continue;
|
||||||
if (entryName.toLowerCase().endsWith('.zip')) {
|
if (entryName.toLowerCase().endsWith('.zip')) {
|
||||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
||||||
rebaseTree(nested, zipName);
|
if (nested) {
|
||||||
nested.name = entryName.split('/').pop();
|
rebaseTree(nested, zipName);
|
||||||
addPath(root, entryName.split('/'), nested);
|
nested.name = entryName.split('/').pop();
|
||||||
|
addPath(root, entryName.split('/'), nested);
|
||||||
|
}
|
||||||
} else if (extensionAllowed(entryName, allowedExt)) {
|
} else if (extensionAllowed(entryName, allowedExt)) {
|
||||||
const path = `${zipName}/${entryName}`;
|
const path = `${zipName}/${entryName}`;
|
||||||
const file = makeFile(entryData, path);
|
const file = makeFile(entryData, path);
|
||||||
addPath(root, entryName.split('/'), node('file', entryName.split('/').pop(), path, [], file));
|
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) {
|
export async function listZipFiles(file, allowedExt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user