From 3d227f1daa31c13f127afbfd6b2d4ae98276542f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 5 Sep 2026 14:42:50 +0300 Subject: [PATCH] feat: make file picker embeddable --- .gitignore | 3 + README.md | 17 +- build.mjs | 22 + dist/file-picker.esm.js | 964 ++++++++++++++++++++ dist/file-picker.iife.js | 988 +++++++++++++++++++++ docs/CODE-REFERENCE.md | 39 +- docs/PLAN-componentization.md | 202 +++++ package-lock.json | 480 ++++++++++ package.json | 15 + site/app.py | 8 +- site/templates/index.html | 41 +- upload/README.md | 18 +- upload/frontend/index.js | 189 ++++ upload/frontend/table/init_upload_table.js | 6 +- upload/frontend/table/on_files_change.js | 5 +- upload/frontend/table/on_folder_change.js | 5 +- upload/frontend/table/render.js | 58 +- upload/frontend/zip/list_zip_files.js | 33 +- 18 files changed, 3005 insertions(+), 88 deletions(-) create mode 100644 build.mjs create mode 100644 dist/file-picker.esm.js create mode 100644 dist/file-picker.iife.js create mode 100644 docs/PLAN-componentization.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 upload/frontend/index.js diff --git a/.gitignore b/.gitignore index f8ab551..2803fba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ __pycache__/ build/ develop-eggs/ dist/ +!dist/ +!dist/file-picker.esm.js +!dist/file-picker.iife.js downloads/ eggs/ .eggs/ diff --git a/README.md b/README.md index aadeeb4..e028cdd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ backend. | Слой | Ответственность | |---|---| -| Конфиг | `allowedExt` | +| Конфиг | `allowedExt`, `labels`, `layout`, `limits` | | Picker | таблица, дедупликация, ZIP, рекурсивный обход папки | | Интегрирующее приложение | получает leaf-файлы через `getFiles()` | @@ -50,16 +50,17 @@ python site/app.py ## Как интегрировать picker в другой проект -1. Скопировать папку `upload/` в свой проект. -2. Задать конфиг по образцу `upload/config.example.json`. -3. Подключить локальный `fflate` до picker-модуля. -4. Вызвать `initUploadTable({ ...cfg, fileInputEl, folderInputEl, tableBodyEl, countEl })`. -5. Получить выбранные leaf-файлы через `table.getFiles()`; каждый элемент содержит +1. Скопировать `upload/frontend/` и один из собранных файлов `dist/` в свой проект. +2. Подключить `file-picker.iife.js` через ` - + \ No newline at end of file diff --git a/upload/README.md b/upload/README.md index 4309b1e..c4bb9b2 100644 --- a/upload/README.md +++ b/upload/README.md @@ -1,23 +1,25 @@ # Upload module -Скопируйте каталог `upload/` в проект и подключите picker: +Скопируйте `upload/frontend/` и подключите собранный picker: -- `initUploadTable(config)` из `frontend/table/init_upload_table.js`; -- локальный `fflate` до загрузки picker-модуля. +- IIFE: `FilePicker.initFilePicker(config)`; +- ESM: `import { initFilePicker } from './file-picker.esm.js'`. -`config` должен содержать `allowedExt`, а также ссылки на DOM-элементы -`fileInputEl`, `folderInputEl`, `tableBodyEl` и `countEl`. +`config` должен содержать `mount` как DOM-элемент или CSS-селектор. Основные +параметры: `allowedExt`, `labels`, `layout`, `limits` и `onChange`. Публичный API таблицы: - `pickFiles()` и `pickFolder()` — открыть системный выбор; - `getFiles()` — получить leaf-файлы с `path`, `name`, `size` и `file`; - `addFiles(files)` — добавить файлы программно; -- `remove(id)` и `clear()` — удалить узел или очистить дерево. +- `remove(id)` и `clear()` — удалить узел или очистить дерево; +- `render()` и `destroy()` — перерисовать или удалить экземпляр. Picker поддерживает разрешённые документы, папки и вложенные ZIP, хранит исходные browser `File` objects и выполняет дедупликацию по пути и размеру. -Загрузка на VM, backend sessions и upload API не используются. +Загрузка на VM, backend sessions и upload API не используются. ZIP обрабатывается +локально через встроенный в bundle `fflate`; отдельный vendor script не требуется. ## Legacy @@ -25,7 +27,5 @@ Picker поддерживает разрешённые документы, па предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only интеграции они не нужны. -Для ZIP перед ES-модулями загрузите локальный `fflate` из `site/static/vendor/`. - Подробная таблица функций, входов, выходов и побочных эффектов находится в [`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md). \ No newline at end of file diff --git a/upload/frontend/index.js b/upload/frontend/index.js new file mode 100644 index 0000000..cd210be --- /dev/null +++ b/upload/frontend/index.js @@ -0,0 +1,189 @@ +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) + ? `` : ''; + const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns); + const headings = columns.map((column) => `${esc(cfg.labels.columns[column] || column)}`).join(''); + root.innerHTML = ` +
+
+ + + ${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')} +
+

+
${headings}
+

+
`; +} + +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'), + 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 }; diff --git a/upload/frontend/table/init_upload_table.js b/upload/frontend/table/init_upload_table.js index f99df08..d44560d 100644 --- a/upload/frontend/table/init_upload_table.js +++ b/upload/frontend/table/init_upload_table.js @@ -63,13 +63,13 @@ export function initUploadTable(cfg) { if (toggle) { const found = findNode(state.nodes, toggle.dataset.toggle); if (found) found.node.expanded = !found.node.expanded; - render(state, elements); + render(state, elements, cfg); return; } const remove = event.target.closest('[data-remove]'); if (remove) { removeNode(state, remove.dataset.remove); - render(state, elements); + render(state, elements, cfg); } }); @@ -83,7 +83,7 @@ export function initUploadTable(cfg) { getFiles: () => flattenFiles(state.nodes), remove: (id) => { removeNode(state, id); - render(state, elements); + render(state, elements, cfg); }, render: () => render(state, elements), clear: () => { diff --git a/upload/frontend/table/on_files_change.js b/upload/frontend/table/on_files_change.js index 5233cc5..609e9b4 100644 --- a/upload/frontend/table/on_files_change.js +++ b/upload/frontend/table/on_files_change.js @@ -30,15 +30,16 @@ export async function addFiles(state, cfg, files, elements) { } try { // listZipFiles возвращает дерево только с разрешёнными leaf-файлами. - const zipTree = await listZipFiles(file, cfg.allowedExt); + 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); continue; } } // Единая перерисовка после всей пачки предотвращает промежуточные состояния UI. - render(state, elements); + render(state, elements, cfg); } /** diff --git a/upload/frontend/table/on_folder_change.js b/upload/frontend/table/on_folder_change.js index 619de1f..a24cac2 100644 --- a/upload/frontend/table/on_folder_change.js +++ b/upload/frontend/table/on_folder_change.js @@ -53,13 +53,14 @@ export function onFolderChange(state, cfg, elements) { if (lowerPath.endsWith('.zip')) { try { // ZIP rebased на имя выбранной папки перед вставкой в общий root. - const zip = await listZipFiles(file, cfg.allowedExt); + 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); continue; } } else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) { @@ -71,7 +72,7 @@ export function onFolderChange(state, cfg, elements) { roots.forEach((root) => addFileWithDedup(state, root)); // Сброс value позволяет выбрать тот же каталог повторно. elements.folderInputEl.value = ''; - render(state, elements); + render(state, elements, cfg); } finally { state.busy = false; } diff --git a/upload/frontend/table/render.js b/upload/frontend/table/render.js index 995757d..76b8967 100644 --- a/upload/frontend/table/render.js +++ b/upload/frontend/table/render.js @@ -1,6 +1,31 @@ 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-строки таблицы. * @@ -12,7 +37,7 @@ import { fs } from './fs.js'; * Имена и пути проходят через esc до вставки в innerHTML. Группа получает * кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов. */ -function renderNode(node, depth) { +function renderNode(node, depth, cfg) { const isGroup = node.kind !== 'file'; const padding = depth * 4; const name = esc(node.name); @@ -20,14 +45,22 @@ function renderNode(node, depth) { ? `` : `${name}`; - const remove = ``; + const remove = ``; const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : ''; - const row = `${action}` - + `${node.kind === 'file' ? fs(node.file.size) : ''}` - + `${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}${remove}`; + 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 = `` + + columns.map((column) => `${cells[column] || ''}`).join('') + + `${remove}`; 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, cfg)).join(''); } /** @@ -40,10 +73,14 @@ function renderNode(node, depth) { * Обход для счётчика независим от раскрытия: свернутая группа всё равно входит * в количество leaf-файлов и суммарный размер. */ -export function render(state, elements) { +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)).join('') - : 'Нет выбранных файлов'; + ? state.nodes.map((node) => renderNode(node, 0, normalized)).join('') + : `${esc(normalized.labels.empty)}`; const files = []; // Собирает все leaf-файлы, включая содержимое свернутых групп. const visit = (node) => { @@ -51,7 +88,8 @@ export function render(state, elements) { else node.children.forEach(visit); }; state.nodes.forEach(visit); - elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`; + const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0); + elements.countEl.textContent = normalized.labels.count(files.length, totalBytes); } /** diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js index 58f6e66..f773de3 100644 --- a/upload/frontend/zip/list_zip_files.js +++ b/upload/frontend/zip/list_zip_files.js @@ -1,6 +1,14 @@ // Построить дерево 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(); @@ -59,10 +67,21 @@ function addPath(root, parts, fileNode) { * @returns {Promise} Дерево или null, если разрешённых leaf нет. * @throws {Error} При превышении глубины или повреждённом ZIP. */ -async function listEntries(data, zipName, allowedExt, depth) { +async function listEntries(data, zipName, allowedExt, depth, limits, budget) { // Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии. - if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP'); - const entries = fflate.unzipSync(data); + 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'); + 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)) { @@ -73,7 +92,7 @@ async function listEntries(data, zipName, allowedExt, depth) { const normalizedName = parts.join('/'); 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, limits, budget); if (nested) { rebaseTree(nested, zipName); nested.name = parts.at(-1); @@ -94,9 +113,11 @@ async function listEntries(data, zipName, allowedExt, depth) { * * @param {File} file Browser File с ZIP-содержимым. * @param {string[]} allowedExt Разрешённые расширения. + * @param {object} customLimits Ограничения распаковки. * @returns {Promise} Корневой zip-узел или null для пустого результата. */ -export async function listZipFiles(file, allowedExt) { +export async function listZipFiles(file, allowedExt, customLimits = {}) { const data = new Uint8Array(await file.arrayBuffer()); - return listEntries(data, file.name, allowedExt, 0); + const limits = { ...DEFAULT_LIMITS, ...customLimits }; + return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 }); } \ No newline at end of file