Add obscure.lst manifest filter for folder selection

This commit is contained in:
Repinoid
2026-10-24 08:45:45 +03:00
parent 3c0c3a2043
commit bbb05e361a
7 changed files with 937 additions and 13 deletions
+8 -2
View File
@@ -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')}
+394
View File
@@ -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 };
}
+15 -1
View File
@@ -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;