diff --git a/.gitignore b/.gitignore index ad1b3fe..295c0d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,24 @@ -# vscode -.vscode - -# Intellij -*.iml -.idea - -# npm -node_modules - -# Don't include the compiled main.js file in the repo. -# They should be uploaded to GitHub releases instead. -main.js - -# Exclude sourcemaps -*.map - -# obsidian -data.json - -# Exclude macOS Finder (System Explorer) View States -.DS_Store - -coverage/ +# vscode +.vscode + +# Intellij +*.iml +.idea + +# npm +node_modules + +# Don't include the compiled main.js file in the repo. +# They should be uploaded to GitHub releases instead. +main.js + +# Exclude sourcemaps +*.map + +# obsidian +data.json + +# Exclude macOS Finder (System Explorer) View States +.DS_Store + +coverage/ diff --git a/README.md b/README.md index 447d89c..570e5fe 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ It automatically updates parent checkboxes based on their children's state, and * Respects list indentation for nested hierarchies. * Flexible checkbox symbol interpretation (define checked/unchecked/ignored symbols). * Option to disable automatic sync on file open. +* File Ignore Rules. ## Quick Links @@ -43,4 +44,4 @@ Contributions are welcome! Please see the [**Contributing Guide**](https://grold ## License -This project is licensed under the 0BSD license. See the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the 0BSD license. See the [LICENSE](LICENSE) file for details. diff --git a/__tests__/FileFilter.test.ts b/__tests__/FileFilter.test.ts new file mode 100644 index 0000000..3e552c4 --- /dev/null +++ b/__tests__/FileFilter.test.ts @@ -0,0 +1,189 @@ +import { FileFilter } from "src/FileFilter"; +import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types"; + +const createSettings = (pathGlobs: string[]): Readonly => ({ + ...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); + }); + }); + }); +}); diff --git a/docs/changelog.md b/docs/changelog.md index fa6681d..16fd3ff 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,11 @@ nav_order: 3 --- # Changelog +## [1.2.0] - 2025-XX-XX +### Added +- Added Feature: **Logging Toggle:** Add a setting to enable/disable detailed logging for debugging purposes. +- added Feature **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active. + ## [1.1.0] - 2025-05-05 ### Added - Added Feature: Flexible Checkbox Symbol Configuration[#11](https://github.com/groldsf/obsidian_check_plugin/issues/11). @@ -67,4 +72,4 @@ nav_order: 3 - Fixed cursor and scroll bug. ## [1.0.0] - 2025-02-03 -- Initial release of Checkbox Sync. \ No newline at end of file +- Initial release of Checkbox Sync. diff --git a/docs/settings.md b/docs/settings.md index 3f3474d..212cf8d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -51,6 +51,12 @@ These settings control when and how the synchronization logic runs. - **Enabled:** Automatically synchronizes checkbox states when files are loaded/opened and immediately after plugin settings are applied. This ensures consistency but might have performance implications on very large vaults or files. *(Requires Obsidian restart or settings reload to take full effect)*. - **Disabled (Default):** Synchronization only occurs when you *manually* change a checkbox's state within Obsidian. This is the default behavior to minimize potential performance impact. +### File Scope & Filtering +- **File ignore Rules (.gitignore style)** + - This setting allows you to define which files and folders Checkbox Sync should process. If the list of patterns is empty, the plugin will operate on all markdown files in your vault. + - The filtering uses **.gitignore syntax** and is powered by the [`ignore`](https://github.com/kaelzhang/node-ignore) library. + + ### Dev - **Enable console log** diff --git a/package-lock.json b/package-lock.json index ce0380a..7c4c947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 09e08ff..310d583 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "typescript": "4.7.4" }, "dependencies": { - "async-mutex": "^0.5.0" + "async-mutex": "^0.5.0", + "ignore": "^7.0.4" } -} \ No newline at end of file +} diff --git a/src/FileFilter.ts b/src/FileFilter.ts new file mode 100644 index 0000000..5a9ded7 --- /dev/null +++ b/src/FileFilter.ts @@ -0,0 +1,54 @@ +import { CheckboxSyncPluginSettings } from './types'; +import ignore, { Ignore } from 'ignore'; + +export class FileFilter { + private settings: Readonly; + private ignorer: Ignore | null = null; + + constructor(settings: Readonly) { + 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); + } + + /** + * Updates the filter المستقيمs with new plugin settings and re-initializes the ignorer. + * @param newSettings The new plugin settings. + */ + public updateSettings(newSettings: Readonly): void { + console.log("FileFilter: Settings updated."); // Для отладки + this.settings = newSettings; // Обновляем ссылку на настройки + this.initIgnorer(); // Переинициализируем ignorer с новыми pathGlobs + } +} diff --git a/src/SyncController.ts b/src/SyncController.ts index 75abc12..cf68472 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -2,93 +2,109 @@ import { Mutex } from "async-mutex"; import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian"; import { CheckboxUtils } from "./checkboxUtils"; import FileStateHolder from "./FileStateHolder"; +import { FileFilter } from "./FileFilter"; export default class SyncController { - // private plugin: CheckboxSyncPlugin;//delete - private vault: Vault; - private checkboxUtils: CheckboxUtils; - private fileStateHolder: FileStateHolder; + // private plugin: CheckboxSyncPlugin;//delete + private vault: Vault; + private checkboxUtils: CheckboxUtils; + private fileStateHolder: FileStateHolder; + private fileFilter: FileFilter; - private mutex: Mutex; + private mutex: Mutex; - constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder) { - // this.plugin = plugin;//delete - this.vault = vault; - this.checkboxUtils = checkboxUtils; - this.fileStateHolder = fileStateHolder; - this.mutex = new Mutex(); - } + constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder, fileFilter: FileFilter) { + // this.plugin = plugin;//delete + this.vault = vault; + this.checkboxUtils = checkboxUtils; + this.fileStateHolder = fileStateHolder; + this.fileFilter = fileFilter; + this.mutex = new Mutex(); + } - async syncEditor(editor: Editor, info: MarkdownView | MarkdownFileInfo) { - if (!info.file) { - return; - } - await this.mutex.runExclusive(() => { - const file = info.file!; - console.log(`sync editor "${file.basename}" start.`); - const text = editor.getValue(); - const textBefore = this.fileStateHolder.get(file); + async syncEditor(editor: Editor, info: MarkdownView | MarkdownFileInfo) { + if (!info.file) { + return; + } + await this.mutex.runExclusive(async () => { + const file = info.file!; + console.log(`sync editor "${file.path}" start.`); - let newText = this.checkboxUtils.syncText(text, textBefore); - if (newText === text) { - this.fileStateHolder.set(file, newText); - console.log(`sync editor "${file.basename}" stop. new text equals old text.`); - return; - } + const text = editor.getValue(); - this.fileStateHolder.set(file, newText); - this.editEditor(editor, text, newText); + if (!this.fileFilter.isPathAllowed(file.path)) { + console.log(`sync editor "${file.path}" skip, path is not allowed.`); + this.fileStateHolder.set(file, text); + return; + } - console.log(`syncEditor "${file.basename}" stop.`); - }); - } + const textBefore = this.fileStateHolder.get(file); - async syncFile(file: TFile | null) { - if (!(file instanceof TFile) || file.extension !== "md") { - return; - } - await this.mutex.runExclusive(async () => { - console.log(`sync file "${file.basename}" start.`); - const newText = await this.vault.process(file, (text) => { - let textBefore = this.fileStateHolder.get(file); - let newText = this.checkboxUtils.syncText(text, textBefore); - return newText; - }); - this.fileStateHolder.set(file, newText); - console.log(`sync file "${file.basename}" stop.`); - }); - } + let newText = this.checkboxUtils.syncText(text, textBefore); + this.fileStateHolder.set(file, newText); - private editEditor(editor: Editor, oldText: string, newText: string) { - const cursor = editor.getCursor(); + if (newText === text) { + console.log(`sync editor "${file.basename}" stop. new text equals old text.`); + } else { + this.editEditor(editor, text, newText); + console.log(`syncEditor "${file.basename}" stop.`); + } + }); + } - const newLines = newText.split("\n"); - const oldLines = oldText.split("\n"); + async syncFile(file: TFile | null) { + if (!(file instanceof TFile) || file.extension !== "md") { + return; + } + await this.mutex.runExclusive(async () => { + console.log(`sync file "${file.path}" start.`); - const diffIndexes = this.checkboxUtils.findDifferentLineIndexes(oldLines, newLines); + if (!this.fileFilter.isPathAllowed(file.path)) { + console.log(`sync file "${file.path}" skip, path is not allowed.`); + const text = await this.vault.read(file); + this.fileStateHolder.set(file, text); + } else { + const newText = await this.vault.process(file, (text) => { + let textBefore = this.fileStateHolder.get(file); + let newText = this.checkboxUtils.syncText(text, textBefore); + return newText; + }); + this.fileStateHolder.set(file, newText); + } + console.log(`sync file "${file.basename}" stop.`); + }); + } - const changes: EditorChange[] = []; + private editEditor(editor: Editor, oldText: string, newText: string) { + const cursor = editor.getCursor(); - for (let ind of diffIndexes) { - changes.push({ - from: { line: ind, ch: 0 }, - to: { line: ind, ch: oldLines[ind].length }, - text: newLines[ind] - }); - } - editor.transaction({ - changes: changes - }); + const newLines = newText.split("\n"); + const oldLines = oldText.split("\n"); - editor.setCursor(cursor); + const diffIndexes = this.checkboxUtils.findDifferentLineIndexes(oldLines, newLines); - const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1; - if (lastDifferentLineIndex != -1) { - editor.scrollIntoView({ - from: { line: lastDifferentLineIndex, ch: 0 }, - to: { line: lastDifferentLineIndex, ch: 0 } - }); - } - } + const changes: EditorChange[] = []; -} \ No newline at end of file + for (let ind of diffIndexes) { + changes.push({ + from: { line: ind, ch: 0 }, + to: { line: ind, ch: oldLines[ind].length }, + text: newLines[ind] + }); + } + editor.transaction({ + changes: changes + }); + + editor.setCursor(cursor); + + const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1; + if (lastDifferentLineIndex != -1) { + editor.scrollIntoView({ + from: { line: lastDifferentLineIndex, ch: 0 }, + to: { line: lastDifferentLineIndex, ch: 0 } + }); + } + } + +} diff --git a/src/main.ts b/src/main.ts index 379134d..6f45ea0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import FileStateHolder from "./FileStateHolder"; import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab"; import SyncController from "./SyncController"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types"; +import { FileFilter } from "./FileFilter"; const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG'; @@ -25,13 +26,15 @@ export default class CheckboxSyncPlugin extends Plugin { private fileStateHolder: FileStateHolder; private fileLoadEventHandler: FileLoadEventHandler; private fileChangeEventHandler: FileChangeEventHandler; + private fileFilter: FileFilter; async onload() { await this.loadSettings(); + this.fileFilter = new FileFilter(this.settings); this.fileStateHolder = new FileStateHolder(this.app.vault); this.checkboxUtils = new CheckboxUtils(this.settings); - this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.fileStateHolder); + this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.fileStateHolder, this.fileFilter); this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); @@ -52,8 +55,11 @@ export default class CheckboxSyncPlugin extends Plugin { async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise) { await callback(this._settings); this.setDebugFlag(this.settings.consoleEnabled); + this.fileFilter.updateSettings(this.settings); await this.saveData(this.settings); + + if (this.settings.enableAutomaticFileSync) { //надо пересинхронизировать все файлы в кеше const allFile = this.fileStateHolder.getAllFiles(); diff --git a/src/types.ts b/src/types.ts index 77c49dc..0072b49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export interface CheckboxSyncPluginSettings { unknownSymbolPolicy: CheckboxState; enableAutomaticFileSync: boolean; consoleEnabled: boolean; + pathGlobs: string[]; } export enum CheckboxState { @@ -24,4 +25,5 @@ export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { unknownSymbolPolicy: CheckboxState.Checked, enableAutomaticFileSync: false, consoleEnabled: false, + pathGlobs: [], }; diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index c91fe45..2a3498e 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -16,6 +16,7 @@ import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { SettingsValidator } from "./validation/SettingsValidator"; import { ValidationError } from "./validation/types"; import { EnableConsoleLogSettingComponent } from "./components/devGroup/EnableDebug"; +import { PathGlobsSettingComponent } from "./components/pathGroup/PathGlobsSettingComponent"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -59,6 +60,14 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] ); + const pathGlobsComp = new PathGlobsSettingComponent(); + + const filteringGroup = new SettingGroup( + "File Scope & Filtering", + [pathGlobsComp], + "Define rules to include or exclude files/folders from plugin processing." + ); + const consoleLog = new EnableConsoleLogSettingComponent(); const devGroup = new SettingGroup( @@ -70,6 +79,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { this.settingGroups = [ symbolGroup, behaviorGroup, + filteringGroup, devGroup ]; diff --git a/src/ui/basedClasses/BaseSettingComponent.ts b/src/ui/basedClasses/BaseSettingComponent.ts index bdeed80..47a2341 100644 --- a/src/ui/basedClasses/BaseSettingComponent.ts +++ b/src/ui/basedClasses/BaseSettingComponent.ts @@ -25,4 +25,16 @@ export abstract class BaseSettingComponent implements ISettingComponent { public setChangeListener(listener: () => void): void { this.onChangeCallback = listener; } -} \ No newline at end of file + + /** + * Creates a DocumentFragment from an HTML string. + * Useful for setting complex descriptions with HTML content. + * @param html The HTML string. + * @returns A DocumentFragment containing the parsed HTML. + */ + protected createFragmentWithHTML(html: string): DocumentFragment { + return createFragment((documentFragment) => { + documentFragment.createDiv().innerHTML = html; + }); + } +} diff --git a/src/ui/components/pathGroup/PathGlobsSettingComponent.ts b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts new file mode 100644 index 0000000..b63b0cb --- /dev/null +++ b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts @@ -0,0 +1,122 @@ +import { Setting, TextAreaComponent } from 'obsidian'; +import { CheckboxSyncPluginSettings } from 'src/types'; +import { BaseSettingComponent } from 'src/ui/basedClasses/BaseSettingComponent'; +import { ValidationError } from 'src/ui/validation/types'; + + +export class PathGlobsSettingComponent extends BaseSettingComponent { + private textInput: TextAreaComponent; + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'pathGlobs'; + } + + private arrayToMultilineString(value: string[] | undefined | null): string { + if (Array.isArray(value)) { + try { + return value.join('\n'); + } catch (e) { + console.error(`[${this.getSettingKey()}] Error joining array to multiline string: `, e); + return ''; + } + } + return ''; + } + + render(container: HTMLElement, currentValue: any): void { + const multilineStringValue = this.arrayToMultilineString(currentValue as string[] | undefined); + + const descHtml = + `

