feat(filter): Implement file filtering using 'ignore' library

This commit is contained in:
Grol Grol 2025-05-18 18:21:50 +03:00
parent 8ac4b60509
commit 683b358053
7 changed files with 326 additions and 56 deletions

View file

@ -0,0 +1,189 @@
import { FileFilter } from "src/FileFilter";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types";
const createSettings = (pathGlobs: string[]): Readonly<CheckboxSyncPluginSettings> => ({
...DEFAULT_SETTINGS,
pathGlobs,
});
describe('FileFilter with "ignore" library (.gitignore-style logic)', () => {
describe('isPathAllowed should return true if file is NOT ignored, false if IGNORED', () => {
const testCases = [
// --- Базовые случаи ---
{ name: 'No globs: any/file.md', globs: [], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Empty glob string: any/file.md', globs: [''], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Comment only: any/file.md', globs: ['#comment'], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Empty and comment: any/file.md', globs: ['', '#comment'], path: 'any/file.md', expected: true }, // Not ignored
// --- Простое исключение (файл ИГНОРИРУЕТСЯ -> isPathAllowed: false) ---
{ name: 'Exclude folder logs/: logs/error.log', globs: ['logs/'], path: 'logs/error.log', expected: false },
{ name: 'Exclude folder logs/: data/file.md (not in folder)', globs: ['logs/'], path: 'data/file.md', expected: true },
{ name: 'Exclude *.tmp: file.tmp', globs: ['*.tmp'], path: 'file.tmp', expected: false },
{ name: 'Exclude *.tmp: file.md (different type)', globs: ['*.tmp'], path: 'file.md', expected: true },
{ name: 'Exclude secret.md: secret.md', globs: ['secret.md'], path: 'secret.md', expected: false },
// --- Правило: "Нельзя пере-включить файл, если его родительская директория исключена" ---
// Тесты, которые раньше падали из-за моего неверного expected: true
{
name: 'Parent Excluded 1: logs/, !logs/important.log -> logs/important.log',
globs: ['logs/', '!logs/important.log'],
path: 'logs/important.log',
expected: false // Stays ignored because 'logs/' is excluded
},
{
name: 'Parent Excluded 2: docs/, !docs/README.md -> docs/README.md',
globs: ['docs/', '!docs/README.md'],
path: 'docs/README.md',
expected: false // Stays ignored
},
{
name: 'Parent Excluded 3: *, !projects/** -> projects/my/file.md',
globs: ['*', '!projects/**'],
path: 'projects/my/file.md',
expected: false // Stays ignored because 'projects/' (or '*' matching it) is excluded
},
{
name: 'Parent Excluded 4: .obsidian/, !.obsidian/config -> .obsidian/config',
globs: ['.obsidian/', '!.obsidian/config'],
path: '.obsidian/config',
expected: false // Stays ignored
},
// --- Случаи, где правило о родительской директории НЕ применяется, и ! отменяет предыдущее ---
// Здесь мы не исключаем родительскую директорию целиком, а только файлы по паттерну
{
name: 'Negation Works 1: *.js, !src/safe.js -> src/safe.js',
globs: ['*.js', '!src/safe.js'],
path: 'src/safe.js',
expected: true // Not ignored
},
{
name: 'Negation Works 2: *.js, !src/safe.js -> lib/bad.js',
globs: ['*.js', '!src/safe.js'],
path: 'lib/bad.js',
expected: false // Ignored by *.js
},
{
name: 'Negation Works 3: specific.md, !specific.md -> specific.md',
globs: ['specific.md', '!specific.md'],
path: 'specific.md',
expected: true // Not ignored
},
// --- Проверка правила о родительской директории более явно ---
// Сначала исключаем родителя, потом пытаемся включить ребенка И родителя
{
name: 'Parent Excluded then Re-include Parent: logs/, !logs/ -> logs/important.log',
globs: ['logs/', '!logs/'], // !logs/ отменяет logs/ для самой папки logs и ее содержимого
path: 'logs/important.log',
expected: true // logs/important.log НЕ игнорируется
},
{
name: 'Parent Excluded then Re-include Parent & Child: logs/, !logs/, !logs/important.log -> logs/important.log',
globs: ['logs/', '!logs/', '!logs/important.log'], // !logs/important.log здесь избыточно, но для теста
path: 'logs/important.log',
expected: true // НЕ игнорируется
},
// Исключаем папку, потом пытаемся включить подпапку - НЕ СРАБОТАЕТ для файлов ВНУТРИ подпапки
{
name: 'Exclude parent, try un-ignore sub-folder: top/, !top/mid/ -> top/mid/file.txt',
globs: ['top/', '!top/mid/'],
path: 'top/mid/file.txt',
expected: false // 'top/' excluded, so 'top/mid/file.txt' is ignored. '!top/mid/' cannot un-ignore contents.
},
// Но если мы хотим работать с top/mid/, нам нужно отменить top/ СНАЧАЛА для top/mid/
{
name: 'Un-ignore sub-folder first, then parent: !top/mid/, top/ -> top/mid/file.txt',
globs: ['!top/mid/', 'top/'], // 'top/' will re-ignore 'top/mid/file.txt'. Last rule wins if parent rule is not violated.
path: 'top/mid/file.txt',
expected: false
},
{
name: 'Un-ignore sub-folder from wildcard: *, !top/mid/** -> top/mid/file.txt',
globs: ['*', '!top/mid/**'], // '*' excludes 'top/', then '!top/mid/**' tries to un-exclude. Parent 'top/' is excluded.
path: 'top/mid/file.txt',
expected: false // Parent 'top/' is excluded by '*', so '!top/mid/**' has no effect on contents.
},
{
name: 'Correct way for "only top/mid/**" (other file in top): *, !top/, top/*, !top/mid/** -> top/other.txt',
globs: ['*', '!top/', 'top/*', '!top/mid/**'],
path: 'top/other.txt',
expected: false // Ignored by 'top/*'
},
// --- Другие случаи с порядком (должны работать как раньше, т.к. не нарушают правило о родителях) ---
{
name: 'Order matters: !file.md, file.md -> file.md',
globs: ['!file.md', 'file.md'],
path: 'file.md',
expected: false // Ignored by 'file.md' (last rule)
},
// --- Случай "только это" (перепроверяем с учетом правила о родителях) ---
// Globs: ["*", "!projects/**"], Path: "projects/my/file.md"
// '*' excludes 'projects/'. So '!projects/**' cannot re-include contents.
// Expected: false
// (Этот тест уже был в секции Parent Excluded и остался false)
// --- Файлы с точкой (dot files/folders) ---
// Globs: [".obsidian/", "!.obsidian/config"], Path: ".obsidian/config"
// '.obsidian/' excludes parent.
// Expected: false
// (Этот тест уже был в секции Parent Excluded и остался false)
// --- "Противоречивые" случаи ---
{
name: 'Contradictory 1: !src/**, *.js -> src/subdir/file.js',
globs: ['!src/**', '*.js'], // !src/** makes src/ not ignored. Then *.js ignores file.js.
path: 'src/subdir/file.js',
expected: false
},
{
name: 'Contradictory 1: !src/**, *.js -> src/subdir/file.txt',
globs: ['!src/**', '*.js'], // !src/** makes src/ not ignored. *.js does not match.
path: 'src/subdir/file.txt',
expected: true
},
{
name: 'Contradictory 2: *.js, !src/** -> src/file.js',
globs: ['*.js', '!src/**'], // *.js ignores src/file.js. !src/** makes src/ (and its contents) not ignored. Last rule wins.
path: 'src/file.js',
expected: true
},
{
name: 'Contradictory 2: *.js, !src/** -> lib/file.js',
globs: ['*.js', '!src/**'], // *.js ignores lib/file.js. !src/** does not match.
path: 'lib/file.js',
expected: false
},
// --- Только правило на разрешение ---
{
name: 'Only allow rule !important.txt: other.txt',
globs: ['!important.txt'], // No rule ignores other.txt. !important.txt does not match.
path: 'other.txt',
expected: true // Not ignored
},
{
name: 'Only allow rule !important.txt: important.txt',
globs: ['!important.txt'], // !important.txt matches, makes it not ignored.
path: 'important.txt',
expected: true // Not ignored
},
];
testCases.forEach(tc => {
it(`should correctly process: ${tc.name} (path: "${tc.path}", globs: ${JSON.stringify(tc.globs)})`, () => {
const filter = new FileFilter(createSettings(tc.globs));
const received = filter.isPathAllowed(tc.path);
// Логирование только для упавших тестов (убрано из предыдущей версии, т.к. expect сам покажет)
// if (received !== tc.expected) { ... }
expect(received).toBe(tc.expected);
});
});
});
});

52
package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "0BSD",
"dependencies": {
"async-mutex": "^0.5.0"
"async-mutex": "^0.5.0",
"ignore": "^7.0.4"
},
"devDependencies": {
"@types/jest": "^29.5.14",
@ -1085,6 +1086,17 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@eslint/js": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
@ -1892,6 +1904,16 @@
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.29.0.tgz",
@ -3027,6 +3049,17 @@
"node": ">=4.0"
}
},
"node_modules/eslint/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@ -3510,6 +3543,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -3566,10 +3609,9 @@
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz",
"integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==",
"license": "MIT",
"engines": {
"node": ">= 4"

View file

@ -29,6 +29,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"async-mutex": "^0.5.0"
"async-mutex": "^0.5.0",
"ignore": "^7.0.4"
}
}

