Add obscure.lst manifest filter for folder selection
This commit is contained in:
+1
-1
@@ -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():
|
||||
|
||||
Vendored
+215
-3
@@ -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) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(","));
|
||||
const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].join(","));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${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")}
|
||||
|
||||
Vendored
+215
-3
@@ -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) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
const fileAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip"])].join(","));
|
||||
const folderAccept = esc([.../* @__PURE__ */ new Set([...cfg.allowedExt, ".zip", ".lst"])].join(","));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${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")}
|
||||
|
||||
@@ -466,6 +466,9 @@
|
||||
</p>
|
||||
|
||||
<div id="filePicker"></div>
|
||||
<p class="sub" style="margin-top:6px;font-size:12px;">
|
||||
Фильтр по маскам: положите в корень папки файл <code>obscure.lst</code>. Подробнее — кнопка HELP.
|
||||
</p>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span id="fileCount">0 файлов</span>
|
||||
@@ -938,15 +941,98 @@
|
||||
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center"
|
||||
onclick="this.style.display='none'">
|
||||
<div
|
||||
style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)"
|
||||
style="background:var(--card);border-radius:12px;padding:24px;max-width:640px;max-height:85vh;overflow-y:auto;box-shadow:0 4px 24px rgba(0,0,0,.15);text-align:left"
|
||||
onclick="event.stopPropagation()">
|
||||
<h3 style="margin-bottom:12px">Obfuscator — обфускация документов</h3>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6">
|
||||
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
|
||||
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные
|
||||
коды.
|
||||
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
|
||||
коды. CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
|
||||
</p>
|
||||
|
||||
<h4 style="margin:18px 0 8px;font-size:14px">Выбор файлов через obscure.lst</h4>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6">
|
||||
Чтобы обработать не всю папку, а только нужные файлы, положите в <b>корень</b> выбранной папки файл
|
||||
<code>obscure.lst</code> со списком масок. Тогда загружаются только файлы, подходящие под правила.
|
||||
</p>
|
||||
<ul style="font-size:13px;color:var(--muted);line-height:1.7;padding-left:20px;margin:8px 0">
|
||||
<li>Файл ищется только в корне папки; имя без учёта регистра: <code>obscure.lst</code>,
|
||||
<code>OBSCURE.LST</code>.</li>
|
||||
<li>Если файла нет — обрабатываются все поддерживаемые документы (как раньше).</li>
|
||||
<li>Сам <code>obscure.lst</code> не загружается и не обфусцируется.</li>
|
||||
<li>Другие <code>.lst</code> игнорируются; вложенный <code>subdir/obscure.lst</code> фильтр не включает.</li>
|
||||
<li>Пустой файл или только комментарии — не загрузится ничего; причина появится в строке статуса.</li>
|
||||
</ul>
|
||||
|
||||
<h4 style="margin:18px 0 8px;font-size:14px">Синтаксис правил</h4>
|
||||
<ul style="font-size:13px;color:var(--muted);line-height:1.7;padding-left:20px;margin:8px 0">
|
||||
<li>Одна строка — одно правило; пустые строки игнорируются.</li>
|
||||
<li><code>#</code> в начале строки — комментарий.</li>
|
||||
<li><code>!</code> в начале строки — исключение (удаляет совпадения).</li>
|
||||
<li>Пути задаются относительно выбранной папки; регистр не важен.</li>
|
||||
<li>Включающие правила собирают файлы, исключения применяются после них.</li>
|
||||
</ul>
|
||||
|
||||
<table style="width:100%;font-size:12px;border-collapse:collapse;margin:8px 0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>*</code></td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">любые символы в
|
||||
одном сегменте (не <code>/</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>?</code></td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">ровно один символ,
|
||||
кроме <code>/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>**</code>
|
||||
</td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">целый сегмент:
|
||||
любые подпапки, включая текущую</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>[a-z]</code>,
|
||||
<code>[abc]</code></td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">класс или диапазон
|
||||
символов</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap"><code>[!a]</code>,
|
||||
<code>[^a]</code></td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">любой символ, кроме
|
||||
указанных</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);white-space:nowrap">
|
||||
<code>reports/</code></td>
|
||||
<td style="padding:4px 8px;border-bottom:1px solid var(--brand-gray);color:var(--muted)">то же, что
|
||||
<code>reports/**</code> — всё поддерево</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size:12px;color:var(--muted);line-height:1.6;margin:6px 0">
|
||||
<code>*.pdf</code> — только PDF в корне, <code>**/*.pdf</code> — PDF в корне и во всех подпапках.
|
||||
<code>a**b</code> внутри сегмента работает как <code>a*b</code>.
|
||||
ZIP фильтруется по имени архива и затем раскрывается целиком.
|
||||
</p>
|
||||
|
||||
<h4 style="margin:18px 0 8px;font-size:14px">Пример obscure.lst</h4>
|
||||
<pre
|
||||
style="font-size:12px;background:var(--brand-grey-light);border:1px solid var(--brand-gray);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0"># Все PDF в корне и во вложенных папках
|
||||
**/*.pdf
|
||||
|
||||
# Договоры в reports, начинающиеся с name
|
||||
reports/name*.docx
|
||||
|
||||
# Исключить черновики и старые архивы
|
||||
!**/*draft*.*
|
||||
!archive/**</pre>
|
||||
<p style="font-size:12px;color:#9a3412;line-height:1.6;margin:6px 0">
|
||||
Если правило написано с ошибкой (абсолютный путь, <code>..</code>, незакрытый <code>[</code>),
|
||||
оно пропускается, а номер строки с причиной показывается в строке статуса под таблицей.
|
||||
</p>
|
||||
|
||||
<button class="btn" style="margin-top:12px;width:100%"
|
||||
onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
|
||||
</div>
|
||||
|
||||
@@ -76,11 +76,17 @@ function buildMarkup(root, cfg) {
|
||||
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
||||
// Обычный file input не принимает manifest: `.lst` ему не нужен.
|
||||
const fileAccept = esc([...new Set([...cfg.allowedExt, '.zip'])].join(','));
|
||||
// Folder input дополнительно объявляет `.lst`: при webkitdirectory Chromium
|
||||
// обычно игнорирует accept, но явное расширение служит страховкой, чтобы
|
||||
// `obscure.lst` попал в FileList. Сам файл в дерево не добавляется.
|
||||
const folderAccept = esc([...new Set([...cfg.allowedExt, '.zip', '.lst'])].join(','));
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
||||
<input class="fp-file-input" type="file" accept="${fileAccept}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${folderAccept}" webkitdirectory directory multiple hidden>
|
||||
${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')}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* lst_filter — фильтрация файлов каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Модуль намеренно чистый: не обращается к DOM, сети, upload или Flask.
|
||||
* На вход получает список File из folder picker и набор поддерживаемых
|
||||
* расширений (включая '.zip'), на выходе отдаёт отфильтрованный список File
|
||||
* в исходном порядке и диагностику для интерфейса.
|
||||
*
|
||||
* Формат manifest:
|
||||
* - одна строка — одно правило;
|
||||
* - пустые строки игнорируются;
|
||||
* - строки, начинающиеся с '#', — комментарии;
|
||||
* - '!' в начале строки задаёт исключение;
|
||||
* - правила задают пути относительно выбранной папки;
|
||||
* - положительные правила собирают кандидатов, исключения удаляют совпадения.
|
||||
*
|
||||
* Glob-семантика:
|
||||
* - '*' — ноль или больше символов в одном сегменте (не '/');
|
||||
* - '?' — один символ, кроме '/';
|
||||
* - '**' — целый сегмент: рекурсивное совпадение через каталоги;
|
||||
* внутри сегмента ('a**b') эквивалентен '*';
|
||||
* - '[abc]', '[a-z]' — классы и диапазоны символов;
|
||||
* - '[!a]', '[^a]' — отрицательные классы символов;
|
||||
* - 'reports/' эквивалентно 'reports/**';
|
||||
* - путь без glob-символов — точное совпадение;
|
||||
* - сравнение всего пути без учёта регистра.
|
||||
*/
|
||||
|
||||
/** Фиксированное имя manifest-файла (ищется только в корне выбранной папки). */
|
||||
const MANIFEST_NAME = 'obscure.lst';
|
||||
|
||||
/**
|
||||
* Возвращает true, если имя файла оканчивается одним из разрешённых расширений.
|
||||
*
|
||||
* Сравнение без учёта регистра: 'A.PDF' и '.pdf' считаются одинаковыми.
|
||||
*
|
||||
* @param {string} name Имя файла.
|
||||
* @param {string[]} allowedExt Список расширений (с точкой) включая '.zip'.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(String(extension).toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет путь файла относительно корня выбранной папки.
|
||||
*
|
||||
* Браузер для folder picker кладёт в `webkitRelativePath` путь вида
|
||||
* `<корень>/подпапка/файл`; первый сегмент (имя выбранной папки) отбрасываем.
|
||||
* Обратные слеши нормализуются в '/', чтобы правило можно было писать
|
||||
* в любом стиле.
|
||||
*
|
||||
* @param {{webkitRelativePath?: string, name: string}} file Файл или его фейк.
|
||||
* @returns {string} Путь относительно выбранной папки.
|
||||
*/
|
||||
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] || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет ровно корневой `obscure.lst` (без учёта регистра).
|
||||
*
|
||||
* Manifest — это файл на глубине 1 от выбранной папки. Вложенный
|
||||
* `subdir/obscure.lst` манифестом не считается; другие `*.lst` тоже.
|
||||
*
|
||||
* @param {Array} files Список файлов folder picker.
|
||||
* @returns {object|null} Найденный manifest либо null.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует символы регулярного выражения, чтобы glob-литералы
|
||||
* не превращались в regex-метасимволы.
|
||||
*
|
||||
* @param {string} text Произвольная строка.
|
||||
* @returns {string} Строка, безопасная для вставки в RegExp.
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит закрывающую ']' для класса символов.
|
||||
*
|
||||
* Учитывает, что ']' сразу после '[' или '[!' / '[^' является литералом,
|
||||
* а не закрывающей скобкой.
|
||||
*
|
||||
* @param {string} glob Исходный шаблон.
|
||||
* @param {number} openIndex Позиция открывающей '['.
|
||||
* @returns {number} Индекс закрывающей ']' либо -1, если класс не закрыт.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компилирует glob в регистронезависимый RegExp.
|
||||
*
|
||||
* @param {string} glob Нормализованный шаблон правила.
|
||||
* @returns {RegExp|null} null, если шаблон синтаксически некорректен
|
||||
* (например, незакрытый класс символов).
|
||||
*/
|
||||
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 {
|
||||
// 'a**b' внутри сегмента эквивалентно 'a*b' и не пересекает '/'.
|
||||
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);
|
||||
// '[!a]' — традиционная запись отрицательного класса, '[^a]' — regex-стиль.
|
||||
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) {
|
||||
// Некорректный класс (например, '[]') Regexp не компилирует — правило
|
||||
// отклоняется, остальные правила продолжают обрабатываться.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует одну строку manifest в путь правила.
|
||||
*
|
||||
* @param {string} line Исходная строка.
|
||||
* @returns {null|{error: string}|{path: string, negative: boolean}}
|
||||
* null — пустая строка или комментарий;
|
||||
* {error} — некорректное правило;
|
||||
* {path, negative} — нормализованное правило.
|
||||
*/
|
||||
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: 'пустое правило после "!"' };
|
||||
}
|
||||
|
||||
// Единый разделитель, схлопывание повторных '/' и устранение ведущих './'.
|
||||
// Схлопывание идёт до снятия './', чтобы './/reports' превращалось в
|
||||
// 'reports', а не в абсолютный '/reports'.
|
||||
text = text.replace(/\\/g, '/');
|
||||
text = text.replace(/\/{2,}/g, '/');
|
||||
text = text.replace(/^(?:\.\/)+/, '');
|
||||
|
||||
// Абсолютные пути (POSIX и Windows drive) недопустимы.
|
||||
if (text.startsWith('/') || /^[a-zA-Z]:\//.test(text)) {
|
||||
return { error: 'абсолютный путь не допускается' };
|
||||
}
|
||||
|
||||
// Выход за пределы выбранной папки запрещён.
|
||||
if (text.split('/').some((segment) => segment === '..')) {
|
||||
return { error: 'сегмент ".." не допускается' };
|
||||
}
|
||||
|
||||
if (!text) return { error: 'пустое правило' };
|
||||
|
||||
// Правило 'reports/' означает всё поддерево reports.
|
||||
if (text.endsWith('/')) text += '**';
|
||||
|
||||
return { path: text, negative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает текст manifest в массив правил.
|
||||
*
|
||||
* Некорректные строки не прерывают разбор: они попадают в diagnostics
|
||||
* с номером строки, остальные правила продолжают работать.
|
||||
*
|
||||
* @param {string} text Содержимое manifest (BOM уже снят).
|
||||
* @param {Array} diagnostics Аккумулятор диагностики.
|
||||
* @returns {Array<{line: number, source: string, negative: boolean, regex: RegExp}>}
|
||||
*/
|
||||
function parseRules(text, diagnostics) {
|
||||
// Поддерживаем CRLF, LF и одиночный CR.
|
||||
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: `некорректный glob: "${line.trim()}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
line: lineNumber,
|
||||
source: line.trim(),
|
||||
negative: normalized.negative,
|
||||
regex,
|
||||
});
|
||||
});
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрует файлы каталога по manifest-файлу `obscure.lst`.
|
||||
*
|
||||
* Поведение:
|
||||
* - нет корневого manifest — возвращаются все файлы (прежнее поведение);
|
||||
* - manifest есть — результат строится только по его правилам, fallback нет;
|
||||
* - сам manifest никогда не попадает в результат;
|
||||
* - файлы неподдерживаемых типов не попадают в кандидаты;
|
||||
* - ZIP фильтруется по имени архива, внутренние имена не анализируются.
|
||||
*
|
||||
* @param {FileList|File[]} files Файлы, выбранные через folder picker.
|
||||
* @param {string[]} allowedExt Поддерживаемые расширения, включая '.zip'.
|
||||
* @returns {Promise<{
|
||||
* manifest: object|null,
|
||||
* files: Array,
|
||||
* diagnostics: Array<{level: string, line: number, message: string}>
|
||||
* }>}
|
||||
*/
|
||||
export async function lstFilter(files, allowedExt) {
|
||||
const list = Array.from(files || []);
|
||||
const manifest = findManifest(list);
|
||||
|
||||
// Manifest отсутствует: сохраняем прежнее поведение без какой-либо фильтрации.
|
||||
if (!manifest) {
|
||||
return { manifest: null, files: list, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
|
||||
// Ошибка чтения или UTF-8-декодирования: предупреждение и пустой результат.
|
||||
// TextDecoder с fatal:true не подменяет битые байты на U+FFFD молча.
|
||||
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: `не удалось прочитать ${MANIFEST_NAME}: ${error.message}`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// BOM в начале снимаем вручную, иначе он попадёт в первое правило.
|
||||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||||
|
||||
const rules = parseRules(text, diagnostics);
|
||||
const positives = rules.filter((rule) => !rule.negative);
|
||||
const negatives = rules.filter((rule) => rule.negative);
|
||||
|
||||
// Нет ни одного корректного включающего правила — пустой результат без fallback.
|
||||
if (positives.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: `в ${MANIFEST_NAME} нет корректных включающих правил`,
|
||||
});
|
||||
return { manifest, files: [], diagnostics };
|
||||
}
|
||||
|
||||
// Кандидаты: только поддерживаемые типы, без самого manifest.
|
||||
// ZIP остаётся обычным кандидатом — include/exclude применяются к его имени.
|
||||
const candidates = list.filter((file) => file !== manifest
|
||||
&& extensionAllowed(file.name, allowedExt));
|
||||
|
||||
// Проход по файлам (а не по правилам) сохраняет исходный порядок и
|
||||
// исключает появление дублей при пересекающихся масках.
|
||||
const matched = 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: `правило не совпало ни с одним файлом: "${rule.source}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
line: 0,
|
||||
message: 'manifest не выбрал ни одного файла',
|
||||
});
|
||||
}
|
||||
|
||||
return { manifest, files: selected, diagnostics };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
import { lstFilter } from './lst_filter.js';
|
||||
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
@@ -25,7 +26,20 @@ export function onFolderChange(state, cfg, elements) {
|
||||
if (elements.statusEl) elements.statusEl.textContent = '';
|
||||
try {
|
||||
const roots = new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
// Manifest-фильтр: при наличии корневого `obscure.lst` набор файлов
|
||||
// ограничивается его правилами, а сам manifest в дерево не попадает.
|
||||
// Без manifest модуль возвращает исходный список без изменений.
|
||||
const filtered = await lstFilter(elements.folderInputEl.files, [...cfg.allowedExt, '.zip']);
|
||||
if (filtered.manifest) {
|
||||
// Диагностику показываем в statusEl: ошибки строк и предупреждения правил.
|
||||
const messages = filtered.diagnostics.map((item) => (
|
||||
item.line ? `строка ${item.line}: ${item.message}` : item.message
|
||||
));
|
||||
if (messages.length && elements.statusEl) {
|
||||
elements.statusEl.textContent = messages.join('; ');
|
||||
}
|
||||
}
|
||||
for (const file of filtered.files) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
|
||||
Reference in New Issue
Block a user