Define rules to specify which files and folders the plugin should IGNORE. + Uses the same pattern syntax as .gitignore files (powered by the 'ignore' library).

+

For detailed syntax, refer to the + official .gitignore documentation. +

`; + + this.setting = new Setting(container) + .setName('File Ignore Rules (.gitignore style)') + // Используем createFragmentWithHTML для описания + .setDesc(this.createFragmentWithHTML(descHtml)) + .addTextArea(text => { + this.textInput = text; + text.setPlaceholder( + '# Examples:\n' + + '# Ignore all files in the "Drafts" folder\n' + + 'Drafts/\n\n' + ); + + text.setValue(multilineStringValue); + + text.onChange(() => { + this.onChangeCallback(); + }); + }); + + // Применяем стиль "многострочной настройки" как в Tasks плагине + this.makeMultilineTextSetting(); + } + + /** + * Изменяет стиль Setting, чтобы описание было над TextArea, а TextArea занимала всю ширину. + */ + private makeMultilineTextSetting(): void { + if (!this.setting) return; + + const { settingEl, infoEl, controlEl } = this.setting; + const textAreaEl: HTMLTextAreaElement | null = controlEl.querySelector('textarea'); + + if (textAreaEl === null) { + // Это не настройка с TextArea, ничего не делаем + return; + } + + settingEl.style.display = 'block'; + // infoEl может не существовать, если .setName() и .setDesc() не вызывались или были очищены. + // В нашем случае они вызываются, так что infoEl должен быть. + if (infoEl) { + infoEl.style.marginBottom = 'var(--size-4-2)'; // Небольшой отступ под описанием + infoEl.style.marginRight = '0px'; // Убираем отступ справа, если он был + } + + // Для controlEl (контейнер для textarea) + controlEl.style.width = '100%'; // Заставляем контейнер контрола быть на всю ширину + + // Для самой textarea + textAreaEl.style.width = '100%'; // Занимает всю ширину controlEl + textAreaEl.style.minHeight = '120px'; // Минимальная высота для нескольких строк + textAreaEl.rows = 8; + } + + + getValueFromUi(): string[] { + if (this.textInput) { + const rawValue = this.textInput.getValue(); + return rawValue + .split('\n') + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith('#')); + } + throw new Error(`[${this.getSettingKey()}] Cannot get value from UI: TextArea component not rendered or available.`); + } + + setValueInUi(value: any): void { + if (this.textInput) { + if (value === undefined) { + this.textInput.setValue(''); + return; + } + this.textInput.setValue((value as string[]).join('\n')); + } else { + throw new Error(`[${this.getSettingKey()}] Cannot set value in UI: TextArea component not rendered or available.`); + } + } + + validate(): ValidationError | null { + try { + this.getValueFromUi(); + } catch (e: any) { + return { field: this.getSettingKey(), message: e.message }; + } + return null; + } +} diff --git a/tsconfig.json b/tsconfig.json index e35b253..b8340ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"], }