44
src/FileFilter.ts Normal file
View file

@ -0,0 +1,44 @@
import { CheckboxSyncPluginSettings } from './types';
import ignore, { Ignore } from 'ignore';
export class FileFilter {
private settings: Readonly<CheckboxSyncPluginSettings>;
private ignorer: Ignore | null = null;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
this.initIgnorer();
}
private initIgnorer(): void {
const globs = this.settings.pathGlobs;
if (globs && globs.length > 0) {
this.ignorer = ignore();
// Добавляем только непустые строки и не комментарии
const validGlobs = globs.filter(g => g && g.trim() !== '' && !g.trim().startsWith('#'));
if (validGlobs.length > 0) {
this.ignorer.add(validGlobs);
} else {
// Если все глобы были пустыми или комментариями
this.ignorer = null;
}
} else {
this.ignorer = null;
}
}
/**
* Checks if a given file path is allowed based on the pathGlobs setting
* using .gitignore-like logic provided by the 'ignore' library.
* @param filePath The file path to check (relative to the vault root).
* @returns True if the file is allowed for processing (i.e., NOT ignored), false otherwise.
*/
public isPathAllowed(filePath: string): boolean {
if (!this.ignorer) {
return true; // Если нет правил (или все были комментариями), все разрешено
}
// Метод ignorer.ignores(filePath) вернет true, если файл должен быть ИГНОРИРОВАН.
// Нам нужно инвертировать это, чтобы получить "разрешен ли файл".
return !this.ignorer.ignores(filePath);
}
}

