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 = `