From bbb05e361a91f634de5eff46f32b5f06bc235a77 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 24 Oct 2026 08:45:45 +0300 Subject: [PATCH] Add obscure.lst manifest filter for folder selection --- site/app.py | 2 +- site/static/dist/file-picker.esm.js | 218 +++++++++++- site/static/dist/file-picker.iife.js | 218 +++++++++++- site/templates/index.html | 92 ++++- upload/frontend/index.js | 10 +- upload/frontend/table/lst_filter.js | 394 ++++++++++++++++++++++ upload/frontend/table/on_folder_change.js | 16 +- 7 files changed, 937 insertions(+), 13 deletions(-) create mode 100644 upload/frontend/table/lst_filter.js diff --git a/site/app.py b/site/app.py index bb51f31..b12bb46 100644 --- a/site/app.py +++ b/site/app.py @@ -22,7 +22,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "1.0.5" +VERSION = "1.1.0" def setup_logging(): diff --git a/site/static/dist/file-picker.esm.js b/site/static/dist/file-picker.esm.js index 1ff9eec..1d3cc0c 100644 --- a/site/static/dist/file-picker.esm.js +++ b/site/static/dist/file-picker.esm.js @@ -727,6 +727,209 @@ function onFilesChange(state, cfg, elements) { }; } +// upload/frontend/table/lst_filter.js +var MANIFEST_NAME = "obscure.lst"; +function extensionAllowed2(name, allowedExt) { + const lowerName = String(name || "").toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase())); +} +function relativePathOf(file) { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length > 1 ? parts.slice(1).join("/") : parts[0] || ""; +} +function findManifest(files) { + return files.find((file) => { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME; + }) || null; +} +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function findClosingBracket(glob, openIndex) { + let index = openIndex + 1; + if (glob[index] === "!" || glob[index] === "^") index += 1; + if (glob[index] === "]") index += 1; + for (; index < glob.length; index += 1) { + if (glob[index] === "]") return index; + } + return -1; +} +function compileGlob(glob) { + let pattern = "^"; + let index = 0; + while (index < glob.length) { + const character = glob[index]; + if (character === "*") { + const isDouble = glob[index + 1] === "*"; + if (!isDouble) { + pattern += "[^/]*"; + index += 1; + continue; + } + const atSegmentStart = index === 0 || glob[index - 1] === "/"; + const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === "/"; + if (atSegmentStart && atSegmentEnd) { + if (glob[index + 2] === "/") { + pattern += "(?:[^/]+/)*"; + index += 3; + } else { + pattern += ".*"; + index += 2; + } + } else { + pattern += "[^/]*"; + index += 2; + } + continue; + } + if (character === "?") { + pattern += "[^/]"; + index += 1; + continue; + } + if (character === "[") { + const close = findClosingBracket(glob, index); + if (close === -1) return null; + let body = glob.slice(index + 1, close); + if (body.startsWith("!")) body = `^${body.slice(1)}`; + pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`; + index = close + 1; + continue; + } + pattern += escapeRegExp(character); + index += 1; + } + pattern += "$"; + try { + return new RegExp(pattern, "i"); + } catch (error) { + return null; + } +} +function normalizeRule(line) { + let text = line.trim(); + if (!text || text.startsWith("#")) return null; + let negative = false; + if (text.startsWith("!")) { + negative = true; + text = text.slice(1).trim(); + if (!text) return { error: '\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043F\u043E\u0441\u043B\u0435 "!"' }; + } + text = text.replace(/\\/g, "/"); + text = text.replace(/\/{2,}/g, "/"); + text = text.replace(/^(?:\.\/)+/, ""); + if (text.startsWith("/") || /^[a-zA-Z]:\//.test(text)) { + return { error: "\u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u044B\u0439 \u043F\u0443\u0442\u044C \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F" }; + } + if (text.split("/").some((segment) => segment === "..")) { + return { error: '\u0441\u0435\u0433\u043C\u0435\u043D\u0442 ".." \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F' }; + } + if (!text) return { error: "\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E" }; + if (text.endsWith("/")) text += "**"; + return { path: text, negative }; +} +function parseRules(text, diagnostics) { + const lines = text.split(/\r\n|\r|\n/); + const rules = []; + lines.forEach((line, index) => { + const lineNumber = index + 1; + const normalized = normalizeRule(line); + if (normalized === null) return; + if (normalized.error) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `${normalized.error}: "${line.trim()}"` + }); + return; + } + const regex = compileGlob(normalized.path); + if (!regex) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 glob: "${line.trim()}"` + }); + return; + } + rules.push({ + line: lineNumber, + source: line.trim(), + negative: normalized.negative, + regex + }); + }); + return rules; +} +async function lstFilter(files, allowedExt) { + const list = Array.from(files || []); + const manifest = findManifest(list); + if (!manifest) { + return { manifest: null, files: list, diagnostics: [] }; + } + const diagnostics = []; + let text; + try { + const buffer = await manifest.arrayBuffer(); + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch (error) { + diagnostics.push({ + level: "error", + line: 0, + message: `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C ${MANIFEST_NAME}: ${error.message}` + }); + return { manifest, files: [], diagnostics }; + } + if (text.charCodeAt(0) === 65279) text = text.slice(1); + const rules = parseRules(text, diagnostics); + const positives = rules.filter((rule) => !rule.negative); + const negatives = rules.filter((rule) => rule.negative); + if (positives.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: `\u0432 ${MANIFEST_NAME} \u043D\u0435\u0442 \u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0445 \u0432\u043A\u043B\u044E\u0447\u0430\u044E\u0449\u0438\u0445 \u043F\u0440\u0430\u0432\u0438\u043B` + }); + return { manifest, files: [], diagnostics }; + } + const candidates = list.filter((file) => file !== manifest && extensionAllowed2(file.name, allowedExt)); + const matched = /* @__PURE__ */ new Set(); + const selected = []; + for (const file of candidates) { + const path = relativePathOf(file); + let included = false; + for (const rule of positives) { + if (rule.regex.test(path)) { + matched.add(rule); + included = true; + } + } + if (!included) continue; + const excluded = negatives.some((rule) => rule.regex.test(path)); + if (!excluded) selected.push(file); + } + for (const rule of positives) { + if (!matched.has(rule)) { + diagnostics.push({ + level: "warning", + line: rule.line, + message: `\u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u043B\u043E \u043D\u0438 \u0441 \u043E\u0434\u043D\u0438\u043C \u0444\u0430\u0439\u043B\u043E\u043C: "${rule.source}"` + }); + } + } + if (selected.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: "manifest \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043B \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u0444\u0430\u0439\u043B\u0430" + }); + } + return { manifest, files: selected, diagnostics }; +} + // upload/frontend/table/on_folder_change.js function onFolderChange(state, cfg, elements) { return async () => { @@ -735,7 +938,14 @@ function onFolderChange(state, cfg, elements) { if (elements.statusEl) elements.statusEl.textContent = ""; try { const roots = /* @__PURE__ */ new Map(); - for (const file of Array.from(elements.folderInputEl.files)) { + const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, ".zip"]); + if (filtered.manifest) { + const messages = filtered.diagnostics.map((item) => item.line ? `\u0441\u0442\u0440\u043E\u043A\u0430 ${item.line}: ${item.message}` : item.message); + if (messages.length && elements.statusEl) { + elements.statusEl.textContent = messages.join("; "); + } + } + for (const file of filtered.files) { const parts = (file.webkitRelativePath || file.name).split("/"); const relativePath = parts.slice(1).join("/") || file.name; const lowerPath = relativePath.toLowerCase(); @@ -1073,11 +1283,13 @@ function buildMarkup(root, cfg) { 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(""); + const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(",")); + const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].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")} diff --git a/site/static/dist/file-picker.iife.js b/site/static/dist/file-picker.iife.js index ec46e8f..67d8617 100644 --- a/site/static/dist/file-picker.iife.js +++ b/site/static/dist/file-picker.iife.js @@ -755,6 +755,209 @@ var FilePicker = (() => { }; } + // upload/frontend/table/lst_filter.js + var MANIFEST_NAME = "obscure.lst"; + function extensionAllowed2(name, allowedExt) { + const lowerName = String(name || "").toLowerCase(); + return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase())); + } + function relativePathOf(file) { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length > 1 ? parts.slice(1).join("/") : parts[0] || ""; + } + function findManifest(files) { + return files.find((file) => { + const raw = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/"); + const parts = raw.split("/"); + return parts.length === 2 && parts[1].toLowerCase() === MANIFEST_NAME; + }) || null; + } + function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function findClosingBracket(glob, openIndex) { + let index = openIndex + 1; + if (glob[index] === "!" || glob[index] === "^") index += 1; + if (glob[index] === "]") index += 1; + for (; index < glob.length; index += 1) { + if (glob[index] === "]") return index; + } + return -1; + } + function compileGlob(glob) { + let pattern = "^"; + let index = 0; + while (index < glob.length) { + const character = glob[index]; + if (character === "*") { + const isDouble = glob[index + 1] === "*"; + if (!isDouble) { + pattern += "[^/]*"; + index += 1; + continue; + } + const atSegmentStart = index === 0 || glob[index - 1] === "/"; + const atSegmentEnd = index + 2 === glob.length || glob[index + 2] === "/"; + if (atSegmentStart && atSegmentEnd) { + if (glob[index + 2] === "/") { + pattern += "(?:[^/]+/)*"; + index += 3; + } else { + pattern += ".*"; + index += 2; + } + } else { + pattern += "[^/]*"; + index += 2; + } + continue; + } + if (character === "?") { + pattern += "[^/]"; + index += 1; + continue; + } + if (character === "[") { + const close = findClosingBracket(glob, index); + if (close === -1) return null; + let body = glob.slice(index + 1, close); + if (body.startsWith("!")) body = `^${body.slice(1)}`; + pattern += `[${body.replace(/[\\\]]/g, (symbol) => `\\${symbol}`)}]`; + index = close + 1; + continue; + } + pattern += escapeRegExp(character); + index += 1; + } + pattern += "$"; + try { + return new RegExp(pattern, "i"); + } catch (error) { + return null; + } + } + function normalizeRule(line) { + let text = line.trim(); + if (!text || text.startsWith("#")) return null; + let negative = false; + if (text.startsWith("!")) { + negative = true; + text = text.slice(1).trim(); + if (!text) return { error: '\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043F\u043E\u0441\u043B\u0435 "!"' }; + } + text = text.replace(/\\/g, "/"); + text = text.replace(/\/{2,}/g, "/"); + text = text.replace(/^(?:\.\/)+/, ""); + if (text.startsWith("/") || /^[a-zA-Z]:\//.test(text)) { + return { error: "\u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u044B\u0439 \u043F\u0443\u0442\u044C \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F" }; + } + if (text.split("/").some((segment) => segment === "..")) { + return { error: '\u0441\u0435\u0433\u043C\u0435\u043D\u0442 ".." \u043D\u0435 \u0434\u043E\u043F\u0443\u0441\u043A\u0430\u0435\u0442\u0441\u044F' }; + } + if (!text) return { error: "\u043F\u0443\u0441\u0442\u043E\u0435 \u043F\u0440\u0430\u0432\u0438\u043B\u043E" }; + if (text.endsWith("/")) text += "**"; + return { path: text, negative }; + } + function parseRules(text, diagnostics) { + const lines = text.split(/\r\n|\r|\n/); + const rules = []; + lines.forEach((line, index) => { + const lineNumber = index + 1; + const normalized = normalizeRule(line); + if (normalized === null) return; + if (normalized.error) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `${normalized.error}: "${line.trim()}"` + }); + return; + } + const regex = compileGlob(normalized.path); + if (!regex) { + diagnostics.push({ + level: "error", + line: lineNumber, + message: `\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 glob: "${line.trim()}"` + }); + return; + } + rules.push({ + line: lineNumber, + source: line.trim(), + negative: normalized.negative, + regex + }); + }); + return rules; + } + async function lstFilter(files, allowedExt) { + const list = Array.from(files || []); + const manifest = findManifest(list); + if (!manifest) { + return { manifest: null, files: list, diagnostics: [] }; + } + const diagnostics = []; + let text; + try { + const buffer = await manifest.arrayBuffer(); + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch (error) { + diagnostics.push({ + level: "error", + line: 0, + message: `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C ${MANIFEST_NAME}: ${error.message}` + }); + return { manifest, files: [], diagnostics }; + } + if (text.charCodeAt(0) === 65279) text = text.slice(1); + const rules = parseRules(text, diagnostics); + const positives = rules.filter((rule) => !rule.negative); + const negatives = rules.filter((rule) => rule.negative); + if (positives.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: `\u0432 ${MANIFEST_NAME} \u043D\u0435\u0442 \u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0445 \u0432\u043A\u043B\u044E\u0447\u0430\u044E\u0449\u0438\u0445 \u043F\u0440\u0430\u0432\u0438\u043B` + }); + return { manifest, files: [], diagnostics }; + } + const candidates = list.filter((file) => file !== manifest && extensionAllowed2(file.name, allowedExt)); + const matched = /* @__PURE__ */ new Set(); + const selected = []; + for (const file of candidates) { + const path = relativePathOf(file); + let included = false; + for (const rule of positives) { + if (rule.regex.test(path)) { + matched.add(rule); + included = true; + } + } + if (!included) continue; + const excluded = negatives.some((rule) => rule.regex.test(path)); + if (!excluded) selected.push(file); + } + for (const rule of positives) { + if (!matched.has(rule)) { + diagnostics.push({ + level: "warning", + line: rule.line, + message: `\u043F\u0440\u0430\u0432\u0438\u043B\u043E \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u043B\u043E \u043D\u0438 \u0441 \u043E\u0434\u043D\u0438\u043C \u0444\u0430\u0439\u043B\u043E\u043C: "${rule.source}"` + }); + } + } + if (selected.length === 0) { + diagnostics.push({ + level: "warning", + line: 0, + message: "manifest \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043B \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u0444\u0430\u0439\u043B\u0430" + }); + } + return { manifest, files: selected, diagnostics }; + } + // upload/frontend/table/on_folder_change.js function onFolderChange(state, cfg, elements) { return async () => { @@ -763,7 +966,14 @@ var FilePicker = (() => { if (elements.statusEl) elements.statusEl.textContent = ""; try { const roots = /* @__PURE__ */ new Map(); - for (const file of Array.from(elements.folderInputEl.files)) { + const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, ".zip"]); + if (filtered.manifest) { + const messages = filtered.diagnostics.map((item) => item.line ? `\u0441\u0442\u0440\u043E\u043A\u0430 ${item.line}: ${item.message}` : item.message); + if (messages.length && elements.statusEl) { + elements.statusEl.textContent = messages.join("; "); + } + } + for (const file of filtered.files) { const parts = (file.webkitRelativePath || file.name).split("/"); const relativePath = parts.slice(1).join("/") || file.name; const lowerPath = relativePath.toLowerCase(); @@ -1101,11 +1311,13 @@ var FilePicker = (() => { 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(""); + const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(",")); + const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].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")} diff --git a/site/templates/index.html b/site/templates/index.html index de546b8..7cf3c3d 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -466,6 +466,9 @@

+

+ Фильтр по маскам: положите в корень папки файл obscure.lst. Подробнее — кнопка HELP. +