View file

@ -27,32 +27,22 @@ export class PathGlobsSettingComponent extends BaseSettingComponent {
const multilineStringValue = this.arrayToMultilineString(currentValue as string[] | undefined);
const descHtml =
`<p>List files or folders that the plugin should IGNORE.
This uses "glob patterns" (similar to <code>.gitignore</code> syntax) to match paths.</p>
<ul>
<li>Each pattern on a new line. If this list is empty, no files are ignored (plugin processes all).</li>
<li>Example to IGNORE: <code>My Folder/</code> or <code>*.log</code></li>
<li>Example to PROCESS an item within an IGNORED path: <code>!My Folder/My Important File.md</code></li>
<li>The last rule that matches a file determines its fate.</li>
<li>For more on glob syntax, search "glob patterns online" or see
<a href="https://en.wikipedia.org/wiki/Glob_(programming)" target="_blank" rel="noopener noreferrer">Glob (programming) on Wikipedia</a>.
</li>
</ul>`;
`<p>Define rules to specify which files and folders the plugin should IGNORE.
Uses the same pattern syntax as <code>.gitignore</code> files (powered by the 'ignore' library).</p>
<p>For detailed syntax, refer to the
<a href="https://git-scm.com/docs/gitignore" target="_blank" rel="noopener noreferrer">official .gitignore documentation</a>.
</p>`;
this.setting = new Setting(container)
.setName('Ignored Files & Folders')
.setName('File Ignore Rules (.gitignore style)')
// Используем createFragmentWithHTML для описания
.setDesc(this.createFragmentWithHTML(descHtml))
.addTextArea(text => {
this.textInput = text;
text.setPlaceholder(
'# Lines starting with # are comments and will be ignored\n' +
'Drafts/\n' +
'*.tmp\n' +
'!Drafts/ReadyToPublish.md\n\n' +
'# To process ONLY files in "Projects" folder:\n' +
'*\n' +
'!Projects/**'
'# Examples:\n' +
'# Ignore all files in the "Drafts" folder\n' +
'Drafts/\n\n'
);
text.setValue(multilineStringValue);

View file

@ -4,7 +4,7 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
@ -13,18 +13,22 @@
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2018",
"DOM.Iterable"
],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
// "strict": true, // Включение этой опции активирует множество строгих проверок (включая noImplicitAny, strictNullChecks, и др.) - очень рекомендуется для новых проектов. Если сейчас вызовет много ошибок, можно добавлять постепенно.
// "rootDir": "src", // Указываем, где исходный код
// "outDir": "dist", // Куда компилировать
"resolveJsonModule": true // Поддержка импорта JSON файлов
"resolveJsonModule": true, // Поддержка импорта JSON файлов
"skipLibCheck": true,
},
"include": [
"**/*.ts",
"src/**/*.ts", // Включаем только исходники из папки src
"__tests__/**/*.ts" // Включаем тесты из папки __tests__
]
],
"exclude": ["node_modules"],
}