From 595bb8194e40389ccb967fbd533b7b22bd29b3c6 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 14 May 2025 13:24:08 +0300 Subject: [PATCH 01/28] feat(settings): Add option to enable/disable debug logging --- docs/settings.md | 16 ++- esbuild-debug-wrap-plugin.mjs | 78 +++++++++++++ esbuild.config.mjs | 7 ++ package-lock.json | 2 +- src/main.ts | 103 ++++++++++-------- src/types.ts | 4 +- src/ui/CheckboxSyncPluginSettingTab.ts | 13 ++- src/ui/components/devGroup/EnableDebug.ts | 57 ++++++++++ .../EnableChildSyncSettingComponent.ts | 2 +- 9 files changed, 232 insertions(+), 50 deletions(-) create mode 100644 esbuild-debug-wrap-plugin.mjs create mode 100644 src/ui/components/devGroup/EnableDebug.ts diff --git a/docs/settings.md b/docs/settings.md index 5c61036..3f3474d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -51,6 +51,13 @@ 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. +### Dev + +- **Enable console log** + - **Description:** Toggles detailed logging to the developer console. When enabled, the plugin will output more information about its operations, which can be helpful for troubleshooting or understanding its behavior. + - **Enabled:** Debug logs are printed to the console. + - **Disabled (Default):** Debug logs are suppressed. + ### Actions and Status - **Error Display:** An area below the settings displays any errors encountered, such as invalid JSON in the symbol configuration. @@ -105,10 +112,17 @@ These settings control when and how the synchronization logic runs. - **Включено:** Автоматически синхронизирует состояния чекбоксов при загрузке/открытии файлов и сразу после применения настроек плагина. Это обеспечивает консистентность, но может влиять на производительность в очень больших хранилищах или файлах. *(Требует перезапуска Obsidian или перезагрузки настроек для полного вступления в силу)*. - **Отключено (По умолчанию):** Синхронизация происходит только тогда, когда вы *вручную* изменяете состояние чекбокса в Obsidian. Это поведение по умолчанию для минимизации потенциального влияния на производительность. +### Разработка / Отладка + +- **Включить Отладочное Логирование (Enable console log)** + - **Описание:** Переключает вывод подробных логов в консоль разработчика. Когда включено, плагин будет выводить больше информации о своих операциях, что может быть полезно для устранения неполадок или понимания его поведения. + - **Включено:** Отладочные логи выводятся в консоль. + - **Отключено (По умолчанию):** Отладочные логи не выводятся. + ### Действия и Статус - **Отображение Ошибок:** Область под настройками отображает любые возникшие ошибки, например, невалидный JSON в конфигурации символов. - **Кнопки:** - `Apply Changes` (Применить изменения): Сохраняет и применяет измененные настройки. Активна только при наличии изменений. - `Reset changes` (Отменить изменения): Возвращает несохраненные изменения к последнему примененному состоянию настроек. - - `Reset to defaults` (Сбросить по умолчанию): Сбрасывает все настройки к значениям по умолчанию и немедленно применяет их. \ No newline at end of file + - `Reset to defaults` (Сбросить по умолчанию): Сбрасывает все настройки к значениям по умолчанию и немедленно применяет их. diff --git a/esbuild-debug-wrap-plugin.mjs b/esbuild-debug-wrap-plugin.mjs new file mode 100644 index 0000000..c248ea8 --- /dev/null +++ b/esbuild-debug-wrap-plugin.mjs @@ -0,0 +1,78 @@ +import { promises } from "fs"; + +/** + * Плагин esbuild для обертывания вызовов console.* в условие. + * @param {object} options + * @param {string} [options.debugFlagName='DEBUG_WRAP_CONSOLE_PLUGIN_FLAG'] - Имя глобальной переменной для проверки. + * @param {string[]} [options.methods=['log', 'warn', 'info', 'debug', 'error']] - Методы console, которые нужно обернуть. + * @returns {import('esbuild').Plugin} + */ +export const debugWrapConsolePlugin = ({ + debugFlagName = "DEBUG_WRAP_CONSOLE_PLUGIN_FLAG", // Имя глобального флага (лучше сделать уникальным для вашего плагина) + methods = ["log", "warn", "info", "debug", "error"], // Какие методы console оборачивать +} = {}) => ({ + name: "debug-wrap-console", + setup(build) { + // Создаем фильтр для методов console + const methodsPattern = methods.join("|"); + // Регулярное выражение для поиска вызовов console.method(...) + // Оно пытается обработать простые случаи, но может быть неидеальным для сложных вложенных выражений или многострочных вызовов. + // $& в замене представляет всю совпавшую строку. + const consoleRegex = new RegExp( + // Match 'console.' followed by one of the specified methods + `(^|\\s+|\\{|\\;)(console\\.(${methodsPattern}))\\s*\\(` + + // Match arguments (non-greedy) - this is the tricky part and might not capture perfectly balanced parentheses in all complex cases + `([\\s\\S]*?)` + + // Match the closing parenthesis and optional semicolon + `\\)(;?)`, + "g" // Global search + ); + + const wrapperStart = `if (window.${debugFlagName}) { `; + const wrapperEnd = ` }`; + + // Перехватываем загрузку JS/TS файлов + build.onLoad({ filter: /\.[jt]sx?$/ }, async (args) => { + try { + // Читаем содержимое файла + const source = await promises.readFile(args.path, "utf8"); + + // Заменяем вызовы console.* + const contents = source.replace( + consoleRegex, + (match, prefix, fullCall, methodName, argsContent, semicolon) => { + // prefix: Пробел, начало строки, {, ; перед вызовом console + // fullCall: Сам вызов, например, console.log + // methodName: Имя метода, например, log + // argsContent: Содержимое скобок + // semicolon: Завершающая точка с запятой (если была) + + // Собираем обернутый вызов + // Мы используем 'match' целиком, чтобы сохранить оригинальное форматирование и содержимое, + // но убираем исходный префикс (пробел/начало строки/итд) и добавляем его перед if. + const originalCall = match.substring(prefix.length); + return `${prefix}${wrapperStart}${originalCall}${wrapperEnd}`; + } + ); + + // Возвращаем измененное содержимое и указываем esbuild, + // что это все еще JS/TS код (в зависимости от исходного файла) + const loader = + args.path.endsWith(".ts") || args.path.endsWith(".tsx") ? "ts" : "js"; + + return { + contents, + loader, + }; + } catch (error) { + console.error( + `Error processing file ${args.path} in debug-wrap-console plugin:`, + error + ); + // В случае ошибки возвращаем null или выбрасываем ошибку, чтобы сборка прервалась + return null; + } + }); + }, +}); + diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 54986fd..3d62642 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,6 +1,7 @@ import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; +import { debugWrapConsolePlugin } from "./esbuild-debug-wrap-plugin.mjs"; const banner = `/* @@ -39,6 +40,12 @@ const context = await esbuild.context({ treeShaking: true, outfile: "main.js", minify: prod, + plugins: [ + debugWrapConsolePlugin({ + debugFlagName: 'CHECKBOX_SYNC_DEBUG', + methods: ['log', 'info', 'debug'], + }), + ], }); if (prod) { diff --git a/package-lock.json b/package-lock.json index ac7b798..ce0380a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "checkbox-sync", "version": "1.0.0", - "license": "MIT", + "license": "0BSD", "dependencies": { "async-mutex": "^0.5.0" }, diff --git a/src/main.ts b/src/main.ts index a23c16d..ecb68a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,49 +7,64 @@ import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab" import SyncController from "./SyncController"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types"; -export default class CheckboxSyncPlugin extends Plugin { - private _settings: CheckboxSyncPluginSettings; +const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG'; - private syncController: SyncController; - private checkboxUtils: CheckboxUtils; - private fileStateHolder: FileStateHolder; - private fileLoadEventHandler: FileLoadEventHandler; - private fileChangeEventHandler: FileChangeEventHandler; - - async onload() { - await this.loadSettings(); - - 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.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); - this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); - - this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this)); - this.fileLoadEventHandler.registerEvents(); - this.fileChangeEventHandler.registerEvents(); - } - - async loadSettings() { - this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - get settings(): Readonly { - return this._settings; - } - - async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise) { - await callback(this._settings); - await this.saveData(this.settings); - - if (this.settings.enableAutomaticFileSync) { - //надо пересинхронизировать все файлы в кеше - const allFile = this.fileStateHolder.getAllFiles(); - await Promise.all( - allFile.map(async (file: TFile) => { - await this.syncController.syncFile(file); - }) - ); - } - } +// --- Объявление глобальной переменной для TypeScript --- +// Это нужно, чтобы TypeScript не ругался на window[DEBUG_FLAG_NAME] +declare global { + interface Window { + [key: string]: any; // Позволяем индексировать window строкой + } +} + +export default class CheckboxSyncPlugin extends Plugin { + private _settings: CheckboxSyncPluginSettings; + + private syncController: SyncController; + private checkboxUtils: CheckboxUtils; + private fileStateHolder: FileStateHolder; + private fileLoadEventHandler: FileLoadEventHandler; + private fileChangeEventHandler: FileChangeEventHandler; + + async onload() { + await this.loadSettings(); + + 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.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); + this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); + + this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this)); + this.fileLoadEventHandler.registerEvents(); + this.fileChangeEventHandler.registerEvents(); + } + + async loadSettings() { + this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + get settings(): Readonly { + return this._settings; + } + + async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise) { + await callback(this._settings); + this.setDebugFlag(this.settings.consoleEnabled); + await this.saveData(this.settings); + + if (this.settings.enableAutomaticFileSync) { + //надо пересинхронизировать все файлы в кеше + const allFile = this.fileStateHolder.getAllFiles(); + await Promise.all( + allFile.map(async (file: TFile) => { + await this.syncController.syncFile(file); + }) + ); + } + } + + private setDebugFlag(enabled: boolean) { + window[DEBUG_FLAG_NAME] = enabled; + } } diff --git a/src/types.ts b/src/types.ts index 709bee8..77c49dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export interface CheckboxSyncPluginSettings { ignoreSymbols: string[]; unknownSymbolPolicy: CheckboxState; enableAutomaticFileSync: boolean; + consoleEnabled: boolean; } export enum CheckboxState { @@ -22,4 +23,5 @@ export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { ignoreSymbols: [], unknownSymbolPolicy: CheckboxState.Checked, enableAutomaticFileSync: false, -}; \ No newline at end of file + consoleEnabled: false, +}; diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index b836b59..801eea2 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -15,6 +15,7 @@ import { EnableParentSyncSettingComponent } from "./components/synchronizationBe import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { SettingsValidator } from "./validation/SettingsValidator"; import { ValidationError } from "./validation/types"; +import { EnableConsoleLogSettingComponent } from "./components/devGroup/EnableDebug"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -58,10 +59,18 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] ); + const consoleLog = new EnableConsoleLogSettingComponent(); + + const devGroup = new SettingGroup( + "Dev", + [consoleLog] + ); + // Сохраняем созданную группу (или группы) в поле класса this.settingGroups = [ symbolGroup, - behaviorGroup + behaviorGroup, + devGroup ]; const changeListener = () => this.settingChanged(); @@ -294,4 +303,4 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { throw saveError; } } -} \ No newline at end of file +} diff --git a/src/ui/components/devGroup/EnableDebug.ts b/src/ui/components/devGroup/EnableDebug.ts new file mode 100644 index 0000000..c346b98 --- /dev/null +++ b/src/ui/components/devGroup/EnableDebug.ts @@ -0,0 +1,57 @@ +import { ToggleComponent, Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateValueIsBoolean } from "src/ui/validation/validators"; + + +export class EnableConsoleLogSettingComponent extends BaseSettingComponent { + private toggleComponent: ToggleComponent; + + constructor() { + super(); + } + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'consoleEnabled'; + } + + render(container: HTMLElement, currentValue: any): void { + this.setting = new Setting(container) + .setName('Enable console logs') + .addToggle(toggle => { + this.toggleComponent = toggle; + toggle + .setValue(currentValue as boolean) + .onChange(this.onChangeCallback); + }); + } + + getValueFromUi(): boolean { + if (this.toggleComponent) { + return this.toggleComponent.getValue(); + } + throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`); + } + + setValueInUi(value: any): void { + if (this.toggleComponent) { + this.toggleComponent.setValue(value as boolean); + } else { + throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`); + } + } + + validate(): ValidationError | null { + const valueFromUi = this.getValueFromUi(); + const validationResult = validateValueIsBoolean(valueFromUi); + + if (validationResult) { + return { + field: this.getSettingKey(), + message: validationResult.message + }; + } + return null; + } +} diff --git a/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts index 1559146..345447a 100644 --- a/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts +++ b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts @@ -55,4 +55,4 @@ export class EnableChildSyncSettingComponent extends BaseSettingComponent { } return null; } -} \ No newline at end of file +} From 4bcb4a168a21333dde9ceb97a6f6c97957ccd0f6 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 14 May 2025 13:48:26 +0300 Subject: [PATCH 02/28] fix loading settings --- src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.ts b/src/main.ts index ecb68a5..379134d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ export default class CheckboxSyncPlugin extends Plugin { async loadSettings() { this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.setDebugFlag(this.settings.consoleEnabled); } get settings(): Readonly { From a453a07b18767b5f7077e371b9c6e2eb2b52c620 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 14 May 2025 14:17:33 +0300 Subject: [PATCH 03/28] fix error, miss else block --- .../EnableParentSyncSettingComponent.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts index 116366f..8832218 100644 --- a/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts +++ b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts @@ -41,8 +41,9 @@ export class EnableParentSyncSettingComponent extends BaseSettingComponent { setValueInUi(value: any): void { if (this.toggleComponent) { this.toggleComponent.setValue(value as boolean); - } - throw new Error(`[${this.getSettingKey()}] Attempted to set value before component is rendered.`); + }else{ + throw new Error(`[${this.getSettingKey()}] Attempted to set value before component is rendered.`); + } } validate(): ValidationError | null { @@ -68,4 +69,4 @@ export class EnableParentSyncSettingComponent extends BaseSettingComponent { } return null; } -} \ No newline at end of file +} From 77653389f34bfd8adcdd21cea4da92159998fe51 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 14 May 2025 18:45:01 +0300 Subject: [PATCH 04/28] fix space --- src/ui/CheckboxSyncPluginSettingTab.ts | 474 ++++++++++++------------- 1 file changed, 237 insertions(+), 237 deletions(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 801eea2..c91fe45 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -18,46 +18,46 @@ import { ValidationError } from "./validation/types"; import { EnableConsoleLogSettingComponent } from "./components/devGroup/EnableDebug"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { - plugin: CheckboxSyncPlugin; + plugin: CheckboxSyncPlugin; - private settingGroups: SettingGroup[] = [] + private settingGroups: SettingGroup[] = [] - private errorDisplay: ErrorDisplay; + private errorDisplay: ErrorDisplay; - private settingsControls: SettingsControls; + private settingsControls: SettingsControls; - private isDirty: boolean = false; - private actionMutex = new Mutex(); + private isDirty: boolean = false; + private actionMutex = new Mutex(); - constructor(app: App, plugin: CheckboxSyncPlugin) { - super(app, plugin); - this.plugin = plugin; - this.initializeSettingGroups(); - } + constructor(app: App, plugin: CheckboxSyncPlugin) { + super(app, plugin); + this.plugin = plugin; + this.initializeSettingGroups(); + } - // Метод для создания и конфигурации групп и компонентов - private initializeSettingGroups(): void { - // Создаем экземпляры наших компонентов + // Метод для создания и конфигурации групп и компонентов + private initializeSettingGroups(): void { + // Создаем экземпляры наших компонентов - const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); - const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); - const unknownPolicyComp = new UnknownPolicySettingComponent(); - const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent(); + const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); + const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); + const unknownPolicyComp = new UnknownPolicySettingComponent(); + const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent(); - const symbolGroup = new SettingGroup( - "Checkbox Symbol Configuration (Advanced: JSON)", - [checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp], - "Warning: Requires valid JSON format. Use double quotes for strings." - ); + const symbolGroup = new SettingGroup( + "Checkbox Symbol Configuration (Advanced: JSON)", + [checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp], + "Warning: Requires valid JSON format. Use double quotes for strings." + ); - const parentToggleComp = new EnableParentSyncSettingComponent(); - const childrenToggleComp = new EnableChildSyncSettingComponent(); - const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent(); + const parentToggleComp = new EnableParentSyncSettingComponent(); + const childrenToggleComp = new EnableChildSyncSettingComponent(); + const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent(); - const behaviorGroup = new SettingGroup( - "Synchronization Behavior", - [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] - ); + const behaviorGroup = new SettingGroup( + "Synchronization Behavior", + [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] + ); const consoleLog = new EnableConsoleLogSettingComponent(); @@ -66,241 +66,241 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { [consoleLog] ); - // Сохраняем созданную группу (или группы) в поле класса - this.settingGroups = [ - symbolGroup, - behaviorGroup, + // Сохраняем созданную группу (или группы) в поле класса + this.settingGroups = [ + symbolGroup, + behaviorGroup, devGroup - ]; + ]; - const changeListener = () => this.settingChanged(); - for (const group of this.settingGroups) { - for (const component of group.components) { - // Устанавливаем общий listener для всех компонентов - component.setChangeListener(changeListener); - } - } - } + const changeListener = () => this.settingChanged(); + for (const group of this.settingGroups) { + for (const component of group.components) { + // Устанавливаем общий listener для всех компонентов + component.setChangeListener(changeListener); + } + } + } - private settingChanged() { - this.isDirty = true; - this.settingsControls.setApplyState({ disabled: false, cta: true }); - } + private settingChanged() { + this.isDirty = true; + this.settingsControls.setApplyState({ disabled: false, cta: true }); + } - private settingSaved() { - this.isDirty = false; - this.settingsControls.setApplyState({ disabled: true, cta: false }); - } + private settingSaved() { + this.isDirty = false; + this.settingsControls.setApplyState({ disabled: true, cta: false }); + } - display(): void { - this.isDirty = false; - const containerEl = this.containerEl; - containerEl.empty(); - containerEl.createEl('h2', { text: 'Checkbox Sync Settings' }); + display(): void { + this.isDirty = false; + const containerEl = this.containerEl; + containerEl.empty(); + containerEl.createEl('h2', { text: 'Checkbox Sync Settings' }); - for (const group of this.settingGroups) { - group.render(containerEl, this.plugin.settings); - } + for (const group of this.settingGroups) { + group.render(containerEl, this.plugin.settings); + } - // --- Область для вывода ошибок --- - this.errorDisplay = new ErrorDisplay(containerEl); + // --- Область для вывода ошибок --- + this.errorDisplay = new ErrorDisplay(containerEl); - // Создаем экземпляр SettingsControls - const controlActions: ISettingsControlActions = { - onApply: () => this.applyChanges(), - onReset: () => this.resetInputsToSavedSettings(), - // Оставляем ConfirmModal здесь, но передаем вызов applyDefaultSettings - onResetDefaults: () => { - new ConfirmModal(this.app, - "Reset all settings to default and apply immediately?\nThis cannot be undone.", - // Только если пользователь нажал ОК, вызываем реальный сброс - async () => { await this.applyDefaultSettings(); } - // Optional: callback for cancel if needed - ).open(); - } - }; - this.settingsControls = new SettingsControls(containerEl, controlActions); + // Создаем экземпляр SettingsControls + const controlActions: ISettingsControlActions = { + onApply: () => this.applyChanges(), + onReset: () => this.resetInputsToSavedSettings(), + // Оставляем ConfirmModal здесь, но передаем вызов applyDefaultSettings + onResetDefaults: () => { + new ConfirmModal(this.app, + "Reset all settings to default and apply immediately?\nThis cannot be undone.", + // Только если пользователь нажал ОК, вызываем реальный сброс + async () => { await this.applyDefaultSettings(); } + // Optional: callback for cancel if needed + ).open(); + } + }; + this.settingsControls = new SettingsControls(containerEl, controlActions); - // Устанавливаем начальное состояние кнопки Apply - // (isDirty здесь всегда false при первом вызове display) - this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }); + // Устанавливаем начальное состояние кнопки Apply + // (isDirty здесь всегда false при первом вызове display) + this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }); - // Начальное состояние кнопки Reset Defaults (всегда включена) - this.settingsControls.setResetDefaultsState({ disabled: false }); - } + // Начальное состояние кнопки Reset Defaults (всегда включена) + this.settingsControls.setResetDefaultsState({ disabled: false }); + } - private async applyChanges() { - await this.actionMutex.runExclusive(async () => { - this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false }); - this.errorDisplay.clear(); + private async applyChanges() { + await this.actionMutex.runExclusive(async () => { + this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false }); + this.errorDisplay.clear(); - try { - const result = await this.validateAndSaveSettings(); - if (result.success) { - this.settingSaved(); - new Notice("Checkbox Sync settings applied!", 3000); - } else { - console.warn("Validation errors:", result.errors); - this.errorDisplay.displayErrors(result.errors); - } - } catch (error: any) { - console.error("Unexpected error during applyChanges:", error); - const message = error.message || "Unknown error"; - this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); - } finally { - this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }) - } - }); - } + try { + const result = await this.validateAndSaveSettings(); + if (result.success) { + this.settingSaved(); + new Notice("Checkbox Sync settings applied!", 3000); + } else { + console.warn("Validation errors:", result.errors); + this.errorDisplay.displayErrors(result.errors); + } + } catch (error: any) { + console.error("Unexpected error during applyChanges:", error); + const message = error.message || "Unknown error"; + this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); + } finally { + this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }) + } + }); + } - /** - * Resets the settings UI elements to reflect the currently saved plugin settings. - */ - private resetInputsToSavedSettings() { - const settings = this.plugin.settings; + /** + * Resets the settings UI elements to reflect the currently saved plugin settings. + */ + private resetInputsToSavedSettings() { + const settings = this.plugin.settings; - for (const group of this.settingGroups) { - for (const component of group.components) { - const key = component.getSettingKey(); - if (key in settings) { - try { - component.setValueInUi(settings[key]); - } catch (error) { - console.error(`Error resetting UI for component ${key}:`, error); - } - } - } - } + for (const group of this.settingGroups) { + for (const component of group.components) { + const key = component.getSettingKey(); + if (key in settings) { + try { + component.setValueInUi(settings[key]); + } catch (error) { + console.error(`Error resetting UI for component ${key}:`, error); + } + } + } + } - // Очищаем ошибку и сбрасываем состояние "грязный" - this.errorDisplay.clear(); - this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply - } + // Очищаем ошибку и сбрасываем состояние "грязный" + this.errorDisplay.clear(); + this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply + } - /** - * Applies the default settings immediately after confirmation. - */ - private async applyDefaultSettings() { - await this.actionMutex.runExclusive(async () => { - this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' }); - this.errorDisplay.clear(); + /** + * Applies the default settings immediately after confirmation. + */ + private async applyDefaultSettings() { + await this.actionMutex.runExclusive(async () => { + this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' }); + this.errorDisplay.clear(); - try { - const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией + try { + const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией - await this.plugin.updateSettings(settings => { - // Полностью перезаписываем текущие настройки дефолтными - Object.assign(settings, defaultsCopy); - }); + await this.plugin.updateSettings(settings => { + // Полностью перезаписываем текущие настройки дефолтными + Object.assign(settings, defaultsCopy); + }); - // После успешного сохранения обновляем UI и состояние - this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply + // После успешного сохранения обновляем UI и состояние + this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply - new Notice("Settings reset to defaults and applied.", 3000); + new Notice("Settings reset to defaults and applied.", 3000); - } catch (error: any) { - console.error("Error resetting settings to default:", error); - const message = error.message || "Unknown error"; - this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`); - } finally { - // Восстанавливаем кнопку Reset - this.settingsControls.setResetDefaultsState({ disabled: false }); - } - }); - } + } catch (error: any) { + console.error("Error resetting settings to default:", error); + const message = error.message || "Unknown error"; + this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`); + } finally { + // Восстанавливаем кнопку Reset + this.settingsControls.setResetDefaultsState({ disabled: false }); + } + }); + } - /** - * Overridden hide method to handle unsaved changes. - */ - async hide() { - if (this.isDirty) { - // Колбэк для кнопки "Save" - const saveCallback = async () => { - try { - const result = await this.validateAndSaveSettings(); - if (result.success) { - this.isDirty = false; // Сбрасываем флаг только при успехе - new Notice("Settings saved.", 2000); - } else { - console.error("Error saving settings on hide (validation):", result.errors); - const errorMessage = result.errors - .map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`) - .join('\n'); - new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open(); - } - } catch (error: any) { - // Ловим НЕОЖИДАННЫЕ ошибки (например, ошибка сохранения) - console.error("Error saving settings on hide (unexpected):", error); - let errorMessage = "An unknown error occurred."; - if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === 'string') { - errorMessage = error; - } - new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open(); - } - }; + /** + * Overridden hide method to handle unsaved changes. + */ + async hide() { + if (this.isDirty) { + // Колбэк для кнопки "Save" + const saveCallback = async () => { + try { + const result = await this.validateAndSaveSettings(); + if (result.success) { + this.isDirty = false; // Сбрасываем флаг только при успехе + new Notice("Settings saved.", 2000); + } else { + console.error("Error saving settings on hide (validation):", result.errors); + const errorMessage = result.errors + .map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`) + .join('\n'); + new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open(); + } + } catch (error: any) { + // Ловим НЕОЖИДАННЫЕ ошибки (например, ошибка сохранения) + console.error("Error saving settings on hide (unexpected):", error); + let errorMessage = "An unknown error occurred."; + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } + new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open(); + } + }; - // Колбэк для кнопки "Discard" - const discardCallback = () => { - this.isDirty = false; // Считаем изменения отмененными - }; + // Колбэк для кнопки "Discard" + const discardCallback = () => { + this.isDirty = false; // Считаем изменения отмененными + }; - // Открываем модалку Save/Discard и ЖДЕМ ее закрытия - const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback); - saveModal.open(); - } - // Продолжаем стандартное закрытие - super.hide(); - } + // Открываем модалку Save/Discard и ЖДЕМ ее закрытия + const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback); + saveModal.open(); + } + // Продолжаем стандартное закрытие + super.hide(); + } - /** - * Reads UI values, validates them, and calls plugin.updateSettings. - * Throws an error if validation or saving fails. - * Does NOT interact with UI feedback (buttons, notices, error display). - */ - private async validateAndSaveSettings(): Promise<{ success: boolean; errors: ValidationError[] }> { + /** + * Reads UI values, validates them, and calls plugin.updateSettings. + * Throws an error if validation or saving fails. + * Does NOT interact with UI feedback (buttons, notices, error display). + */ + private async validateAndSaveSettings(): Promise<{ success: boolean; errors: ValidationError[] }> { - let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации - let newSettingsData: Partial = {}; // Объект для новых данных + let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации + let newSettingsData: Partial = {}; // Объект для новых данных - for (const group of this.settingGroups) { - for (const component of group.components) { - const key = component.getSettingKey(); - try { - const value = component.getValueFromUi(); - const validationError = component.validate(); - if (validationError) { - errors.push(validationError); - } else { - newSettingsData[key] = value; - } - } catch (err: any) { - console.error(`Error processing component ${key}:`, err); - errors.push({ field: key, message: `Failed to read value: ${err.message}` }); - } - } - } + for (const group of this.settingGroups) { + for (const component of group.components) { + const key = component.getSettingKey(); + try { + const value = component.getValueFromUi(); + const validationError = component.validate(); + if (validationError) { + errors.push(validationError); + } else { + newSettingsData[key] = value; + } + } catch (err: any) { + console.error(`Error processing component ${key}:`, err); + errors.push({ field: key, message: `Failed to read value: ${err.message}` }); + } + } + } - if (errors.length === 0) { - const settingsValidator = new SettingsValidator(); - const crossErrors = settingsValidator.validate(newSettingsData); - errors.push(...crossErrors); - } + if (errors.length === 0) { + const settingsValidator = new SettingsValidator(); + const crossErrors = settingsValidator.validate(newSettingsData); + errors.push(...crossErrors); + } - if (errors.length > 0) { - return { success: false, errors: errors }; - } + if (errors.length > 0) { + return { success: false, errors: errors }; + } - try { - await this.plugin.updateSettings(settings => { - Object.assign(settings, newSettingsData); - }); - return { success: true, errors: [] }; - } catch (saveError: any) { - // Ловим ТОЛЬКО ошибки сохранения (от updateSettings) - console.error("Error saving settings:", saveError); - throw saveError; - } - } + try { + await this.plugin.updateSettings(settings => { + Object.assign(settings, newSettingsData); + }); + return { success: true, errors: [] }; + } catch (saveError: any) { + // Ловим ТОЛЬКО ошибки сохранения (от updateSettings) + console.error("Error saving settings:", saveError); + throw saveError; + } + } } From a69f23159adbce123cdde51be65bc3c9b85947fa Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Thu, 15 May 2025 13:26:55 +0300 Subject: [PATCH 05/28] feat: Add pathGlobs setting for file filtering --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) 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: [], }; From f3f0685bdcc4745c7abd0c3c4c1490910ac0b74f Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 16 May 2025 00:50:09 +0300 Subject: [PATCH 06/28] feat(settings): Add UI for file filtering glob patterns --- src/ui/CheckboxSyncPluginSettingTab.ts | 10 ++ .../pathGroup/PathGlobsSettingComponent.ts | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/ui/components/pathGroup/PathGlobsSettingComponent.ts 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/components/pathGroup/PathGlobsSettingComponent.ts b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts new file mode 100644 index 0000000..0dbbf7b --- /dev/null +++ b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts @@ -0,0 +1,91 @@ +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); + + this.setting = new Setting(container) + .setName('Ignored Files & Folders') + .setDesc('List files or folders that the plugin should IGNORE. ' + + 'This uses "glob patterns" (similar to .gitignore syntax) to match paths.\n' + + '- Each pattern on a new line. If this list is empty, no files are ignored (plugin processes all).\n' + + '- Example to IGNORE: `My Folder/` or `*.log`\n' + + '- Example to PROCESS an item within an IGNORED path: `!My Folder/My Important File.md`\n' + + '- The last rule that matches a file determines its fate.' + ) + .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/**' + ); + text.inputEl.setAttr('rows', 10); // Увеличил немного для плейсхолдера + text.inputEl.style.width = '100%'; + + text.setValue(multilineStringValue); + + text.onChange(() => { + this.onChangeCallback(); + }); + }); + } + + 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; + } + // Ожидаем, что value это string[]. Если нет, будет ошибка времени выполнения при .join() + 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; + } +} From 8ac4b60509f728d320571c665a4592b52636e3c6 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 16 May 2025 01:41:36 +0300 Subject: [PATCH 07/28] refactor(settings): Make createFragmentWithHTML an instance method --- src/ui/basedClasses/BaseSettingComponent.ts | 14 +- .../pathGroup/PathGlobsSettingComponent.ts | 195 +++++++++++------- 2 files changed, 131 insertions(+), 78 deletions(-) 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 index 0dbbf7b..3c317f1 100644 --- a/src/ui/components/pathGroup/PathGlobsSettingComponent.ts +++ b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts @@ -3,89 +3,130 @@ 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; + private textInput: TextAreaComponent; - getSettingKey(): keyof CheckboxSyncPluginSettings { - return 'pathGlobs'; - } + 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 ''; - } + 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); + render(container: HTMLElement, currentValue: any): void { + const multilineStringValue = this.arrayToMultilineString(currentValue as string[] | undefined); - this.setting = new Setting(container) - .setName('Ignored Files & Folders') - .setDesc('List files or folders that the plugin should IGNORE. ' + - 'This uses "glob patterns" (similar to .gitignore syntax) to match paths.\n' + - '- Each pattern on a new line. If this list is empty, no files are ignored (plugin processes all).\n' + - '- Example to IGNORE: `My Folder/` or `*.log`\n' + - '- Example to PROCESS an item within an IGNORED path: `!My Folder/My Important File.md`\n' + - '- The last rule that matches a file determines its fate.' - ) - .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/**' - ); - text.inputEl.setAttr('rows', 10); // Увеличил немного для плейсхолдера - text.inputEl.style.width = '100%'; - - text.setValue(multilineStringValue); - - text.onChange(() => { - this.onChangeCallback(); - }); - }); - } + const descHtml = + `

List files or folders that the plugin should IGNORE. + This uses "glob patterns" (similar to .gitignore syntax) to match paths.

+
    +
  • Each pattern on a new line. If this list is empty, no files are ignored (plugin processes all).
  • +
  • Example to IGNORE: My Folder/ or *.log
  • +
  • Example to PROCESS an item within an IGNORED path: !My Folder/My Important File.md
  • +
  • The last rule that matches a file determines its fate.
  • +
  • For more on glob syntax, search "glob patterns online" or see + Glob (programming) on Wikipedia. +
  • +
`; - 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.`); - } + this.setting = new Setting(container) + .setName('Ignored Files & Folders') + // Используем 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/**' + ); - setValueInUi(value: any): void { - if (this.textInput) { - if (value === undefined) { - this.textInput.setValue(''); - return; - } - // Ожидаем, что value это string[]. Если нет, будет ошибка времени выполнения при .join() - 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.`); - } - } + text.setValue(multilineStringValue); - validate(): ValidationError | null { - try { - this.getValueFromUi(); - } catch (e: any) { - return { field: this.getSettingKey(), message: e.message }; - } - return null; - } + 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; + } } From 683b358053ef7fc1869b0aff9d2559fb020f4ed1 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 18 May 2025 18:21:50 +0300 Subject: [PATCH 08/28] feat(filter): Implement file filtering using 'ignore' library --- .gitignore | 48 ++--- __tests__/FileFilter.test.ts | 189 ++++++++++++++++++ package-lock.json | 52 ++++- package.json | 5 +- src/FileFilter.ts | 44 ++++ .../pathGroup/PathGlobsSettingComponent.ts | 28 +-- tsconfig.json | 16 +- 7 files changed, 326 insertions(+), 56 deletions(-) create mode 100644 __tests__/FileFilter.test.ts create mode 100644 src/FileFilter.ts 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/__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/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..4aa4e17 --- /dev/null +++ b/src/FileFilter.ts @@ -0,0 +1,44 @@ +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); + } +} diff --git a/src/ui/components/pathGroup/PathGlobsSettingComponent.ts b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts index 3c317f1..b63b0cb 100644 --- a/src/ui/components/pathGroup/PathGlobsSettingComponent.ts +++ b/src/ui/components/pathGroup/PathGlobsSettingComponent.ts @@ -27,32 +27,22 @@ export class PathGlobsSettingComponent extends BaseSettingComponent { const multilineStringValue = this.arrayToMultilineString(currentValue as string[] | undefined); const descHtml = - `

List files or folders that the plugin should IGNORE. - This uses "glob patterns" (similar to .gitignore syntax) to match paths.

-
    -
  • Each pattern on a new line. If this list is empty, no files are ignored (plugin processes all).
  • -
  • Example to IGNORE: My Folder/ or *.log
  • -
  • Example to PROCESS an item within an IGNORED path: !My Folder/My Important File.md
  • -
  • The last rule that matches a file determines its fate.
  • -
  • For more on glob syntax, search "glob patterns online" or see - Glob (programming) on Wikipedia. -
  • -
`; + `

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('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); 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"], } From e5f3318809dd3e8d5358a0db9c7771c5e2e43363 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 18 May 2025 18:42:36 +0300 Subject: [PATCH 09/28] feat(filter): Integrate FileFilter into plugin lifecycle --- src/FileFilter.ts | 10 ++++++++++ src/main.ts | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/src/FileFilter.ts b/src/FileFilter.ts index 4aa4e17..323879e 100644 --- a/src/FileFilter.ts +++ b/src/FileFilter.ts @@ -40,5 +40,15 @@ export class FileFilter { // Метод 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. Re-initializing ignorer."); // Для отладки + this.settings = newSettings; // Обновляем ссылку на настройки + this.initIgnorer(); // Переинициализируем ignorer с новыми pathGlobs } } diff --git a/src/main.ts b/src/main.ts index 379134d..f436c71 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,10 +26,12 @@ 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); @@ -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(); From a7cfbd70df248508a92224c3f98c1aa3b40c0775 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 18 May 2025 20:09:21 +0300 Subject: [PATCH 10/28] feat(filter): Implement path filtering in SyncController --- src/FileFilter.ts | 2 +- src/SyncController.ts | 162 +++++++++++++++++++++++------------------- src/main.ts | 2 +- 3 files changed, 91 insertions(+), 75 deletions(-) diff --git a/src/FileFilter.ts b/src/FileFilter.ts index 323879e..5a9ded7 100644 --- a/src/FileFilter.ts +++ b/src/FileFilter.ts @@ -47,7 +47,7 @@ export class FileFilter { * @param newSettings The new plugin settings. */ public updateSettings(newSettings: Readonly): void { - console.log("FileFilter: Settings updated. Re-initializing ignorer."); // Для отладки + 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 f436c71..6f45ea0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,7 @@ export default class CheckboxSyncPlugin extends Plugin { 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); From 9398dc67ecdc3ef8169f06d489bb76e32eef8d6b Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 18 May 2025 21:26:01 +0300 Subject: [PATCH 11/28] upd docs --- README.md | 3 ++- docs/changelog.md | 7 ++++++- docs/settings.md | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) 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/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** From b18acc10b1d0dd23ed8185dbfb62bf19dc94d223 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 18 May 2025 21:43:12 +0300 Subject: [PATCH 12/28] 123 --- src/events/FileChangeEventHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/FileChangeEventHandler.ts b/src/events/FileChangeEventHandler.ts index 93cb5e3..cd9f075 100644 --- a/src/events/FileChangeEventHandler.ts +++ b/src/events/FileChangeEventHandler.ts @@ -42,4 +42,4 @@ export class FileChangeEventHandler { }) ); } -} \ No newline at end of file +} From 5115988918a8f154522ff66884d2c74851aab56d Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Tue, 20 May 2025 02:33:50 +0300 Subject: [PATCH 13/28] implement feature --- __tests__/checkboxUtils.base.test.ts | 932 +++++++++++++++++++++ __tests__/checkboxUtils.plainLists.test.ts | 383 +++++++++ __tests__/checkboxUtils.test.ts | 774 ----------------- jest.config.ts | 3 +- src/checkboxUtils.ts | 390 +++++---- src/types.ts | 3 +- 6 files changed, 1531 insertions(+), 954 deletions(-) create mode 100644 __tests__/checkboxUtils.base.test.ts create mode 100644 __tests__/checkboxUtils.plainLists.test.ts delete mode 100644 __tests__/checkboxUtils.test.ts diff --git a/__tests__/checkboxUtils.base.test.ts b/__tests__/checkboxUtils.base.test.ts new file mode 100644 index 0000000..c277cec --- /dev/null +++ b/__tests__/checkboxUtils.base.test.ts @@ -0,0 +1,932 @@ +import { CheckboxLineInfo, CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу +import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов + +// Вспомогательная функция для создания настроек с переопределениями +const createSettings = (overrides: Partial = {}): Readonly => { + return { ...DEFAULT_SETTINGS, ...overrides } as Readonly; +}; + +describe('CheckboxUtils', () => { + let checkboxUtils: CheckboxUtils; + let settings: Readonly; + + beforeEach(() => { + // Используем настройки по умолчанию для большинства тестов + settings = createSettings(); + checkboxUtils = new CheckboxUtils(settings); + }); + + // --- Тесты для matchCheckboxLine --- + describe('matchCheckboxLine', () => { + it('should match standard unchecked checkbox', () => { + const line = '- [ ] Task'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '-', + checkChar: ' ', + checkboxCharPosition: 3, + checkboxState: CheckboxState.Unchecked, + isChecked: false, + listItemText: "Task", + }); + }); + + it('should match standard checked checkbox', () => { + const line = '* [x] Done'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '*', + checkChar: 'x', + checkboxCharPosition: 3, + checkboxState: CheckboxState.Checked, + isChecked: true, + listItemText: "Done", + }); + }); + + it('should match checkbox with indentation', () => { + const line = ' + [x] Indented Task'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 2, + marker: '+', + checkChar: 'x', + checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5 + checkboxState: CheckboxState.Checked, + isChecked: true, + listItemText: "Indented Task", + }); + }); + + it('should match numbered list checkbox', () => { + const line = '1. [ ] Numbered'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '1.', + checkChar: ' ', + checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4 + checkboxState: CheckboxState.Unchecked, + isChecked: false, + listItemText: "Numbered", + }); + }); + + it('should match checkbox with multi-digit numbered list', () => { + const line = '10. [x] Double Digit'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '10.', + checkChar: 'x', + checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5 + checkboxState: CheckboxState.Checked, + isChecked: true, + listItemText: "Double Digit", + }); + }); + + it('should return null for lines without checkbox format', () => { + expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull(); + expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ] + expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside + expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker + expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker + }); + + it('should match custom symbols based on settings', () => { + const customSettings = createSettings({ + checkedSymbols: ['X', 'V'], + uncheckedSymbols: ['O', '-'], + ignoreSymbols: ['~'], + }); + const customUtils = new CheckboxUtils(customSettings); + + const checkedLine = '- [V] Custom Checked'; + const checkedResult = customUtils.matchCheckboxLine(checkedLine); + expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked); + expect(checkedResult?.isChecked).toBe(true); + expect(checkedResult?.checkChar).toBe('V'); + + const uncheckedLine = '* [O] Custom Unchecked'; + const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine); + expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked); + expect(uncheckedResult?.isChecked).toBe(false); + expect(uncheckedResult?.checkChar).toBe('O'); + + const ignoredLine = '+ [~] Custom Ignored'; + const ignoredResult = customUtils.matchCheckboxLine(ignoredLine); + expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore); + expect(ignoredResult?.isChecked).toBeUndefined(); + expect(ignoredResult?.checkChar).toBe('~'); + }); + }); + + // --- Тесты для getCheckboxState --- + describe('getCheckboxState', () => { + it('should return Checked for symbols in checkedSymbols', () => { + const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] }); + const customUtils = new CheckboxUtils(customSettings); + expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked); + expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked); + expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked); + }); + + it('should return Unchecked for symbols in uncheckedSymbols', () => { + const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] }); + const customUtils = new CheckboxUtils(customSettings); + expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked); + expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked); + expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked); + }); + + it('should return Ignore for symbols in ignoreSymbols', () => { + const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] }); + const customUtils = new CheckboxUtils(customSettings); + expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore); + expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore); + expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore); + }); + + it('should use unknownSymbolPolicy for symbols not in any list', () => { + const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked }); + const utilsChecked = new CheckboxUtils(settingsChecked); + expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked); + + const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked }); + const utilsUnchecked = new CheckboxUtils(settingsUnchecked); + expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked); + + const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore }); + const utilsIgnore = new CheckboxUtils(settingsIgnore); + expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore); + }); + }); + + // --- Тесты для updateLineCheckboxStateWithInfo --- + describe('updateLineCheckboxStateWithInfo', () => { + it('should update checkbox state from unchecked to checked', () => { + const line = '- [ ] Task'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); + // Uses the first symbol from settings.checkedSymbols ('x' by default) + expect(updatedLine).toBe('- [x] Task'); + }); + + it('should update checkbox state from checked to unchecked', () => { + const line = '* [x] Done'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo); + // Uses the first symbol from settings.uncheckedSymbols (' ' by default) + expect(updatedLine).toBe('* [ ] Done'); + }); + + it('should use first symbol from settings for update', () => { + const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] }); + const customUtils = new CheckboxUtils(customSettings); + + const lineToCheck = '- [O] Task'; + const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!; + const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck); + expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V' + + const lineToUncheck = '* [V] Done'; + const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!; + const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck); + expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O' + }); + + it('should handle indented lines correctly', () => { + const line = ' - [ ] Indented'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); + expect(updatedLine).toBe(' - [x] Indented'); + }); + + it('should return original line if position is invalid (simulated)', () => { + const line = '- [ ] Task'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; + // Simulate invalid position + const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 }; + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo); + expect(updatedLine).toBe(line); // Should not change + }); + + it('should use default checked symbol "x" if checkedSymbols setting is empty', () => { + // Создаем настройки с пустым списком checkedSymbols + const customSettings = createSettings({ checkedSymbols: [] }); + const customUtils = new CheckboxUtils(customSettings); + const line = '- [ ] Task'; + const lineInfo = customUtils.matchCheckboxLine(line)!; + + // Пытаемся отметить чекбокс + const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); + expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x' + }); + + it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => { + // Создаем настройки с пустым списком uncheckedSymbols + const customSettings = createSettings({ uncheckedSymbols: [] }); + const customUtils = new CheckboxUtils(customSettings); + const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols + const lineInfo = customUtils.matchCheckboxLine(line)!; + + // Пытаемся снять отметку + const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo); + expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел) + }); + + // Усиленный тест для невалидной позиции (с проверкой console.warn) + it('should return original line and warn if position is invalid', () => { + const line = '- [ ] Task'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; + // Мокаем console.warn + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); + + // Simulate invalid position + const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция + const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo); + expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку + expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn + + const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция + const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2); + expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку + expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз + + // Восстанавливаем оригинальную функцию console.warn + warnSpy.mockRestore(); + }); + + it('should return original line and warn if checkboxCharPosition is invalid for a valid checkbox type', () => { + const line = '- [ ] Task'; + const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Это валидный чекбокс + const invalidPosInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidPosInfo); + expect(updatedLine).toBe(line); + expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); + warnSpy.mockRestore(); + }); + + it('UPDATE_LINE: should return original line and warn if lineInfo is for NoCheckbox', () => { + const line = "- Plain item"; + // Создаем lineInfo, как будто он пришел от plain list item + const plainLineInfo: CheckboxLineInfo = { + indent: 0, + marker: '-', + checkboxState: CheckboxState.NoCheckbox, // Ключевой момент + listItemText: 'Plain item', + // checkboxCharPosition не важен, т.к. до него не дойдет + }; + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); + + const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, plainLineInfo); + + expect(updatedLine).toBe(line); // Строка не должна измениться + expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line); + + warnSpy.mockRestore(); + }); + }); + + // --- Тесты для propagateStateToChildren --- + describe('propagateStateToChildren', () => { + const setupUtils = (customSettings?: Partial) => { + return new CheckboxUtils(createSettings(customSettings)); + } + + it('should check children when parent is checked', () => { + const text = [ + '- [x] Parent', // line 0 + ' - [ ] Child 1', + ' - [ ] Grandchild', + ' - [ ] Child 2', + '- [ ] Sibling', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child 1', + ' - [x] Grandchild', + ' - [x] Child 2', + '- [ ] Sibling', // Sibling should not change + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should uncheck children when parent is unchecked', () => { + const text = [ + '- [ ] Parent', // line 0 + ' - [x] Child 1', + ' - [x] Grandchild', + ' - [ ] Child 2', // Already unchecked + '- [x] Sibling', + ].join('\n'); + const expected = [ + '- [ ] Parent', + ' - [ ] Child 1', + ' - [ ] Grandchild', + ' - [ ] Child 2', + '- [x] Sibling', // Sibling should not change + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should stop propagation at siblings or less indented lines', () => { + const text = [ + ' - [x] Parent', // line 0 + ' - [ ] Child', + ' - [ ] Sibling', // Same indent level + '- [ ] Less Indented', + ].join('\n'); + const expected = [ + ' - [x] Parent', + ' - [x] Child', // Changed + ' - [ ] Sibling', // Not changed + '- [ ] Less Indented', // Not changed + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should skip ignored children and their subtrees', () => { + const utils = setupUtils({ ignoreSymbols: ['~'] }); + const text = [ + '- [x] Parent', // 0 + ' - [ ] Child 1', // 1 + ' - [~] Ignored Child',// 2 (ignore) + ' - [ ] Skipped GC', // 3 (skipped because parent ignored) + ' - [ ] Child 2', // 4 + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child 1', // Changed + ' - [~] Ignored Child',// Unchanged + ' - [ ] Skipped GC', // Unchanged + ' - [x] Child 2', // Changed + ].join('\n'); + expect(utils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should do nothing if the parent line is not a checkbox', () => { + const text = [ + 'Parent Line', + ' - [ ] Child', + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text); + }); + + it('should do nothing if the parent checkbox is ignored', () => { + const utils = setupUtils({ ignoreSymbols: ['~'] }); + const text = [ + '- [~] Ignored Parent', // 0 + ' - [ ] Child', + ].join('\n'); + expect(utils.propagateStateToChildren(text, 0)).toBe(text); + }); + + it('should return original text and warn if parent line index does not contain a list item', () => { + const text = "Not a list item\n - [ ] Child"; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); + const result = checkboxUtils.propagateStateToChildren(text, 0); + expect(result).toBe(text); + expect(warnSpy).toHaveBeenCalledWith("checkbox not found in line 0"); + warnSpy.mockRestore(); + }); + + it('should stop propagation if a non-list-item line is encountered among children', () => { + const text = [ + '- [x] Parent', + ' Not a child list item', // This will make childLineInfo null + ' - [ ] Grandchild (should not be processed)', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' Not a child list item', + ' - [ ] Grandchild (should not be processed)', + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should correctly stop processing sub-children of an ignored node if a non-list-item is found', () => { + const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] })); + const text = [ + '- [x] Parent', + ' - [~] Ignored Child', + ' - [ ] Valid Sub-Child (will be skipped by inner loop)', // j инкрементируется + ' Not a sub-child list item', // subChildLineInfo = null, inner loop breaks + ' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)', + ' - [ ] Next Sibling of Ignored', // This should also not be processed due to outer loop break + ].join('\n'); + + // Ожидаем, что Parent и Ignored Child останутся, а остальное не изменится, + // потому что внешний цикл прервется после обработки "Not a sub-child list item" + const expected = [ + '- [x] Parent', + ' - [~] Ignored Child', + ' - [ ] Valid Sub-Child (will be skipped by inner loop)', + ' Not a sub-child list item', + ' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)', + ' - [ ] Next Sibling of Ignored', + ].join('\n'); + + const result = utils.propagateStateToChildren(text, 0); + expect(result).toBe(expected); + // В данном случае, поскольку Parent [x], и мы ничего не меняем, результат будет равен text. + // Важно то, что Next Sibling of Ignored не стал [x]. + }); + }); + + // --- Тесты для propagateStateFromChildren --- + describe('propagateStateFromChildren', () => { + const setupUtils = (customSettings?: Partial) => { + return new CheckboxUtils(createSettings(customSettings)); + } + + it('should check parent if all children are checked', () => { + const text = [ + '- [ ] Parent', // line 0 + ' - [x] Child 1', + ' - [x] Child 2', + ' - [x] Grandchild', // Needs parent (Child 2) to be checked first + ].join('\n'); + // Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent + const expectedPass1 = [ // After processing Grandchild and Child 2 + '- [ ] Parent', + ' - [x] Child 1', + ' - [x] Child 2', // Stays checked + ' - [x] Grandchild', + ].join('\n'); + const expectedPass2 = [ // After processing Child 1 and Parent + '- [x] Parent', // Changes to checked + ' - [x] Child 1', + ' - [x] Child 2', + ' - [x] Grandchild', + ].join('\n'); + // Since it processes bottom-up, Child 2 should already be evaluated correctly + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2); + }); + + it('should uncheck parent if any child is unchecked', () => { + const text = [ + '- [x] Parent', // line 0 + ' - [x] Child 1', + ' - [ ] Child 2', // This one makes the parent unchecked + ' - [ ] Grandchild', + ].join('\n'); + const expected = [ + '- [ ] Parent', // Changes to unchecked + ' - [x] Child 1', + ' - [ ] Child 2', + ' - [ ] Grandchild', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + + it('should handle nested updates correctly (bottom-up)', () => { + const text = [ + '- [ ] Grandparent', // 0 + ' - [ ] Parent 1', // 1 + ' - [x] Child 1.1',// 2 + ' - [x] Child 1.2',// 3 + ' - [x] Parent 2', // 4 + ' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked) + ].join('\n'); + const expected = [ + '- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked) + ' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked) + ' - [x] Child 1.1', + ' - [x] Child 1.2', + ' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked) + ' - [ ] Child 2.1', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + + it('should not change parent state if it has no relevant children', () => { + const text = [ + '- [ ] Parent', + 'Sibling', // Not a child checkbox + ' Not indented correctly', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text); + }); + + it('should ignore non-checkbox lines when determining parent state', () => { + const text = [ + '- [ ] Parent', + ' - [x] Child 1', + ' Just text', + ' - [x] Child 2', + ].join('\n'); + const expected = [ + '- [x] Parent', // Should become checked + ' - [x] Child 1', + ' Just text', + ' - [x] Child 2', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + + it('should skip ignored children when determining parent state', () => { + const utils = setupUtils({ ignoreSymbols: ['~'] }); + const text = [ + '- [ ] Parent', // 0 + ' - [x] Child 1', // 1 + ' - [~] Ignored Child',// 2 (skipped) + ' - [ ] Skipped GC', // 3 (doesn't affect parent) + ' - [x] Child 2', // 4 + ].join('\n'); + const expected = [ + '- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only) + ' - [x] Child 1', + ' - [~] Ignored Child', + ' - [ ] Skipped GC', + ' - [x] Child 2', + ].join('\n'); + expect(utils.propagateStateFromChildren(text)).toBe(expected); + }); + + it('should not change parent state if all direct children are ignored', () => { + const utils = setupUtils({ ignoreSymbols: ['~'] }); + const text = [ + '- [ ] Parent', // 0 + ' - [~] Ignored 1', // 1 + ' - [~] Ignored 2', // 2 + ].join('\n'); + expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked + const text2 = [ + '- [x] Parent', // 0 + ' - [~] Ignored 1', // 1 + ' - [~] Ignored 2', // 2 + ].join('\n'); + expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked + }); + + it('should not update ignored parents', () => { + const utils = setupUtils({ ignoreSymbols: ['~'] }); + const text = [ + '- [~] Ignored Parent', // 0 + ' - [x] Child 1', + ' - [x] Child 2', + ].join('\n'); + expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored + }); + + it('should stop searching children when a line with equal or lesser indent is found', () => { + const text = [ + '- [ ] Parent 1', // indent 0 + 'Some regular text', // indent 0. Stops search for Parent 1 children. + ' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1. + '- [ ] Parent 2', // indent 0. Processed independently. + ' - [x] Child 2.1', // indent 2. Child of Parent 2. + ].join('\n'); + + const result = checkboxUtils.propagateStateFromChildren(text); + + const expected = [ + '- [ ] Parent 1', // Unchanged (no children found before stop) + 'Some regular text', + ' - [x] Child 1.1', + '- [x] Parent 2', // Updated by Child 2.1 + ' - [x] Child 2.1', + ].join('\n'); + expect(result).toBe(expected); + }); + + + it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => { + const text = [ + '- [ ] Parent 1', // indent 0 + '- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children. + ' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent. + ].join('\n'); + + const result = checkboxUtils.propagateStateFromChildren(text); + + const expected = [ + '- [ ] Parent 1', // Unchanged (no children found before stop) + '- [x] Sibling Parent', // Updated by Child SP.1 + ' - [x] Child SP.1', + ].join('\n'); + expect(result).toBe(expected); + + const text2 = [ + ' - [ ] Parent 1', // indent 2 + ' - [x] Child 1.1', // indent 4 + ' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children. + ' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent. + ].join('\n'); + const result2 = checkboxUtils.propagateStateFromChildren(text2); + const expected2 = [ + ' - [x] Parent 1', // Updated by Child 1.1 + ' - [x] Child 1.1', + ' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked) + ' - [ ] Child SP.1', + ].join('\n'); + expect(result2).toBe(expected2); + }); + + it('should ignore non-list-item lines when iterating upwards', () => { + const text = [ + '- [ ] Parent', + ' - [x] Child', + 'Just some random text in between', // parentLineInfo will be null for this + '- [ ] Another Parent', + ' - [x] Another Child', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child', + 'Just some random text in between', + '- [x] Another Parent', + ' - [x] Another Child', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + }); + + // --- Тесты для syncText --- + describe('syncText', () => { + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterParentChecked = [ // Simulate user checking parent + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterChildChecked = [ // Simulate user checking child + '- [ ] Parent', + ' - [x] Child', + ].join('\n'); + + it('should propagate down only if child sync enabled and diff matches', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: false, // Parent sync off + })); + const expected = [ + '- [x] Parent', + ' - [x] Child', // Propagated down + ].join('\n'); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected); + }); + + it('should propagate up only if parent sync enabled', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: false, // Child sync off + enableAutomaticParentState: true, + })); + const expected = [ + '- [x] Parent', // Propagated up + ' - [x] Child', + ].join('\n'); + // syncText calls propagateFromChildren unconditionally if enabled + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected); + }); + + it('should propagate down then up if both enabled', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + // 1. User checks parent: '- [x] Parent', ' - [ ] Child' + // 2. Propagate down: '- [x] Parent', ' - [x] Child' + // 3. Propagate up: (no change needed as parent is already checked) + const expectedDown = [ + '- [x] Parent', + ' - [x] Child', // Propagated down + ].join('\n'); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown); + + // 1. User checks child: '- [ ] Parent', ' - [x] Child' + // 2. Propagate down: (no change, as only one line changed, and it wasn't the parent) + // 3. Propagate up: '- [x] Parent', ' - [x] Child' + const expectedUp = [ + '- [x] Parent', // Propagated up + ' - [x] Child', + ].join('\n'); + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp); + }); + + it('should do nothing if both syncs disabled', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: false, + enableAutomaticParentState: false, + })); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked); + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked); + }); + + it('should not propagate down if diff is not a single checkbox state change', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: true, // Down propagation enabled + enableAutomaticParentState: false, + })); + const textMultipleChanges = [ + '- [x] Parent Changed', // Text changed too + ' - [x] Child also changed', + ].join('\n'); + const textIndentChange = [ + ' - [x] Parent', // Indent changed + ' - [ ] Child', + ].join('\n'); + + // Multiple lines changed + expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges); + // Indent changed (diff > 1 line index) + expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange); + }); + }); + + // --- Тесты для propagateStateToChildrenFromSingleDiff --- + describe('propagateStateToChildrenFromSingleDiff', () => { + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + + it('should propagate if only checkbox state changed', () => { + const textAfter = [ + '- [x] Parent', // Only state changed + ' - [ ] Child', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child', // Propagated + ].join('\n'); + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected); + }); + + it('should propagate if text content also changed', () => { + const textAfter = [ + '- [x] Parent Changed Text', // State and text changed + ' - [ ] Child', + ].join('\n'); + const expected = [ + '- [x] Parent Changed Text', + ' - [x] Child', // Propagated + ].join('\n'); + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected); + }); + + it('should NOT propagate if marker changed', () => { + const textAfter = [ + '* [x] Parent', // Marker changed from - to * + ' - [ ] Child', + ].join('\n'); + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should NOT propagate if indentation changed', () => { + const textAfter = [ + ' - [x] Parent', // Indentation changed + ' - [ ] Child', + ].join('\n'); + // This will be detected as > 1 line difference by findDifferentLineIndexes + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + + it('should NOT propagate if multiple lines changed', () => { + const textAfter = [ + '- [x] Parent', + ' - [x] Child', // Second line also changed + ].join('\n'); + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should NOT propagate if changed line is not a checkbox', () => { + const textBeforeNonCheck = "Line 1\nLine 2"; + const textAfterNonCheck = "Line 1 changed\nLine 2"; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck); + }); + + it('should propagate if changed line is an ignored checkbox', () => { + const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] })); + const textBeforeIgnored = [ + '- [~] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterIgnored = [ + '- [x] Parent', // Changed FROM ignored TO checked + ' - [ ] Child', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child', // Propagated + ].join('\n'); + + expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected); + }); + + it('should return original text if textBefore is undefined', () => { + const text = '- [ ] Line'; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text); + }); + + it('should return original text if line counts differ', () => { + const text1 = '- [ ] Line1\n- [ ] Line2'; + const text2 = '- [ ] Line1'; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1); + }); + + it('should return original text if textBefore and text have different line counts', () => { + const textBefore = "- [ ] Line 1"; + const text = "- [ ] Line 1\n- [ ] Line 2"; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, textBefore)).toBe(text); + }); + + it('should return original text if no lines changed (diffIndexes.length === 0)', () => { + const text = "- [ ] Task"; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, text)).toBe(text); + }); + + it('should return original text if multiple lines changed (diffIndexes.length > 1)', () => { + const textBefore = "- [ ] Task1\n- [ ] Task2"; + const textAfter = "- [x] Task1\n- [x] Task2"; // 2 diffs + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should not propagate if changed line was not a list item before', () => { + const textBefore = "Not a list item"; + const textAfter = "- [x] Became a list item"; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should not propagate if changed line is not a list item after', () => { + const textBefore = "- [ ] Was a list item"; + const textAfter = "Not a list item anymore"; + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should not propagate if indent changed', () => { + const textBeforeIndent = [ + "- [ ] Parent", + " - [ ] Child", + ].join('\n'); + const textAfterIndent = [ + " - [ ] Parent", + " - [ ] Child", + ].join('\n'); // Parent indent changed + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterIndent, textBeforeIndent)).toBe(textAfterIndent); + }); + + it('should not propagate if marker changed', () => { + const textBefore = "- [ ] Item\n - [ ] Child"; + const textAfter = "* [ ] Item\n - [ ] Child"; // Marker changed + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + + it('should not propagate if checkbox state did not change (e.g., only text content)', () => { + const textBefore = "- [x] Task Alpha\n - [ ] Child"; + const textAfter = "- [x] Task Bravo\n - [ ] Child"; // Only text changed + expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); + }); + }); + + // --- Тесты для findDifferentLineIndexes --- + describe('findDifferentLineIndexes', () => { + it('should return empty array for identical lines', () => { + const lines1 = ['a', 'b', 'c']; + const lines2 = ['a', 'b', 'c']; + expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]); + }); + + it('should return index of the single different line', () => { + const lines1 = ['a', 'b', 'c']; + const lines2 = ['a', 'B', 'c']; + expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]); + }); + + it('should return indexes of multiple different lines', () => { + const lines1 = ['a', 'b', 'c', 'd']; + const lines2 = ['A', 'b', 'C', 'd']; + expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]); + }); + + it('should detect differences at start and end', () => { + const lines1 = ['a', 'b', 'c']; + const lines2 = ['X', 'b', 'Y']; + expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]); + }); + + it('should throw error if line arrays have different lengths', () => { + const lines1 = ['a', 'b']; + const lines2 = ['a', 'b', 'c']; + expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2)) + .toThrow("the length of the lines must be equal"); + expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1)) + .toThrow("the length of the lines must be equal"); + }); + }); + +}); diff --git a/__tests__/checkboxUtils.plainLists.test.ts b/__tests__/checkboxUtils.plainLists.test.ts new file mode 100644 index 0000000..45bd743 --- /dev/null +++ b/__tests__/checkboxUtils.plainLists.test.ts @@ -0,0 +1,383 @@ +// checkboxUtils.plainLists.test.ts + +import { CheckboxUtils } from '../src/checkboxUtils'; +import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; + +// Вспомогательная функция для создания настроек с переопределениями +const createSettings = (overrides: Partial = {}): Readonly => { + return { ...DEFAULT_SETTINGS, ...overrides } as Readonly; +}; + +describe('CheckboxUtils - Plain List Item Support', () => { + let checkboxUtils: CheckboxUtils; + let settings: Readonly; + + beforeEach(() => { + settings = createSettings(); + checkboxUtils = new CheckboxUtils(settings); + }); + + // --- Тесты для matchCheckboxLine с поддержкой plain list items --- + describe('matchCheckboxLine with Plain List Items', () => { + it('should match a plain bullet list item', () => { + const line = '- Plain item text'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '-', + checkboxState: CheckboxState.NoCheckbox, + listItemText: 'Plain item text', + // Опциональные поля для чекбокса должны быть undefined + checkChar: undefined, + checkboxCharPosition: undefined, + isChecked: undefined, + }); + }); + + it('should match a plain asterisk list item with indentation', () => { + const line = ' * Indented plain item'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 2, + marker: '*', + checkboxState: CheckboxState.NoCheckbox, + listItemText: 'Indented plain item', + }); + }); + + it('should match a plain plus list item', () => { + const line = '+ Another plain item'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '+', + checkboxState: CheckboxState.NoCheckbox, + listItemText: 'Another plain item', + }); + }); + + it('should match a plain numbered list item', () => { + const line = '1. Numbered plain item'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '1.', + checkboxState: CheckboxState.NoCheckbox, + listItemText: 'Numbered plain item', + }); + }); + + it('should match a plain multi-digit numbered list item', () => { + const line = '10. Long numbered plain item'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '10.', + checkboxState: CheckboxState.NoCheckbox, + listItemText: 'Long numbered plain item', + }); + }); + + it('should still match a standard checkbox item', () => { + const line = '- [x] A checkbox item'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result?.checkboxState).toBe(CheckboxState.Checked); + expect(result?.isChecked).toBe(true); + expect(result?.checkChar).toBe('x'); + expect(result?.listItemText).toBe('A checkbox item'); + }); + + it('should match a plain list item with no text after marker', () => { + const line = '- '; // Marker and space + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '-', + checkboxState: CheckboxState.NoCheckbox, + listItemText: '', // Empty string for text + }); + }); + + it('should match a plain list item with only spaces after marker', () => { + const line = '* '; // Marker and spaces + const result = checkboxUtils.matchCheckboxLine(line); + expect(result).not.toBeNull(); + expect(result).toEqual({ + indent: 0, + marker: '*', + checkboxState: CheckboxState.NoCheckbox, + listItemText: '', // Text is trimmed + }); + }); + + it('should correctly parse listItemText for checkboxes', () => { + const line = '- [ ] Task with text'; + const result = checkboxUtils.matchCheckboxLine(line); + expect(result?.listItemText).toBe('Task with text'); + + const lineNoText = '- [x] '; + const resultNoText = checkboxUtils.matchCheckboxLine(lineNoText); + expect(resultNoText?.listItemText).toBe(''); + }); + + it('should return null for lines that are not list items or malformed', () => { + expect(checkboxUtils.matchCheckboxLine('Just some text')).toBeNull(); + expect(checkboxUtils.matchCheckboxLine('-NoSpaceAfterMarker')).toBeNull(); + expect(checkboxUtils.matchCheckboxLine('1.NoSpaceAfterMarker')).toBeNull(); + expect(checkboxUtils.matchCheckboxLine('> Not a list marker')).toBeNull(); + // Это все еще НЕ чекбокс и НЕ plain list item по текущим правилам (нет пробела после маркера) + expect(checkboxUtils.matchCheckboxLine('-[ ] No space after marker for checkbox')).toBeNull(); + // Это plain list item, а не чекбокс, т.к. нет пробела перед [ ] + const trickyLine = "-[ ] Not a checkbox, but could be a plain item if regex allowed marker immediately followed by non-space"; + // По текущему regex ^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$ для plain list, это будет null. + // Если бы regex для plain list был ^(\s*)([*+-]|\d+\.)\s*(.*)$, и мы бы отсекали чекбоксы отдельно, + // то "-[ ] text" мог бы считаться plain. Но текущий regex для plain требует \s+ после маркера. + // А regex для чекбокса требует " \[" . Так что "-[ ] text" не матчится ни тем, ни другим. + expect(checkboxUtils.matchCheckboxLine(trickyLine)).toBeNull(); + + // A line that looks like a checkbox but is missing the char inside brackets + expect(checkboxUtils.matchCheckboxLine('- [] Text')).toBeNull(); // Checkbox regex expects a char + // A line that looks like a checkbox but missing space after brackets (and thus missing listItemText part) + expect(checkboxUtils.matchCheckboxLine('- [x]')).toBeNull(); // Checkbox regex expects \s after [.] + }); + }); + + // --- Тесты для propagateStateToChildren с Plain List Items --- + describe('propagateStateToChildren with Plain List Items', () => { + it('should not change plain list item children', () => { + const text = [ + '- [x] Parent Checkbox', + ' - Plain Child Item', + ' * [ ] Another Checkbox Child', + ' - Deep Plain Child', + ].join('\n'); + const expected = [ + '- [x] Parent Checkbox', + ' - Plain Child Item', // Unchanged + ' * [x] Another Checkbox Child', // Changed + ' - Deep Plain Child', // Unchanged + ].join('\n'); + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should not propagate from a plain list item parent', () => { + const text = [ + '- Parent Plain Item', // line 0 + ' - [ ] Child Checkbox', + ' * Plain Grandchild', + ].join('\n'); + // No changes expected as parent is NoCheckbox + expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text); + }); + + it('should skip ignored children and not skip childs plain list items correctly', () => { + const customSettings = createSettings({ ignoreSymbols: ['~'] }); + const utils = new CheckboxUtils(customSettings); + const text = [ + '- [x] Parent', // 0 + ' - Plain Child', // 1 (NoCheckbox) + ' - [ ] Grandchild CB',// 2 (Checkbox under NoCheckbox, should be affected by Parent) + ' - [~] Ignored Child', // 3 (Ignore) + ' - [ ] Skipped GC', // 4 (Checkbox under Ignore, should not be affected) + ' - [ ] Checkbox Child', // 5 (Checkbox) + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - Plain Child', // Unchanged + ' - [x] Grandchild CB',// Changed + ' - [~] Ignored Child', // Unchanged + ' - [ ] Skipped GC', // Unchanged + ' - [x] Checkbox Child', // Changed + ].join('\n'); + expect(utils.propagateStateToChildren(text, 0)).toBe(expected); + }); + + it('should stop propagation if a non-list-item line is encountered (indent > parent)', () => { + const text = [ + '- [x] Parent', + ' This is just text, not a list item.', // childLineInfo будет null, indent > parent.indent. Цикл прервется. + ' - [ ] Grandchild (should not be processed as loop breaks)', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' This is just text, not a list item.', + ' - [ ] Grandchild (should not be processed as loop breaks)', + ].join('\n'); + // Проверяем, что Grandchild не изменился + const result = checkboxUtils.propagateStateToChildren(text, 0); + expect(result).toBe(expected); + // Дополнительно можно проверить, что matchCheckboxLine для "Grandchild" не вызывался бы + // (если это важно, но для покрытия достаточно проверки результата) + }); + + }); + + // --- Тесты для propagateStateFromChildren с Plain List Items --- + describe('propagateStateFromChildren with Plain List Items', () => { + it('should not update a plain list item parent based on children', () => { + const text = [ + '- Parent Plain Item', + ' - [x] Child Checkbox 1', + ' - [x] Child Checkbox 2', + ].join('\n'); + // Parent is NoCheckbox, so it should not change + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text); + }); + + it('should not ignore plain list item children when determining parent checkbox state', () => { + const text = [ + '- [ ] Parent Checkbox', + ' - Plain Child 1', + ' - [x] Actual Checkbox Child', + ' * Plain Child 2', + ' - [ ] Grandchild (under plain, ignored for Parent Checkbox)', + ].join('\n'); + const expected = [ + '- [ ] Parent Checkbox', // Becomes checked because "Actual Checkbox Child" is checked + ' - Plain Child 1', + ' - [x] Actual Checkbox Child', + ' * Plain Child 2', + ' - [ ] Grandchild (under plain, ignored for Parent Checkbox)', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + + it('parent checkbox remains unchecked if all relevant children are plain or ignored', () => { + const customSettings = createSettings({ ignoreSymbols: ['~'] }); + const utils = new CheckboxUtils(customSettings); + const text = [ + '- [ ] Parent Checkbox', + ' - Plain Child', + ' - [~] Ignored Child', + ].join('\n'); + // No actual checkbox children to influence the parent + expect(utils.propagateStateFromChildren(text)).toBe(text); + + const text2 = [ + '- [x] Parent Checkbox Initially Checked', + ' - Plain Child', + ' - [~] Ignored Child', + ].join('\n'); + // Parent should remain checked as no relevant children to change it + expect(utils.propagateStateFromChildren(text2)).toBe(text2); + }); + + it('parent checkbox becomes unchecked if it has one unchecked checkbox child and other plain/ignored children', () => { + const text = [ + '- [x] Parent Checkbox', + ' - Plain Child', + ' - [ ] Unchecked Checkbox Child', // This will make parent unchecked + ' - [x] Checked Checkbox Child (but one is enough)', + ].join('\n'); + // Note: propagateStateFromChildren processes from bottom up. + // The order of children doesn't matter as much as their collective state. + // Let's simplify to ensure clarity: + const simplerText = [ + '- [x] Parent Checkbox', + ' - Plain Child', + ' - [ ] Unchecked Checkbox Child', + ].join('\n'); + const expected = [ + '- [ ] Parent Checkbox', // Becomes unchecked + ' - Plain Child', + ' - [ ] Unchecked Checkbox Child', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(simplerText)).toBe(expected); + }); + + it('handles mixed children correctly, bottom-up', () => { + const text = [ + '- [ ] GP', // 0 + ' - Plain Child of GP', // 1 + ' - [ ] P1 (Checkbox)', // 2 + ' - [x] C1.1 (Checkbox)', // 3 + ' - Plain Grandchild', // 4 + ' - [x] P2 (Checkbox)', // 5 (initially checked) + ' - Plain Grandchild 2', // 6 + ' - [ ] C2.1 (Unchecked CB)',// 7 + ].join('\n'); + + const expected = [ + '- [ ] GP', // P1 becomes [x], P2 becomes [ ]. So GP is [ ] + ' - Plain Child of GP', + ' - [x] P1 (Checkbox)', // Becomes [x] due to C1.1 + ' - [x] C1.1 (Checkbox)', + ' - Plain Grandchild', + ' - [ ] P2 (Checkbox)', // Becomes [ ] due to C2.1 + ' - Plain Grandchild 2', + ' - [ ] C2.1 (Unchecked CB)', + ].join('\n'); + expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); + }); + }); + + // --- Тесты для syncText с Plain List Items (влияние на propagateStateToChildrenFromSingleDiff) --- + describe('syncText (and propagateStateToChildrenFromSingleDiff) with Plain List Items', () => { + const utils = new CheckboxUtils(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, // Keep true to see full effect if needed, though child is main focus + })); + + it('should propagate down if a line changes from Plain to Checked Checkbox', () => { + const textBefore = [ + '- Parent Plain Item', // Was plain + ' - [ ] Child Checkbox', + ' - Plain Grandchild', + ].join('\n'); + const textAfter = [ + '- [x] Parent Plain Item', // Now a checked checkbox + ' - [ ] Child Checkbox', + ' - Plain Grandchild', + ].join('\n'); + const expected = [ + '- [x] Parent Plain Item', + ' - [x] Child Checkbox', // Propagated + ' - Plain Grandchild', // Unchanged (it's plain) + ].join('\n'); + expect(utils.syncText(textAfter, textBefore)).toBe(expected); + }); + + it('should NOT propagate down if a line changes from Checked Checkbox to Plain', () => { + const textBefore = [ + '- [x] Parent Checkbox', // Was checkbox + ' - [ ] Child Checkbox', // This would have been checked by propagation + ' - Plain Grandchild', + ].join('\n'); + const textAfter = [ + '- Parent Checkbox', // Now plain + ' - [ ] Child Checkbox', + ' - Plain Grandchild', + ].join('\n'); + // propagateStateToChildrenFromSingleDiff will call propagateStateToChildren + // propagateStateToChildren will see the parent is NoCheckbox and do nothing. + // Then propagateStateFromChildren runs, but parent is NoCheckbox. + expect(utils.syncText(textAfter, textBefore)).toBe(textAfter); + }); + + it('should correctly propagate up if a child changes, parent is checkbox, and siblings are plain', () => { + const textBefore = [ + '- [ ] Parent Checkbox', + ' - Plain Sibling', + ' - [ ] Child Checkbox that changes', + ].join('\n'); + const textAfter = [ + '- [ ] Parent Checkbox', + ' - Plain Sibling', + ' - [x] Child Checkbox that changes', // User checks this + ].join('\n'); + const expected = [ + '- [x] Parent Checkbox', // Propagates up + ' - Plain Sibling', + ' - [x] Child Checkbox that changes', + ].join('\n'); + expect(utils.syncText(textAfter, textBefore)).toBe(expected); + }); + }); +}); diff --git a/__tests__/checkboxUtils.test.ts b/__tests__/checkboxUtils.test.ts deleted file mode 100644 index 027035d..0000000 --- a/__tests__/checkboxUtils.test.ts +++ /dev/null @@ -1,774 +0,0 @@ -import { CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу -import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов - -// Вспомогательная функция для создания настроек с переопределениями -const createSettings = (overrides: Partial = {}): Readonly => { - return { ...DEFAULT_SETTINGS, ...overrides } as Readonly; -}; - -describe('CheckboxUtils', () => { - let checkboxUtils: CheckboxUtils; - let settings: Readonly; - - beforeEach(() => { - // Используем настройки по умолчанию для большинства тестов - settings = createSettings(); - checkboxUtils = new CheckboxUtils(settings); - }); - - // --- Тесты для matchCheckboxLine --- - describe('matchCheckboxLine', () => { - it('should match standard unchecked checkbox', () => { - const line = '- [ ] Task'; - const result = checkboxUtils.matchCheckboxLine(line); - expect(result).not.toBeNull(); - expect(result).toEqual({ - indent: 0, - marker: '-', - checkChar: ' ', - checkboxCharPosition: 3, - checkboxState: CheckboxState.Unchecked, - isChecked: false, - }); - }); - - it('should match standard checked checkbox', () => { - const line = '* [x] Done'; - const result = checkboxUtils.matchCheckboxLine(line); - expect(result).not.toBeNull(); - expect(result).toEqual({ - indent: 0, - marker: '*', - checkChar: 'x', - checkboxCharPosition: 3, - checkboxState: CheckboxState.Checked, - isChecked: true, - }); - }); - - it('should match checkbox with indentation', () => { - const line = ' + [x] Indented Task'; - const result = checkboxUtils.matchCheckboxLine(line); - expect(result).not.toBeNull(); - expect(result).toEqual({ - indent: 2, - marker: '+', - checkChar: 'x', - checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5 - checkboxState: CheckboxState.Checked, - isChecked: true, - }); - }); - - it('should match numbered list checkbox', () => { - const line = '1. [ ] Numbered'; - const result = checkboxUtils.matchCheckboxLine(line); - expect(result).not.toBeNull(); - expect(result).toEqual({ - indent: 0, - marker: '1.', - checkChar: ' ', - checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4 - checkboxState: CheckboxState.Unchecked, - isChecked: false, - }); - }); - - it('should match checkbox with multi-digit numbered list', () => { - const line = '10. [x] Double Digit'; - const result = checkboxUtils.matchCheckboxLine(line); - expect(result).not.toBeNull(); - expect(result).toEqual({ - indent: 0, - marker: '10.', - checkChar: 'x', - checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5 - checkboxState: CheckboxState.Checked, - isChecked: true, - }); - }); - - it('should return null for lines without checkbox format', () => { - expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull(); - expect(checkboxUtils.matchCheckboxLine('- Not a checkbox')).toBeNull(); - expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ] - expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside - expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker - expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker - }); - - it('should match custom symbols based on settings', () => { - const customSettings = createSettings({ - checkedSymbols: ['X', 'V'], - uncheckedSymbols: ['O', '-'], - ignoreSymbols: ['~'], - }); - const customUtils = new CheckboxUtils(customSettings); - - const checkedLine = '- [V] Custom Checked'; - const checkedResult = customUtils.matchCheckboxLine(checkedLine); - expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked); - expect(checkedResult?.isChecked).toBe(true); - expect(checkedResult?.checkChar).toBe('V'); - - const uncheckedLine = '* [O] Custom Unchecked'; - const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine); - expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked); - expect(uncheckedResult?.isChecked).toBe(false); - expect(uncheckedResult?.checkChar).toBe('O'); - - const ignoredLine = '+ [~] Custom Ignored'; - const ignoredResult = customUtils.matchCheckboxLine(ignoredLine); - expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore); - expect(ignoredResult?.isChecked).toBeUndefined(); - expect(ignoredResult?.checkChar).toBe('~'); - }); - }); - - // --- Тесты для getCheckboxState --- - describe('getCheckboxState', () => { - it('should return Checked for symbols in checkedSymbols', () => { - const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] }); - const customUtils = new CheckboxUtils(customSettings); - expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked); - expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked); - expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked); - }); - - it('should return Unchecked for symbols in uncheckedSymbols', () => { - const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] }); - const customUtils = new CheckboxUtils(customSettings); - expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked); - expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked); - expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked); - }); - - it('should return Ignore for symbols in ignoreSymbols', () => { - const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] }); - const customUtils = new CheckboxUtils(customSettings); - expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore); - expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore); - expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore); - }); - - it('should use unknownSymbolPolicy for symbols not in any list', () => { - const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked }); - const utilsChecked = new CheckboxUtils(settingsChecked); - expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked); - - const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked }); - const utilsUnchecked = new CheckboxUtils(settingsUnchecked); - expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked); - - const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore }); - const utilsIgnore = new CheckboxUtils(settingsIgnore); - expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore); - }); - }); - - // --- Тесты для updateLineCheckboxStateWithInfo --- - describe('updateLineCheckboxStateWithInfo', () => { - it('should update checkbox state from unchecked to checked', () => { - const line = '- [ ] Task'; - const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line - const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); - // Uses the first symbol from settings.checkedSymbols ('x' by default) - expect(updatedLine).toBe('- [x] Task'); - }); - - it('should update checkbox state from checked to unchecked', () => { - const line = '* [x] Done'; - const lineInfo = checkboxUtils.matchCheckboxLine(line)!; - const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo); - // Uses the first symbol from settings.uncheckedSymbols (' ' by default) - expect(updatedLine).toBe('* [ ] Done'); - }); - - it('should use first symbol from settings for update', () => { - const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] }); - const customUtils = new CheckboxUtils(customSettings); - - const lineToCheck = '- [O] Task'; - const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!; - const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck); - expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V' - - const lineToUncheck = '* [V] Done'; - const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!; - const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck); - expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O' - }); - - it('should handle indented lines correctly', () => { - const line = ' - [ ] Indented'; - const lineInfo = checkboxUtils.matchCheckboxLine(line)!; - const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); - expect(updatedLine).toBe(' - [x] Indented'); - }); - - it('should return original line if position is invalid (simulated)', () => { - const line = '- [ ] Task'; - const lineInfo = checkboxUtils.matchCheckboxLine(line)!; - // Simulate invalid position - const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 }; - const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo); - expect(updatedLine).toBe(line); // Should not change - }); - - it('should use default checked symbol "x" if checkedSymbols setting is empty', () => { - // Создаем настройки с пустым списком checkedSymbols - const customSettings = createSettings({ checkedSymbols: [] }); - const customUtils = new CheckboxUtils(customSettings); - const line = '- [ ] Task'; - const lineInfo = customUtils.matchCheckboxLine(line)!; - - // Пытаемся отметить чекбокс - const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo); - expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x' - }); - - it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => { - // Создаем настройки с пустым списком uncheckedSymbols - const customSettings = createSettings({ uncheckedSymbols: [] }); - const customUtils = new CheckboxUtils(customSettings); - const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols - const lineInfo = customUtils.matchCheckboxLine(line)!; - - // Пытаемся снять отметку - const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo); - expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел) - }); - - // Усиленный тест для невалидной позиции (с проверкой console.warn) - it('should return original line and warn if position is invalid', () => { - const line = '- [ ] Task'; - const lineInfo = checkboxUtils.matchCheckboxLine(line)!; - // Мокаем console.warn - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); - - // Simulate invalid position - const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция - const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo); - expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку - expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn - - const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция - const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2); - expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку - expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз - - // Восстанавливаем оригинальную функцию console.warn - warnSpy.mockRestore(); - }); - }); - - // --- Тесты для propagateStateToChildren --- - describe('propagateStateToChildren', () => { - const setupUtils = (customSettings?: Partial) => { - return new CheckboxUtils(createSettings(customSettings)); - } - - it('should check children when parent is checked', () => { - const text = [ - '- [x] Parent', // line 0 - ' - [ ] Child 1', - ' - [ ] Grandchild', - ' - [ ] Child 2', - '- [ ] Sibling', - ].join('\n'); - const expected = [ - '- [x] Parent', - ' - [x] Child 1', - ' - [x] Grandchild', - ' - [x] Child 2', - '- [ ] Sibling', // Sibling should not change - ].join('\n'); - expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); - }); - - it('should uncheck children when parent is unchecked', () => { - const text = [ - '- [ ] Parent', // line 0 - ' - [x] Child 1', - ' - [x] Grandchild', - ' - [ ] Child 2', // Already unchecked - '- [x] Sibling', - ].join('\n'); - const expected = [ - '- [ ] Parent', - ' - [ ] Child 1', - ' - [ ] Grandchild', - ' - [ ] Child 2', - '- [x] Sibling', // Sibling should not change - ].join('\n'); - expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); - }); - - it('should stop propagation at siblings or less indented lines', () => { - const text = [ - ' - [x] Parent', // line 0 - ' - [ ] Child', - ' - [ ] Sibling', // Same indent level - '- [ ] Less Indented', - ].join('\n'); - const expected = [ - ' - [x] Parent', - ' - [x] Child', // Changed - ' - [ ] Sibling', // Not changed - '- [ ] Less Indented', // Not changed - ].join('\n'); - expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected); - }); - - it('should skip ignored children and their subtrees', () => { - const utils = setupUtils({ ignoreSymbols: ['~'] }); - const text = [ - '- [x] Parent', // 0 - ' - [ ] Child 1', // 1 - ' - [~] Ignored Child',// 2 (ignore) - ' - [ ] Skipped GC', // 3 (skipped because parent ignored) - ' - [ ] Child 2', // 4 - ].join('\n'); - const expected = [ - '- [x] Parent', - ' - [x] Child 1', // Changed - ' - [~] Ignored Child',// Unchanged - ' - [ ] Skipped GC', // Unchanged - ' - [x] Child 2', // Changed - ].join('\n'); - expect(utils.propagateStateToChildren(text, 0)).toBe(expected); - }); - - it('should do nothing if the parent line is not a checkbox', () => { - const text = [ - 'Parent Line', - ' - [ ] Child', - ].join('\n'); - expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text); - }); - - it('should do nothing if the parent checkbox is ignored', () => { - const utils = setupUtils({ ignoreSymbols: ['~'] }); - const text = [ - '- [~] Ignored Parent', // 0 - ' - [ ] Child', - ].join('\n'); - expect(utils.propagateStateToChildren(text, 0)).toBe(text); - }); - }); - - // --- Тесты для propagateStateFromChildren --- - describe('propagateStateFromChildren', () => { - const setupUtils = (customSettings?: Partial) => { - return new CheckboxUtils(createSettings(customSettings)); - } - - it('should check parent if all children are checked', () => { - const text = [ - '- [ ] Parent', // line 0 - ' - [x] Child 1', - ' - [x] Child 2', - ' - [x] Grandchild', // Needs parent (Child 2) to be checked first - ].join('\n'); - // Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent - const expectedPass1 = [ // After processing Grandchild and Child 2 - '- [ ] Parent', - ' - [x] Child 1', - ' - [x] Child 2', // Stays checked - ' - [x] Grandchild', - ].join('\n'); - const expectedPass2 = [ // After processing Child 1 and Parent - '- [x] Parent', // Changes to checked - ' - [x] Child 1', - ' - [x] Child 2', - ' - [x] Grandchild', - ].join('\n'); - // Since it processes bottom-up, Child 2 should already be evaluated correctly - expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2); - }); - - it('should uncheck parent if any child is unchecked', () => { - const text = [ - '- [x] Parent', // line 0 - ' - [x] Child 1', - ' - [ ] Child 2', // This one makes the parent unchecked - ' - [ ] Grandchild', - ].join('\n'); - const expected = [ - '- [ ] Parent', // Changes to unchecked - ' - [x] Child 1', - ' - [ ] Child 2', - ' - [ ] Grandchild', - ].join('\n'); - expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); - }); - - it('should handle nested updates correctly (bottom-up)', () => { - const text = [ - '- [ ] Grandparent', // 0 - ' - [ ] Parent 1', // 1 - ' - [x] Child 1.1',// 2 - ' - [x] Child 1.2',// 3 - ' - [x] Parent 2', // 4 - ' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked) - ].join('\n'); - const expected = [ - '- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked) - ' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked) - ' - [x] Child 1.1', - ' - [x] Child 1.2', - ' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked) - ' - [ ] Child 2.1', - ].join('\n'); - expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); - }); - - it('should not change parent state if it has no relevant children', () => { - const text = [ - '- [ ] Parent', - 'Sibling', // Not a child checkbox - ' Not indented correctly', - ].join('\n'); - expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text); - }); - - it('should ignore non-checkbox lines when determining parent state', () => { - const text = [ - '- [ ] Parent', - ' - [x] Child 1', - ' Just text', - ' - [x] Child 2', - ].join('\n'); - const expected = [ - '- [x] Parent', // Should become checked - ' - [x] Child 1', - ' Just text', - ' - [x] Child 2', - ].join('\n'); - expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected); - }); - - it('should skip ignored children when determining parent state', () => { - const utils = setupUtils({ ignoreSymbols: ['~'] }); - const text = [ - '- [ ] Parent', // 0 - ' - [x] Child 1', // 1 - ' - [~] Ignored Child',// 2 (skipped) - ' - [ ] Skipped GC', // 3 (doesn't affect parent) - ' - [x] Child 2', // 4 - ].join('\n'); - const expected = [ - '- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only) - ' - [x] Child 1', - ' - [~] Ignored Child', - ' - [ ] Skipped GC', - ' - [x] Child 2', - ].join('\n'); - expect(utils.propagateStateFromChildren(text)).toBe(expected); - }); - - it('should not change parent state if all direct children are ignored', () => { - const utils = setupUtils({ ignoreSymbols: ['~'] }); - const text = [ - '- [ ] Parent', // 0 - ' - [~] Ignored 1', // 1 - ' - [~] Ignored 2', // 2 - ].join('\n'); - expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked - const text2 = [ - '- [x] Parent', // 0 - ' - [~] Ignored 1', // 1 - ' - [~] Ignored 2', // 2 - ].join('\n'); - expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked - }); - - it('should not update ignored parents', () => { - const utils = setupUtils({ ignoreSymbols: ['~'] }); - const text = [ - '- [~] Ignored Parent', // 0 - ' - [x] Child 1', - ' - [x] Child 2', - ].join('\n'); - expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored - }); - - it('should stop searching children when a line with equal or lesser indent is found', () => { - const text = [ - '- [ ] Parent 1', // indent 0 - 'Some regular text', // indent 0. Stops search for Parent 1 children. - ' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1. - '- [ ] Parent 2', // indent 0. Processed independently. - ' - [x] Child 2.1', // indent 2. Child of Parent 2. - ].join('\n'); - - const result = checkboxUtils.propagateStateFromChildren(text); - - const expected = [ - '- [ ] Parent 1', // Unchanged (no children found before stop) - 'Some regular text', - ' - [x] Child 1.1', - '- [x] Parent 2', // Updated by Child 2.1 - ' - [x] Child 2.1', - ].join('\n'); - expect(result).toBe(expected); - }); - - - it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => { - const text = [ - '- [ ] Parent 1', // indent 0 - '- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children. - ' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent. - ].join('\n'); - - const result = checkboxUtils.propagateStateFromChildren(text); - - const expected = [ - '- [ ] Parent 1', // Unchanged (no children found before stop) - '- [x] Sibling Parent', // Updated by Child SP.1 - ' - [x] Child SP.1', - ].join('\n'); - expect(result).toBe(expected); - - const text2 = [ - ' - [ ] Parent 1', // indent 2 - ' - [x] Child 1.1', // indent 4 - ' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children. - ' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent. - ].join('\n'); - const result2 = checkboxUtils.propagateStateFromChildren(text2); - const expected2 = [ - ' - [x] Parent 1', // Updated by Child 1.1 - ' - [x] Child 1.1', - ' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked) - ' - [ ] Child SP.1', - ].join('\n'); - expect(result2).toBe(expected2); - }); - }); - - // --- Тесты для syncText --- - describe('syncText', () => { - const textBefore = [ - '- [ ] Parent', - ' - [ ] Child', - ].join('\n'); - const textAfterParentChecked = [ // Simulate user checking parent - '- [x] Parent', - ' - [ ] Child', - ].join('\n'); - const textAfterChildChecked = [ // Simulate user checking child - '- [ ] Parent', - ' - [x] Child', - ].join('\n'); - - it('should propagate down only if child sync enabled and diff matches', () => { - const utils = new CheckboxUtils(createSettings({ - enableAutomaticChildState: true, - enableAutomaticParentState: false, // Parent sync off - })); - const expected = [ - '- [x] Parent', - ' - [x] Child', // Propagated down - ].join('\n'); - expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected); - }); - - it('should propagate up only if parent sync enabled', () => { - const utils = new CheckboxUtils(createSettings({ - enableAutomaticChildState: false, // Child sync off - enableAutomaticParentState: true, - })); - const expected = [ - '- [x] Parent', // Propagated up - ' - [x] Child', - ].join('\n'); - // syncText calls propagateFromChildren unconditionally if enabled - expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected); - }); - - it('should propagate down then up if both enabled', () => { - const utils = new CheckboxUtils(createSettings({ - enableAutomaticChildState: true, - enableAutomaticParentState: true, - })); - // 1. User checks parent: '- [x] Parent', ' - [ ] Child' - // 2. Propagate down: '- [x] Parent', ' - [x] Child' - // 3. Propagate up: (no change needed as parent is already checked) - const expectedDown = [ - '- [x] Parent', - ' - [x] Child', // Propagated down - ].join('\n'); - expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown); - - // 1. User checks child: '- [ ] Parent', ' - [x] Child' - // 2. Propagate down: (no change, as only one line changed, and it wasn't the parent) - // 3. Propagate up: '- [x] Parent', ' - [x] Child' - const expectedUp = [ - '- [x] Parent', // Propagated up - ' - [x] Child', - ].join('\n'); - expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp); - }); - - it('should do nothing if both syncs disabled', () => { - const utils = new CheckboxUtils(createSettings({ - enableAutomaticChildState: false, - enableAutomaticParentState: false, - })); - expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked); - expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked); - }); - - it('should not propagate down if diff is not a single checkbox state change', () => { - const utils = new CheckboxUtils(createSettings({ - enableAutomaticChildState: true, // Down propagation enabled - enableAutomaticParentState: false, - })); - const textMultipleChanges = [ - '- [x] Parent Changed', // Text changed too - ' - [x] Child also changed', - ].join('\n'); - const textIndentChange = [ - ' - [x] Parent', // Indent changed - ' - [ ] Child', - ].join('\n'); - - // Multiple lines changed - expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges); - // Indent changed (diff > 1 line index) - expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange); - }); - }); - - // --- Тесты для propagateStateToChildrenFromSingleDiff --- - describe('propagateStateToChildrenFromSingleDiff', () => { - const textBefore = [ - '- [ ] Parent', - ' - [ ] Child', - ].join('\n'); - - it('should propagate if only checkbox state changed', () => { - const textAfter = [ - '- [x] Parent', // Only state changed - ' - [ ] Child', - ].join('\n'); - const expected = [ - '- [x] Parent', - ' - [x] Child', // Propagated - ].join('\n'); - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected); - }); - - it('should propagate if text content also changed', () => { - const textAfter = [ - '- [x] Parent Changed Text', // State and text changed - ' - [ ] Child', - ].join('\n'); - const expected = [ - '- [x] Parent Changed Text', - ' - [x] Child', // Propagated - ].join('\n'); - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected); - }); - - it('should NOT propagate if marker changed', () => { - const textAfter = [ - '* [x] Parent', // Marker changed from - to * - ' - [ ] Child', - ].join('\n'); - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); - }); - - it('should NOT propagate if indentation changed', () => { - const textAfter = [ - ' - [x] Parent', // Indentation changed - ' - [ ] Child', - ].join('\n'); - // This will be detected as > 1 line difference by findDifferentLineIndexes - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); - }); - - - it('should NOT propagate if multiple lines changed', () => { - const textAfter = [ - '- [x] Parent', - ' - [x] Child', // Second line also changed - ].join('\n'); - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter); - }); - - it('should NOT propagate if changed line is not a checkbox', () => { - const textBeforeNonCheck = "Line 1\nLine 2"; - const textAfterNonCheck = "Line 1 changed\nLine 2"; - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck); - }); - - it('should propagate if changed line is an ignored checkbox', () => { - const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] })); - const textBeforeIgnored = [ - '- [~] Parent', - ' - [ ] Child', - ].join('\n'); - const textAfterIgnored = [ - '- [x] Parent', // Changed FROM ignored TO checked - ' - [ ] Child', - ].join('\n'); - const expected = [ - '- [x] Parent', - ' - [x] Child', // Propagated - ].join('\n'); - - expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected); - }); - - it('should return original text if textBefore is undefined', () => { - const text = '- [ ] Line'; - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text); - }); - - it('should return original text if line counts differ', () => { - const text1 = '- [ ] Line1\n- [ ] Line2'; - const text2 = '- [ ] Line1'; - expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1); - }); - }); - - // --- Тесты для findDifferentLineIndexes --- - describe('findDifferentLineIndexes', () => { - it('should return empty array for identical lines', () => { - const lines1 = ['a', 'b', 'c']; - const lines2 = ['a', 'b', 'c']; - expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]); - }); - - it('should return index of the single different line', () => { - const lines1 = ['a', 'b', 'c']; - const lines2 = ['a', 'B', 'c']; - expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]); - }); - - it('should return indexes of multiple different lines', () => { - const lines1 = ['a', 'b', 'c', 'd']; - const lines2 = ['A', 'b', 'C', 'd']; - expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]); - }); - - it('should detect differences at start and end', () => { - const lines1 = ['a', 'b', 'c']; - const lines2 = ['X', 'b', 'Y']; - expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]); - }); - - it('should throw error if line arrays have different lengths', () => { - const lines1 = ['a', 'b']; - const lines2 = ['a', 'b', 'c']; - expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2)) - .toThrow("the length of the lines must be equal"); - expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1)) - .toThrow("the length of the lines must be equal"); - }); - }); - -}); \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index 38d873b..e061c34 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,8 @@ const config: Config = { coverageDirectory: "coverage", // Indicates which provider should be used to instrument code for coverage - coverageProvider: "v8", + // coverageProvider: "v8", + coverageProvider: "babel", // An array of file extensions your modules use moduleFileExtensions: [ diff --git a/src/checkboxUtils.ts b/src/checkboxUtils.ts index 070c167..dfc6e55 100644 --- a/src/checkboxUtils.ts +++ b/src/checkboxUtils.ts @@ -1,212 +1,246 @@ import { CheckboxState, CheckboxSyncPluginSettings } from "./types"; export interface CheckboxLineInfo { - indent: number; - marker: string; - checkChar: string; - checkboxCharPosition: number; - checkboxState: CheckboxState; - isChecked: boolean | undefined; + indent: number; + marker: string; + checkChar?: string; + checkboxCharPosition?: number; + checkboxState: CheckboxState; + isChecked?: boolean | undefined; + listItemText?: string; } export class CheckboxUtils { - private settings: Readonly; - constructor(settings: Readonly) { - this.settings = settings; - } + private settings: Readonly; + constructor(settings: Readonly) { + this.settings = settings; + } - matchCheckboxLine(line: string): CheckboxLineInfo | null { - const match = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/); - if (!match) { - return null; - } - const indent = match[1].length; - const marker = match[2]; - const checkChar = match[3]; - const checkboxCharPosition = indent + marker.length + 2; - const checkboxState = this.getCheckboxState(checkChar); - let isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked; + matchCheckboxLine(line: string): CheckboxLineInfo | null { + // const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s.*)?$/); + // const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s|$)(.*)$/); + const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/); + if (checkboxMatch) { + const indent = checkboxMatch[1].length; + const marker = checkboxMatch[2]; + const checkChar = checkboxMatch[3]; + const checkboxCharPosition = indent + marker.length + 2; + const checkboxState = this.getCheckboxState(checkChar); + const isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked; + const listItemText = (checkboxMatch[4] || "").trimStart(); - return { - indent, - marker, - checkChar, - checkboxCharPosition, - checkboxState, - isChecked - }; - } + return { + indent, + marker, + checkChar, + checkboxCharPosition, + checkboxState, + isChecked, + listItemText + }; + } + const listItemMatch = line.match(/^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/); + if (listItemMatch) { + const indent = listItemMatch[1].length; + const marker = listItemMatch[2]; + const listItemText = listItemMatch[3].trimStart(); - getCheckboxState(text: string): CheckboxState { - if (this.settings.checkedSymbols.includes(text)) { - return CheckboxState.Checked; - } - if (this.settings.uncheckedSymbols.includes(text)) { - return CheckboxState.Unchecked; - } - if (this.settings.ignoreSymbols.includes(text)) { - return CheckboxState.Ignore; - } - return this.settings.unknownSymbolPolicy; - } + return { + indent, + marker, + // checkChar, checkboxCharPosition, isChecked - не применимы + checkboxState: CheckboxState.NoCheckbox, + listItemText + }; + } + return null; + } - updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string { - const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт - const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт + getCheckboxState(text: string): CheckboxState { + if (this.settings.checkedSymbols.includes(text)) { + return CheckboxState.Checked; + } + if (this.settings.uncheckedSymbols.includes(text)) { + return CheckboxState.Unchecked; + } + if (this.settings.ignoreSymbols.includes(text)) { + return CheckboxState.Ignore; + } + return this.settings.unknownSymbolPolicy; + } - const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar; - const pos = lineInfo.checkboxCharPosition; - if (pos >= 0 && pos < line.length) { - return line.substring(0, pos) + newCheckChar + line.substring(pos + 1); - } - console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); - return line; - } + updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string { + if (lineInfo.checkboxState !== CheckboxState.NoCheckbox) { + const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт + const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт - propagateStateToChildren(text: string, parentLine: number): string { - // console.log("propagateStateToChildren"); + const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar; + const pos = lineInfo.checkboxCharPosition!; + if (pos >= 0 && pos < line.length) { + return line.substring(0, pos) + newCheckChar + line.substring(pos + 1); + } else { + console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); + return line; + } + } + console.warn("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line); + return line; + } - const lines = text.split("\n"); + propagateStateToChildren(text: string, parentLine: number): string { + const lines = text.split("\n"); + const parentLineInfo = this.matchCheckboxLine(lines[parentLine]); - const parentLineInfo = this.matchCheckboxLine(lines[parentLine]); - if (!parentLineInfo) { - console.warn(`checkbox not found in line ${parentLine}`) - return text; - } + if (!parentLineInfo) { + console.warn(`checkbox not found in line ${parentLine}`) + return text; + } - if (parentLineInfo.checkboxState === CheckboxState.Ignore) { - return text; - } - let parentIsChecked = parentLineInfo.isChecked!; + if (parentLineInfo.checkboxState === CheckboxState.Ignore || + parentLineInfo.checkboxState === CheckboxState.NoCheckbox || + parentLineInfo.isChecked === undefined) { + return text; + } + const parentIsChecked = parentLineInfo.isChecked; - let j = parentLine + 1; + let j = parentLine + 1; - while (j < lines.length) { - const childText = lines[j]; - const childLineInfo = this.matchCheckboxLine(childText); - if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break; + while (j < lines.length) { + const childText = lines[j]; + const childLineInfo = this.matchCheckboxLine(childText); + if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) { + break; + } - if (childLineInfo.checkboxState !== CheckboxState.Ignore) { - lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo); - j++; - } else { - //skip children ignore node - j++; - while (j < lines.length) { - const subChildLineInfo = this.matchCheckboxLine(lines[j]); - if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) { - break; - }; - j++; - } - } - } - return lines.join("\n"); - } + if (childLineInfo.checkboxState === CheckboxState.Ignore) { + //skip children ignore node + j++; + while (j < lines.length) { + const subChildLineInfo = this.matchCheckboxLine(lines[j]); + if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) { + break; + }; + j++; + } + } else if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) { + j++; + } else { + lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo); + j++; + } + } + return lines.join("\n"); + } - propagateStateFromChildren(text: string): string { - const lines = text.split("\n"); + propagateStateFromChildren(text: string): string { + const lines = text.split("\n"); - for (let i = lines.length - 1; i >= 0; i--) { - const parentLineInfo = this.matchCheckboxLine(lines[i]); - if (!parentLineInfo) continue; + for (let i = lines.length - 1; i >= 0; i--) { + const parentLineInfo = this.matchCheckboxLine(lines[i]); + if (!parentLineInfo) continue; - if (parentLineInfo.checkboxState === CheckboxState.Ignore) { - continue; - } - const parentIsChecked = parentLineInfo.isChecked!; - let allChildrenChecked = true; - let hasChildren = false; + if ( + parentLineInfo.checkboxState === CheckboxState.Ignore || + parentLineInfo.checkboxState === CheckboxState.NoCheckbox + ) { + continue; + } + const parentIsChecked = parentLineInfo.isChecked!; + let allRelevantChildrenChecked = true; + let hasRelevantChildren = false; - for (let j = i + 1; j < lines.length; j++) { - const childLineInfo = this.matchCheckboxLine(lines[j]); - - if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break; + for (let j = i + 1; j < lines.length; j++) { + const childLineInfo = this.matchCheckboxLine(lines[j]); - if (childLineInfo.checkboxState === CheckboxState.Ignore) { - while (j + 1 < lines.length) { - const subChildLineInfo = this.matchCheckboxLine(lines[j + 1]); - if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) { - break; - }; - j++; - } - continue; - } - hasChildren = true; - const childrenIsChecked = childLineInfo.isChecked!; - if (!childrenIsChecked) { - allChildrenChecked = false; - break; - } - } + if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break; - if (hasChildren && parentIsChecked !== allChildrenChecked) { - lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allChildrenChecked, parentLineInfo); - } - } - return lines.join("\n"); - } + if (childLineInfo.checkboxState === CheckboxState.Ignore) { + while (j + 1 < lines.length) { + const subChildLineInfo = this.matchCheckboxLine(lines[j + 1]); + if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) { + break; + }; + j++; + } + continue; + } + if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) { + continue; + } + hasRelevantChildren = true; + const childrenIsChecked = childLineInfo.isChecked!; + if (!childrenIsChecked) { + allRelevantChildrenChecked = false; + break; + } + } - syncText(text: string, textBefore: string | undefined): string { - let newText = text; - if (this.settings.enableAutomaticChildState) { - newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore); - } - if (this.settings.enableAutomaticParentState) { - newText = this.propagateStateFromChildren(newText); - } - return newText; - } + if (hasRelevantChildren && parentIsChecked !== allRelevantChildrenChecked) { + lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allRelevantChildrenChecked, parentLineInfo); + } + } + return lines.join("\n"); + } - propagateStateToChildrenFromSingleDiff(text: string, textBefore: string | undefined): string { - if (!textBefore) return text; - const textBeforeLines = textBefore.split('\n'); - const textLines = text.split('\n'); + syncText(text: string, textBefore: string | undefined): string { + let newText = text; + if (this.settings.enableAutomaticChildState) { + newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore); + } + if (this.settings.enableAutomaticParentState) { + newText = this.propagateStateFromChildren(newText); + } + return newText; + } - if (textBeforeLines.length !== textLines.length) return text; + propagateStateToChildrenFromSingleDiff(text: string, textBefore: string | undefined): string { + if (!textBefore) return text; + const textBeforeLines = textBefore.split('\n'); + const textLines = text.split('\n'); - const diffIndexes = this.findDifferentLineIndexes(textBeforeLines, textLines); - if (diffIndexes.length !== 1) { - return text; - } + if (textBeforeLines.length !== textLines.length) return text; - const index = diffIndexes[0]; + const diffIndexes = this.findDifferentLineIndexes(textBeforeLines, textLines); + if (diffIndexes.length !== 1) { + return text; + } - const lineBefore = textBeforeLines[index]; - const lineAfter = textLines[index]; - // Получаем информацию о чекбоксе ДО и ПОСЛЕ изменения - const lineInfoBefore = this.matchCheckboxLine(lineBefore); - const lineInfoAfter = this.matchCheckboxLine(lineAfter); - // Проверяем, что: - // 1. Обе строки (до и после) являются валидными строками с чекбоксом. - // 2. Отступ и маркер списка не изменились (т.е. структура строки та же). - // 3. Изменился именно символ внутри скобок [ ]. - if ( - lineInfoBefore && lineInfoAfter && - lineInfoBefore.indent === lineInfoAfter.indent && - lineInfoBefore.marker === lineInfoAfter.marker && - lineInfoBefore.checkboxState !== lineInfoAfter.checkboxState - ) { - return this.propagateStateToChildren(text, diffIndexes[0]); - } - return text; + const index = diffIndexes[0]; - } + const lineBefore = textBeforeLines[index]; + const lineAfter = textLines[index]; + // Получаем информацию о чекбоксе ДО и ПОСЛЕ изменения + const lineInfoBefore = this.matchCheckboxLine(lineBefore); + const lineInfoAfter = this.matchCheckboxLine(lineAfter); + // Проверяем, что: + // 1. Обе строки (до и после) являются валидными строками с чекбоксом. + // 2. Отступ и маркер списка не изменились (т.е. структура строки та же). + // 3. Изменился именно символ внутри скобок [ ]. + if ( + lineInfoBefore && lineInfoAfter && + lineInfoBefore.indent === lineInfoAfter.indent && + lineInfoBefore.marker === lineInfoAfter.marker && + lineInfoBefore.checkboxState !== lineInfoAfter.checkboxState + ) { + return this.propagateStateToChildren(text, diffIndexes[0]); + } + return text; - findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { - if (lines1.length !== lines2.length) { - throw new Error("the length of the lines must be equal"); - } + } - const length = lines1.length; - const result: number[] = []; - for (let i = 0; i < length; i++) { - if (lines1[i] !== lines2[i]) { - result.push(i); - } - } - return result; - } + findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { + if (lines1.length !== lines2.length) { + throw new Error("the length of the lines must be equal"); + } -} \ No newline at end of file + const length = lines1.length; + const result: number[] = []; + for (let i = 0; i < length; i++) { + if (lines1[i] !== lines2[i]) { + result.push(i); + } + } + return result; + } + +} diff --git a/src/types.ts b/src/types.ts index 0072b49..6c16730 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,8 @@ export interface CheckboxSyncPluginSettings { export enum CheckboxState { Checked = 'checked', Unchecked = 'unchecked', - Ignore = 'ignore' + Ignore = 'ignore', + NoCheckbox = "noCheckbox" } export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { From 0b797bed5a3018775dfa2b6d261c43817391e4ba Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 21 May 2025 15:28:04 +0300 Subject: [PATCH 14/28] ref and add test --- __tests__/TextSyncPipeline.test.ts | 234 ++++++++++++++++++ .../fakes/InMemoryFilePathStateHolder.ts | 35 +++ src/FileStateHolder.ts | 128 ++++++---- src/IFilePathStateHolder.ts | 16 ++ src/SyncController.ts | 56 +---- src/TextSyncPipeline.ts | 40 +++ src/main.ts | 5 +- 7 files changed, 413 insertions(+), 101 deletions(-) create mode 100644 __tests__/TextSyncPipeline.test.ts create mode 100644 __tests__/fakes/InMemoryFilePathStateHolder.ts create mode 100644 src/IFilePathStateHolder.ts create mode 100644 src/TextSyncPipeline.ts diff --git a/__tests__/TextSyncPipeline.test.ts b/__tests__/TextSyncPipeline.test.ts new file mode 100644 index 0000000..180f243 --- /dev/null +++ b/__tests__/TextSyncPipeline.test.ts @@ -0,0 +1,234 @@ +import { CheckboxUtils } from "src/checkboxUtils"; +import { FileFilter } from "src/FileFilter"; +import TextSyncPipeline from "src/TextSyncPipeline"; +import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types"; +import { InMemoryFilePathStateHolder } from "./fakes/InMemoryFilePathStateHolder"; + +describe('TextSyncPipeline E2E-like Tests', () => { + let fakeFileStateHolder: InMemoryFilePathStateHolder; + let checkboxUtils: CheckboxUtils; + let fileFilter: FileFilter; + let pipeline: TextSyncPipeline; + let settings: CheckboxSyncPluginSettings; + + beforeEach(() => { + // Используем настройки по умолчанию для большинства тестов, + // их можно переопределять для специфических сценариев + settings = { ...DEFAULT_SETTINGS }; + + fakeFileStateHolder = new InMemoryFilePathStateHolder(); + checkboxUtils = new CheckboxUtils(settings); + fileFilter = new FileFilter(settings); // Инициализируем с настройками + + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + }); + + // --- Тесты на разрешение путей и кеширование --- + + describe('Path Filtering and Caching Logic', () => { + it('process: when path is NOT allowed, should return original text and cache original text', () => { + // Настраиваем FileFilter, чтобы он запрещал путь + settings.pathGlobs = ["restricted/path.md"]; // Этот путь будет игнорироваться + fileFilter.updateSettings(settings); // Обновляем фильтр с новыми глобами + + const filePath = "restricted/path.md"; + const currentText = "- [ ] task 1\n- [x] task 2"; + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + + // Assert + // 1. Текст не должен измениться + expect(resultText).toBe(currentText); + // 2. Оригинальный текст должен быть закеширован + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText); + }); + + it('process: when path IS allowed, but no sync changes occur, should return original text and cache original (synced) text', () => { + // Путь разрешен по умолчанию (pathGlobs пустой) + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + + const filePath = "allowed/path.md"; + // Текст, который не будет изменен CheckboxUtils при текущих настройках + // (например, нет родительских/дочерних для пропагации, или они уже синхронизованы) + const currentText = "- [ ] task 1\n- [ ] task 2"; + const previousTextInHolder = "- [ ] task 1\n- [ ] task 2"; // Предположим, предыдущее состояние было таким же + + fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder }); + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + + // Assert + // 1. Текст не должен измениться (т.к. syncText вернул то же самое) + expect(resultText).toBe(currentText); + // 2. В кеше должен быть этот же (оригинальный/синхронизированный) текст + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText); + }); + + it('process: when path IS allowed and sync changes occur, should return modified text and cache modified text', () => { + // Путь разрешен + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + // Включаем автоматическую пропагацию, чтобы изменения точно были + settings.enableAutomaticChildState = true; + checkboxUtils = new CheckboxUtils(settings); // Пересоздаем с новыми настройками + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + + const filePath = "allowed/changes.md"; + const currentText = "- [x] parent\n - [ ] child"; // Родитель изменен, дочерний должен измениться + const previousTextInHolder = "- [ ] parent\n - [ ] child"; // Предыдущее состояние + + fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder }); + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + const expectedTextAfterSync = "- [x] parent\n - [x] child"; // Ожидаемый результат от CheckboxUtils + + // Assert + // 1. Текст должен измениться + expect(resultText).toBe(expectedTextAfterSync); + // 2. В кеше должен быть измененный текст + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync); + }); + + it('process: when path IS allowed and file was not in state holder, should process and cache result', () => { + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + settings.enableAutomaticChildState = true; + checkboxUtils = new CheckboxUtils(settings); + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + const filePath = "allowed/new_file.md"; + const currentText = "- [x] parent\n - [ ] child"; + // Предыдущего состояния нет в fakeFileStateHolder + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + const expectedTextAfterSync = "- [ ] parent\n - [ ] child"; + + // Assert + expect(resultText).toBe(expectedTextAfterSync); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync); + }); + + it('process: subsequent calls for the same allowed path should use updated cached state', () => { + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + settings.enableAutomaticChildState = true; + checkboxUtils = new CheckboxUtils(settings); + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + const filePath = "allowed/sequential.md"; + const initialText = "- [ ] parent\n - [ ] child 1"; + const textAfterFirstEdit = "- [x] parent\n - [ ] child 1"; // Пользователь изменил родителя + const expectedAfterFirstPipeline = "- [x] parent\n - [x] child 1"; + + // Первый вызов (например, после первого изменения в редакторе) + fakeFileStateHolder.setByPath(filePath, initialText); + let resultText = pipeline.applySyncLogic(textAfterFirstEdit, filePath); // previousText будет undefined + expect(resultText).toBe(expectedAfterFirstPipeline); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterFirstPipeline); + + // Второе изменение (пользователь снимает галочку с родителя) + const textAfterSecondEdit = "- [ ] parent\n - [x] child 1"; // previousText теперь expectedAfterFirstPipeline + const expectedAfterSecondPipeline = "- [ ] parent\n - [ ] child 1"; + + resultText = pipeline.applySyncLogic(textAfterSecondEdit, filePath); + expect(resultText).toBe(expectedAfterSecondPipeline); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterSecondPipeline); + }); + }); + + // --- Можно добавить тесты на специфическое поведение CheckboxUtils через pipeline --- + // Например, если enableAutomaticParentState = true и т.д. + + describe('CheckboxUtils Behavior through Pipeline (Path Allowed)', () => { + beforeEach(() => { + // Убедимся, что путь всегда разрешен для этих тестов + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + }); + + it('process: should propagate state to children if enabled', () => { + settings.enableAutomaticChildState = true; + checkboxUtils = new CheckboxUtils(settings); // Обновляем CheckboxUtils + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + const filePath = "test.md"; + const previousText = "- [ ] Parent\n - [ ] Child"; + const currentText = "- [x] Parent\n - [ ] Child"; // Пользователь отметил родителя + const expectedText = "- [x] Parent\n - [x] Child"; + + fakeFileStateHolder.setInitialStates({ [filePath]: previousText }); + const result = pipeline.applySyncLogic(currentText, filePath); + expect(result).toBe(expectedText); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText); + }); + + it('process: should propagate state from children if enabled', () => { + settings.enableAutomaticParentState = true; + // Убедимся, что child state не влияет на этот тест, если он не нужен + settings.enableAutomaticChildState = false; + checkboxUtils = new CheckboxUtils(settings); + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + const filePath = "test.md"; + // Предыдущее состояние не так важно, если мы не проверяем diff-логику + const currentText = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2"; // Дети отмечены + const expectedText = "- [x] Parent\n - [x] Child 1\n - [x] Child 2"; // Родитель должен стать отмеченным + + const result = pipeline.applySyncLogic(currentText, filePath); + expect(result).toBe(expectedText); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText); + }); + }); + + describe('Text content variations (Path Allowed)', () => { + beforeEach(() => { + // Убедимся, что путь всегда разрешен для этих тестов + settings.pathGlobs = []; + fileFilter.updateSettings(settings); + // Оставляем настройки CheckboxUtils по умолчанию, если не указано иное + checkboxUtils = new CheckboxUtils(settings); + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + }); + + it('process: text with no checkboxes should return original text and cache it', () => { + const filePath = "text_files/no_checkboxes.md"; + const currentText = "This is a line.\nAnd another line without any checkboxes."; + const previousTextInHolder = "Some old text"; + + fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder }); + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + + // Assert + expect(resultText).toBe(currentText); + // CheckboxUtils.syncText не должен был ничего изменить + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText); + }); + + it('process: text with only ignored checkboxes should return original text and cache it', () => { + settings.ignoreSymbols = ["~"]; + checkboxUtils = new CheckboxUtils(settings); // Обновляем с новыми ignoreSymbols + pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter); + + const filePath = "text_files/ignored_checkboxes.md"; + const currentText = "- [~] An ignored task\n - [~] Another sub-task also ignored"; + const previousTextInHolder = "- [~] An old ignored task"; + + fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder }); + + // Act + const resultText = pipeline.applySyncLogic(currentText, filePath); + + // Assert + expect(resultText).toBe(currentText); + expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText); + }); + }); +}); diff --git a/__tests__/fakes/InMemoryFilePathStateHolder.ts b/__tests__/fakes/InMemoryFilePathStateHolder.ts new file mode 100644 index 0000000..9861498 --- /dev/null +++ b/__tests__/fakes/InMemoryFilePathStateHolder.ts @@ -0,0 +1,35 @@ +import { IFilePathStateHolder } from "src/IFilePathStateHolder"; + +export class InMemoryFilePathStateHolder implements IFilePathStateHolder { + private states: Map; + + constructor() { + this.states = new Map(); + } + + public setByPath(filePath: string, text: string): void { + console.log(`InMemoryFileStateHolder: Setting state for "${filePath}"`); + this.states.set(filePath, text); + } + + public getByPath(filePath: string): string | undefined { + const state = this.states.get(filePath); + console.log(`InMemoryFileStateHolder: Getting state for "${filePath}". Found: ${!!state}`); + return state; + } + + // Вспомогательные методы для тестов (не часть интерфейса, но полезны) + public clear(): void { + this.states.clear(); + } + + public getInternalState(filePath: string): string | undefined { + return this.states.get(filePath); // Для прямых проверок в тестах + } + + public setInitialStates(initialStates: Record): void { + for (const path in initialStates) { + this.states.set(path, initialStates[path]); + } + } +} diff --git a/src/FileStateHolder.ts b/src/FileStateHolder.ts index 1ccd252..c2bc0ca 100644 --- a/src/FileStateHolder.ts +++ b/src/FileStateHolder.ts @@ -1,66 +1,84 @@ import { Mutex } from "async-mutex"; import { TFile, Vault } from "obsidian"; +import { IFilePathStateHolder } from "./IFilePathStateHolder"; -export default class FileStateHolder { - private map: Map; - private vault: Vault; - private mutex: Mutex; +export default class FileStateHolder implements IFilePathStateHolder{ + private map: Map; + private vault: Vault; + private mutex: Mutex; - constructor(vault: Vault) { - this.vault = vault; - this.map = new Map(); - this.mutex = new Mutex(); - } + constructor(vault: Vault) { + this.vault = vault; + this.map = new Map(); + this.mutex = new Mutex(); + } - /** - * Initializes the file's data if it hasn't been initialized yet. - * - * @param file - The file object to initialize. - * @param text - Optional text content to associate with the file. If not provided, it will be read from the vault. - * @returns A promise that resolves to `true` if the file was initialized, or `false` if it was already initialized. - */ - async initIfNeeded(file: TFile, text?: string): Promise { - if (this.has(file)) { - return false; - } - let res = await this.mutex.runExclusive(async () => { - if (this.has(file)) { - return false; - } - // console.log(`updateIfNeeded "${file.name}" start`); - if (!text) { - text = await this.vault.read(file); - } - this.set(file, text); - return true; - }); - return res; - } + /** + * Initializes the file's data if it hasn't been initialized yet. + * + * @param file - The file object to initialize. + * @param text - Optional text content to associate with the file. If not provided, it will be read from the vault. + * @returns A promise that resolves to `true` if the file was initialized, or `false` if it was already initialized. + */ + async initIfNeeded(file: TFile, text?: string): Promise { + if (this.has(file)) { + return false; + } + let res = await this.mutex.runExclusive(async () => { + if (this.has(file)) { + return false; + } + // console.log(`updateIfNeeded "${file.name}" start`); + if (!text) { + text = await this.vault.read(file); + } + this.set(file, text); + return true; + }); + return res; + } - has(file: TFile) { - return this.map.has(file); - } + has(file: TFile) { + return this.map.has(file); + } - set(file: TFile, text: string) { - if (text !== this.map.get(file)) { - console.log(`File "${file.name}" load to holder`); - this.map.set(file, text); - } - } + set(file: TFile, text: string) { + if (text !== this.map.get(file)) { + console.log(`File "${file.name}" load to holder`); + this.map.set(file, text); + } + } - get(file: TFile) { - return this.map.get(file); - } + setByPath(filePath: string, text: string) { + const file = this.vault.getFileByPath(filePath); + if (file) { + this.set(file, text); + } else { + throw new Error(`file not found by path: ${filePath}.`); + } + } - getAllFiles(): TFile[] { - const keysIterator = this.map.keys(); - const keysArray = Array.from(keysIterator); - return keysArray; - } + get(file: TFile) { + return this.map.get(file); + } - delete(file: TFile): boolean { - const existed = this.map.delete(file); - return existed; - } -} \ No newline at end of file + getByPath(filePath: string) { + const file = this.vault.getFileByPath(filePath); + if (file) { + return this.get(file); + } + return undefined; + } + + getAllFiles(): TFile[] { + const keysIterator = this.map.keys(); + const keysArray = Array.from(keysIterator); + return keysArray; + } + + delete(file: TFile): boolean { + const existed = this.map.delete(file); + return existed; + } +} diff --git a/src/IFilePathStateHolder.ts b/src/IFilePathStateHolder.ts new file mode 100644 index 0000000..8c5c925 --- /dev/null +++ b/src/IFilePathStateHolder.ts @@ -0,0 +1,16 @@ +export interface IFilePathStateHolder { + /** + * Sets or updates the text state associated with a file identified by its path. + * @param filePath - The path of the file. + * @param text - The text content to store. + * @throws Error if the file is not found by path (implementations may vary). + */ + setByPath(filePath: string, text: string): void; + + /** + * Retrieves the text state associated with a file identified by its path. + * @param filePath - The path of the file. + * @returns The stored text content, or `undefined` if not found or file not found. + */ + getByPath(filePath: string): string | undefined; +} diff --git a/src/SyncController.ts b/src/SyncController.ts index cf68472..7ab4eff 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -1,24 +1,19 @@ 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"; +import TextSyncPipeline from "./TextSyncPipeline"; export default class SyncController { - // private plugin: CheckboxSyncPlugin;//delete private vault: Vault; private checkboxUtils: CheckboxUtils; - private fileStateHolder: FileStateHolder; - private fileFilter: FileFilter; + textSyncPipeline: TextSyncPipeline private mutex: Mutex; - constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder, fileFilter: FileFilter) { - // this.plugin = plugin;//delete + constructor(vault: Vault, checkboxUtils: CheckboxUtils, textSyncPipeline: TextSyncPipeline) { this.vault = vault; this.checkboxUtils = checkboxUtils; - this.fileStateHolder = fileStateHolder; - this.fileFilter = fileFilter; + this.textSyncPipeline = textSyncPipeline; this.mutex = new Mutex(); } @@ -27,27 +22,10 @@ export default class SyncController { return; } await this.mutex.runExclusive(async () => { - const file = info.file!; - console.log(`sync editor "${file.path}" start.`); - - const text = editor.getValue(); - - if (!this.fileFilter.isPathAllowed(file.path)) { - console.log(`sync editor "${file.path}" skip, path is not allowed.`); - this.fileStateHolder.set(file, text); - return; - } - - const textBefore = this.fileStateHolder.get(file); - - let newText = this.checkboxUtils.syncText(text, textBefore); - this.fileStateHolder.set(file, newText); - - 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 currentText = editor.getValue(); + const resultingText = this.textSyncPipeline.applySyncLogic(currentText, info.file!.path); + if (resultingText !== currentText) { + this.editEditor(editor, currentText, resultingText); } }); } @@ -57,21 +35,9 @@ export default class SyncController { return; } await this.mutex.runExclusive(async () => { - console.log(`sync file "${file.path}" start.`); - - 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.`); + this.vault.process(file, (currentText) => { + return this.textSyncPipeline.applySyncLogic(currentText, file.path); + }); }); } diff --git a/src/TextSyncPipeline.ts b/src/TextSyncPipeline.ts new file mode 100644 index 0000000..d02fc31 --- /dev/null +++ b/src/TextSyncPipeline.ts @@ -0,0 +1,40 @@ +import { CheckboxUtils } from "./checkboxUtils"; +import { FileFilter } from "./FileFilter"; +import { IFilePathStateHolder } from "./IFilePathStateHolder"; + +export default class TextSyncPipeline { + private checkboxUtils: CheckboxUtils; + private fileStateHolder: IFilePathStateHolder; + private fileFilter: FileFilter; + + + constructor(checkboxUtils: CheckboxUtils, fileStateHolder: IFilePathStateHolder, fileFilter: FileFilter) { + this.checkboxUtils = checkboxUtils; + this.fileStateHolder = fileStateHolder; + this.fileFilter = fileFilter; + } + + public applySyncLogic(currentText: string, filePath: string): string { + return this.fileStateHolderDecorator(currentText, filePath); + } + + private coreDecorator(currentText: string, textBefore: string | undefined): string { + const resultingText = this.checkboxUtils.syncText(currentText, textBefore); + return resultingText; + } + + private pathAllowedDecorator(currentText: string, textBefore: string | undefined, filePath: string): string { + if (!this.fileFilter.isPathAllowed(filePath)) { + console.log(`pathAllowedDecorator "${filePath}" skip, path is not allowed.`); + return currentText; + } + return this.coreDecorator(currentText, textBefore); + } + + private fileStateHolderDecorator(currentText: string, filePath: string): string { + const textBefore = this.fileStateHolder.getByPath(filePath); + const resultingText = this.pathAllowedDecorator(currentText, textBefore, filePath); + this.fileStateHolder.setByPath(filePath, resultingText); + return resultingText; + } +} diff --git a/src/main.ts b/src/main.ts index 6f45ea0..8d9c097 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab" import SyncController from "./SyncController"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types"; import { FileFilter } from "./FileFilter"; +import TextSyncPipeline from "./TextSyncPipeline"; const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG'; @@ -27,6 +28,7 @@ export default class CheckboxSyncPlugin extends Plugin { private fileLoadEventHandler: FileLoadEventHandler; private fileChangeEventHandler: FileChangeEventHandler; private fileFilter: FileFilter; + private textSyncPipeline: TextSyncPipeline; async onload() { await this.loadSettings(); @@ -34,7 +36,8 @@ export default class CheckboxSyncPlugin extends Plugin { 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.fileFilter); + this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils,this.fileStateHolder, this.fileFilter); + this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.textSyncPipeline); this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); From 93597b2626774e9d4fe02e69816fc2dfaee781bf Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Wed, 21 May 2025 15:32:53 +0300 Subject: [PATCH 15/28] fix fake test --- jest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jest.config.ts b/jest.config.ts index e061c34..27a022a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -43,6 +43,11 @@ const config: Config = { '^src/(.*)$': '/src/$1', }, + testPathIgnorePatterns: [ + "/node_modules/", // Хорошо иметь это явно, хотя Jest часто делает это по умолчанию + "/__tests__/fakes/" // Исключаем папку fakes внутри __tests__ + ], + }; export default config; From b0d58d64476f10593ea804318777df046714b746 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 23 May 2025 03:49:38 +0300 Subject: [PATCH 16/28] ref --- src/checkboxUtils.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/checkboxUtils.ts b/src/checkboxUtils.ts index dfc6e55..7bdbc85 100644 --- a/src/checkboxUtils.ts +++ b/src/checkboxUtils.ts @@ -70,21 +70,21 @@ export class CheckboxUtils { } updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string { - if (lineInfo.checkboxState !== CheckboxState.NoCheckbox) { - const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт - const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт - - const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar; - const pos = lineInfo.checkboxCharPosition!; - if (pos >= 0 && pos < line.length) { - return line.substring(0, pos) + newCheckChar + line.substring(pos + 1); - } else { - console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); - return line; - } + if (lineInfo.checkboxState === CheckboxState.NoCheckbox) { + console.warn("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line); + return line; + } + const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт + const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт + + const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar; + const pos = lineInfo.checkboxCharPosition!; + if (pos >= 0 && pos < line.length) { + return line.substring(0, pos) + newCheckChar + line.substring(pos + 1); + } else { + console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); + return line; } - console.warn("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line); - return line; } propagateStateToChildren(text: string, parentLine: number): string { From a7f33e676de21c6021feac69891c2a5bf4c6194f Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Thu, 5 Jun 2025 11:56:36 +0300 Subject: [PATCH 17/28] init state --- package-lock.json | 20 +-- src/core/CheckboxUtils2.ts | 37 ++++++ src/core/interface/CheckboxProcess.ts | 5 + src/core/interface/ICheckboxUtils.ts | 3 + src/core/model/Context.ts | 75 +++++++++++ src/core/model/ContextFactory.ts | 121 ++++++++++++++++++ src/core/model/Line.ts | 8 ++ src/core/model/TreeNode.ts | 59 +++++++++ src/core/model/View.ts | 27 ++++ src/core/model/line/AbstractLine.ts | 29 +++++ src/core/model/line/CheckboxLine.ts | 84 ++++++++++++ src/core/model/line/ListLine.ts | 27 ++++ src/core/model/line/TextLine.ts | 9 ++ .../PropagateStateToChildrenProcess.ts | 66 ++++++++++ .../process/PropagateStateToParentProcess.ts | 57 +++++++++ 15 files changed, 617 insertions(+), 10 deletions(-) create mode 100644 src/core/CheckboxUtils2.ts create mode 100644 src/core/interface/CheckboxProcess.ts create mode 100644 src/core/interface/ICheckboxUtils.ts create mode 100644 src/core/model/Context.ts create mode 100644 src/core/model/ContextFactory.ts create mode 100644 src/core/model/Line.ts create mode 100644 src/core/model/TreeNode.ts create mode 100644 src/core/model/View.ts create mode 100644 src/core/model/line/AbstractLine.ts create mode 100644 src/core/model/line/CheckboxLine.ts create mode 100644 src/core/model/line/ListLine.ts create mode 100644 src/core/model/line/TextLine.ts create mode 100644 src/core/process/PropagateStateToChildrenProcess.ts create mode 100644 src/core/process/PropagateStateToParentProcess.ts diff --git a/package-lock.json b/package-lock.json index 7c4c947..6d81217 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2741,16 +2741,6 @@ "node": ">=8" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5673,6 +5663,16 @@ } } }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", diff --git a/src/core/CheckboxUtils2.ts b/src/core/CheckboxUtils2.ts new file mode 100644 index 0000000..1131a03 --- /dev/null +++ b/src/core/CheckboxUtils2.ts @@ -0,0 +1,37 @@ +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; +import { ICheckboxUtils } from "./interface/ICheckboxUtils"; +import { ContextFactory } from "./model/ContextFactory"; +import { PropagateStateToChildrenProcess } from "./process/PropagateStateToChildrenProcess"; +import { PropagateStateToParentProcess } from "./process/PropagateStateToParentProcess"; + +export interface CheckboxLineInfo { + indent: number; + marker: string; + checkChar?: string; + checkboxCharPosition?: number; + checkboxState: CheckboxState; + isChecked?: boolean | undefined; + listItemText?: string; +} + +export class CheckboxUtils2 implements ICheckboxUtils { + private settings: Readonly; + private propagateStateToChildrenProcces: PropagateStateToChildrenProcess; + private propagateStateToParentProcces: PropagateStateToParentProcess; + + constructor(settings: Readonly) { + this.settings = settings; + } + + syncText(text: string, textBefore: string | undefined): string { + if (text === textBefore) { + return text; + } + const context = ContextFactory.createContext(text, textBefore, this.settings); + + this.propagateStateToChildrenProcces.process(context); + this.propagateStateToParentProcces.process(context); + + return context.getResultText(); + } +} diff --git a/src/core/interface/CheckboxProcess.ts b/src/core/interface/CheckboxProcess.ts new file mode 100644 index 0000000..5523b83 --- /dev/null +++ b/src/core/interface/CheckboxProcess.ts @@ -0,0 +1,5 @@ +import { Context } from "../model/Context"; + +export interface CheckboxProcess { + process(context: Context): void; +} diff --git a/src/core/interface/ICheckboxUtils.ts b/src/core/interface/ICheckboxUtils.ts new file mode 100644 index 0000000..63ad24f --- /dev/null +++ b/src/core/interface/ICheckboxUtils.ts @@ -0,0 +1,3 @@ +export interface ICheckboxUtils { + syncText(text: string, textBefore: string | undefined): string; +} diff --git a/src/core/model/Context.ts b/src/core/model/Context.ts new file mode 100644 index 0000000..92141ce --- /dev/null +++ b/src/core/model/Context.ts @@ -0,0 +1,75 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { View } from "./View"; +import { Line } from "./Line"; +import { ContextFactory } from "./ContextFactory"; + +export class Context { + + private settings: Readonly; + + private text: string; + private textLines?: string[]; + + private textBeforeChange?: string; + private textBeforeChangeLines?: string[]; + + private lines?: Line[]; + private view?: View; + + constructor(text: string, textBeforeChange: string | undefined, settings: Readonly) { + this.text = text; + this.textBeforeChange = textBeforeChange; + this.settings = settings; + } + + getSettings(): Readonly { + return this.settings; + } + + textBeforeChangeIsPresent(): boolean { + if (this.textBeforeChange) { + return true; + } + return false; + } + + getText() { + return this.text; + } + + getTextLines() { + if (!this.textLines) { + this.textLines = this.getText().split("\n"); + } + return this.textLines; + } + + getTextBeforeChange() { + return this.textBeforeChange; + } + + getTextBeforeChangeLines() { + if (!this.textBeforeChangeLines) { + this.textBeforeChangeLines = this.textBeforeChange?.split("\n"); + } + return this.textBeforeChangeLines; + } + + getLines() { + if (!this.lines) { + this.lines = ContextFactory.createLines(this); + } + return this.lines; + } + + getView(): View { + if (!this.view) { + this.view = ContextFactory.createView(this); + } + return this.view; + } + + getResultText(): string { + return this.getView().toResultText(); + } +} diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts new file mode 100644 index 0000000..4aace2d --- /dev/null +++ b/src/core/model/ContextFactory.ts @@ -0,0 +1,121 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { Context } from "./Context"; +import { TreeNode } from "./TreeNode"; +import { Line } from "./Line"; +import { View } from "./View"; + +export class ContextFactory { + + private constructor() { + } + + public static createContext(text: string, textBefore: string | undefined, settings: Readonly): Context { + return new Context(text, textBefore, settings); + } + + static createLines(context: Context): Line[] { + const settings = context.getSettings(); + const textLines = context.getTextLines(); + const lines = this.createLinesFromTextLines(context.getTextLines(), settings); + if (context.textBeforeChangeIsPresent()) { + const textBeforeLines = context.getTextBeforeChangeLines()!; + if (textLines.length === textBeforeLines.length) { + const diffIndex = this.findSingleDiffLineIndex(textLines, textBeforeLines); + if (diffIndex) { + const oldLine = new Line(textBeforeLines[diffIndex], settings); + const actualLine = lines[diffIndex]; + + // если новая и старая строка чекбокс + // если текст остался старым + // если изменилось состояние чекбокса + if (oldLine.isCheckbox() && + actualLine.isCheckbox() && + oldLine.getText() === actualLine.getText() && + oldLine.getState() !== actualLine.getState() + ) { + actualLine.setChange(true); + } + } + } + } + return lines; + } + + static createView(context: Context) { + const settings = context.getSettings(); + + const lines = context.getLines(); + + // построить дерево + const treeNodes = this.createNodesFromLines(lines); + + // проверить ноды на modify + let isModify = false; + for (const treeNode of treeNodes) { + isModify = isModify || treeNode.isModify(); + } + + return new View(treeNodes, isModify); + } + + private static createNodesFromLines(textLines: Line[]): TreeNode[] { + const nodes: TreeNode[] = []; + const stack: TreeNode[] = []; + for (let index = 0; index < textLines.length; index++) { + const line = textLines[index]; + const node = new TreeNode(line); + + // найти родителя + let parent = undefined; + while (stack.length > 0) { + const previousNode = stack[stack.length - 1]; + const previousLine = previousNode.getLine(); + if (previousLine.getIndent() < line.getIndent()) { + parent = previousNode; + break; + } else { + stack.pop(); + } + } + // добавить родителю, если есть + parent?.addChildren(node); + + nodes.push(node); + stack.push(node); + } + return nodes; + } + + + + // создаёт Line[] из текста + private static createLinesFromTextLines(textLines: string[], settings: Readonly): Line[] { + return textLines.map(line => { + return new Line(line, settings); + }); + } + + private static findSingleDiffLineIndex(lines1: string[], lines2: string[]): number | undefined { + const diffLines = this.findDifferentLineIndexes(lines1, lines2); + if (diffLines.length == 1) { + return diffLines[0]; + } + return undefined; + } + + private static findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { + if (lines1.length !== lines2.length) { + throw new Error("the length of the lines must be equal"); + } + + const length = lines1.length; + const result: number[] = []; + for (let i = 0; i < length; i++) { + if (lines1[i] !== lines2[i]) { + result.push(i); + } + } + return result; + } +} + diff --git a/src/core/model/Line.ts b/src/core/model/Line.ts new file mode 100644 index 0000000..dce5e2b --- /dev/null +++ b/src/core/model/Line.ts @@ -0,0 +1,8 @@ +export interface Line { + getIndent(): number; + + getText(): string; + + toResultText(): string; + +} \ No newline at end of file diff --git a/src/core/model/TreeNode.ts b/src/core/model/TreeNode.ts new file mode 100644 index 0000000..83fb4de --- /dev/null +++ b/src/core/model/TreeNode.ts @@ -0,0 +1,59 @@ +import { Line } from "./Line"; + +export class TreeNode { + private parent?: TreeNode; + private childrens: TreeNode[]; + + private line: Line; + private modify = false; + + constructor(line: Line) { + this.line = line; + if (line.isChange()) { + this.modify = true; + } + } + + getLine(): Line { + return this.line; + } + + isModify(): boolean { + return this.modify; + } + + private setParent(parent: TreeNode) { + this.parent = parent; + } + + getParent(): TreeNode | undefined { + return this.parent; + } + + addChildren(children: TreeNode) { + this.childrens.push(children); + children.setParent(this); + + this.modify = this.modify || children.isModify(); + } + + hasChildren(): boolean { + return this.childrens.length > 0; + } + + getChildrenNodes(): TreeNode[] { + return [...this.childrens]; + } + + toResultText(): string { + const parts: string[] = []; + + parts.push(this.line.toResultText()); + + for (const children of this.childrens) { + parts.push(children.toResultText()); + } + + return parts.join("\n"); + } +} diff --git a/src/core/model/View.ts b/src/core/model/View.ts new file mode 100644 index 0000000..3d9b203 --- /dev/null +++ b/src/core/model/View.ts @@ -0,0 +1,27 @@ +import { TreeNode } from "./TreeNode"; + +export class View { + private treeNodes: TreeNode[]; + private modify: boolean; + + constructor(treeNodes: TreeNode[], isModify: boolean) { + this.treeNodes = treeNodes; + this.modify = isModify; + } + + isModify() { + return this.modify; + } + + getTreeNodes(): TreeNode[] { + return this.treeNodes; + } + + toResultText(): string { + const parts: string[] = []; + for (const treeNode of this.treeNodes) { + parts.push(treeNode.toResultText()); + } + return parts.join("\n"); + } +} diff --git a/src/core/model/line/AbstractLine.ts b/src/core/model/line/AbstractLine.ts new file mode 100644 index 0000000..6b2a90f --- /dev/null +++ b/src/core/model/line/AbstractLine.ts @@ -0,0 +1,29 @@ +import { Line } from "../Line"; + +export abstract class AbstractLine implements Line { + + // длина отступа + protected indent: number; + // текст после чекбокса + protected listText: string; + + constructor(indent:number, lineText: string){ + this.indent = indent; + this.listText = lineText; + + } + + getIndent(): number { + return this.indent; + } + + getText(): string { + return this.listText; + } + + setText(lineText: string){ + this.listText = lineText; + } + + abstract toResultText(): string; +} \ No newline at end of file diff --git a/src/core/model/line/CheckboxLine.ts b/src/core/model/line/CheckboxLine.ts new file mode 100644 index 0000000..3d71851 --- /dev/null +++ b/src/core/model/line/CheckboxLine.ts @@ -0,0 +1,84 @@ +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; +import { AbstractLine } from "./AbstractLine"; + +export class CheckboxLine extends AbstractLine { + + // символы перед чекбоксом + private marker: string; + // символ в чекбоксе + private checkChar: string; + // позиция символа чекбокса в исходной строке + private checkboxCharPosition: number; + // интерпретация символа чекбокса(или его отсутствия) + private checkboxState: CheckboxState; + // интерпретация состояния чекбокса(или его отсутствия) + // private isChecked?: boolean | undefined; + + + private hasChange = false; + + protected settings: Readonly; + + constructor(indent: number, marker: string, checkChar: string, lineText: string, settings: Readonly) { + super(indent, lineText); + this.marker = marker; + this.checkChar = checkChar; + this.settings = settings; + + this.checkboxCharPosition = ; + this.checkboxState = ; + throw new Error("Constructor not implemented."); + } + + isChange(): boolean { + return this.hasChange; + } + + setChange(hasChange: boolean) { + this.hasChange = hasChange; + } + + + + isCheckbox(): boolean { + return this.checkboxState !== CheckboxState.NoCheckbox; + } + + setState(state: CheckboxState): void { + // обновить checkboxState + this.checkboxState = state; + // обновить checkChar + switch (state) { + case CheckboxState.Checked: + this.checkChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт + break; + case CheckboxState.Unchecked: + this.checkChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт + break; + case CheckboxState.Ignore: + if (this.settings.ignoreSymbols.length > 0) { + this.checkChar = this.settings.ignoreSymbols[0]; + } else { + throw new Error("Not found ignore char."); + } + break; + case CheckboxState.NoCheckbox: + this.checkChar = undefined; + break; + default: + throw new Error(`Unexpected value for parameter [state] = ${state}.`); + } + } + + setStateIfNotEquals(newState: CheckboxState) { + if (newState !== this.checkboxState) { + this.setState(newState); + } + } + + getState(): CheckboxState { + return this.checkboxState; + } + + +} \ No newline at end of file diff --git a/src/core/model/line/ListLine.ts b/src/core/model/line/ListLine.ts new file mode 100644 index 0000000..66a05c8 --- /dev/null +++ b/src/core/model/line/ListLine.ts @@ -0,0 +1,27 @@ +import { AbstractLine } from "./AbstractLine"; + +export class ListLine extends AbstractLine { + + // символы перед текстом + private marker: string; + + constructor(indent: number, marker: string, listText: string) { + super(indent, listText); + this.marker = marker; + } + + getMarker(): string { + return this.marker; + } + + setMarker(marker: string) { + this.marker = marker; + } + + toResultText(): string { + const spaces = ' '.repeat(this.indent); + + const resultText = spaces + this.marker + " " + this.listText; + return resultText; + } +} \ No newline at end of file diff --git a/src/core/model/line/TextLine.ts b/src/core/model/line/TextLine.ts new file mode 100644 index 0000000..8d51a04 --- /dev/null +++ b/src/core/model/line/TextLine.ts @@ -0,0 +1,9 @@ +import { AbstractLine } from "./AbstractLine"; + +export class TextLine extends AbstractLine { + toResultText(): string { + const spaces = ' '.repeat(this.indent); + const resultText = spaces + this.listText; + return resultText; + } +} \ No newline at end of file diff --git a/src/core/process/PropagateStateToChildrenProcess.ts b/src/core/process/PropagateStateToChildrenProcess.ts new file mode 100644 index 0000000..fb3f93c --- /dev/null +++ b/src/core/process/PropagateStateToChildrenProcess.ts @@ -0,0 +1,66 @@ +import { CheckboxState } from "src/types"; +import { CheckboxProcess } from "../interface/CheckboxProcess"; +import { Context } from "../model/Context"; +import { TreeNode } from "../model/TreeNode"; + +export class PropagateStateToChildrenProcess implements CheckboxProcess { + + process(context: Context): void { + if (!context.getSettings().enableAutomaticChildState) { + return; + } + if (!context.textBeforeChangeIsPresent()) { + return; + } + // получить представление текста + const view = context.getView(); + + if (!view.isModify()) { + return; + } + + const nodes = view.getTreeNodes(); + for (const node of nodes) { + this.propageteStateToChildrenFromChangeLineNode(node); + } + } + + // находит изменённую Line, если она существует и вызывает от неё propagateStateToChildren + propageteStateToChildrenFromChangeLineNode(node: TreeNode) { + if (!node.isModify()) { + return; + } + const line = node.getLine(); + // если line изменён + if (line.isChange()) { + // значит мы нашли нужную ноду + this.propagateStateToChildren(node); + } else { + // иначе ищем вглубь среди детей + const childrens = node.getChildrenNodes(); + for (const children of childrens) { + this.propageteStateToChildrenFromChangeLineNode(children); + } + } + } + + + propagateStateToChildren(modifiedNode: TreeNode) { + const state = modifiedNode.getLine().getState(); + if (state === CheckboxState.Ignore || state === CheckboxState.NoCheckbox) { + console.warn(`propagateStateToChildren ${state.toString()}`); + return; + } + this.propagateToChildren(modifiedNode, state); + } + + propagateToChildren(node: TreeNode, state: CheckboxState) { + const line = node.getLine(); + if (line.isCheckbox()) { + line.setStateIfNotEquals(state); + } + for (const childrenNode of node.getChildrenNodes()) { + this.propagateToChildren(childrenNode, state); + } + } +} diff --git a/src/core/process/PropagateStateToParentProcess.ts b/src/core/process/PropagateStateToParentProcess.ts new file mode 100644 index 0000000..214a649 --- /dev/null +++ b/src/core/process/PropagateStateToParentProcess.ts @@ -0,0 +1,57 @@ +import { CheckboxState } from "src/types"; +import { CheckboxProcess } from "../interface/CheckboxProcess"; +import { Context } from "../model/Context"; +import { TreeNode } from "../model/TreeNode"; + +export class PropagateStateToParentProcess implements CheckboxProcess { + + process(context: Context): void { + if (!context.getSettings().enableAutomaticParentState) { + return; + } + // получить представление текста + const view = context.getView(); + + + const nodes = view.getTreeNodes(); + + for (const node of nodes) { + this.propagateStateFromChildrens(node); + } + } + + // возврат значения нужен для того, чтобы правильно обрабатывать случаи с "не чекбоксам", чтобы они передавали значения детей + propagateStateFromChildrens(node: TreeNode): CheckboxState { + const line = node.getLine(); + + if (!node.hasChildren()) { + return line.getState(); + } + + let resultIfHasRelevantChildren = CheckboxState.Checked; + let hasRelevantChildren = false; + for (const childrenNode of node.getChildrenNodes()) { + const childrenState = this.propagateStateFromChildrens(childrenNode); + + if (childrenState === CheckboxState.Checked || childrenState === CheckboxState.Unchecked) { + hasRelevantChildren = true; + } + + if (childrenState === CheckboxState.Unchecked) { + resultIfHasRelevantChildren = CheckboxState.Unchecked; + } + } + // делаем эту проверку только после обработки детей + if (line.getState() == CheckboxState.Ignore) { + return line.getState(); + } + // проверка для нод, все дети которых не релевантны + if (!hasRelevantChildren) { + return line.getState(); + } + + line.setStateIfNotEquals(resultIfHasRelevantChildren); + + return resultIfHasRelevantChildren; + } +} From 96727c61f563d1face168cf2fcbf2567d61ad475 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 5 Jul 2025 14:27:48 +0300 Subject: [PATCH 18/28] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + __tests__/TextSyncPipeline.test.ts | 2 +- __tests__/checkboxUtils.base.test.ts | 2 +- __tests__/checkboxUtils.plainLists.test.ts | 2 +- __tests__/core/CheckboxUtils2.test.ts | 312 +++++ .../fakes/InMemoryFilePathStateHolder.ts | 4 +- jest.config.ts | 18 +- package-lock.json | 1024 ++++++++++++++--- package.json | 1 + src/SyncController.ts | 22 +- src/TextSyncPipeline.ts | 6 +- src/core/CheckboxUtils2.ts | 22 +- src/{ => core}/checkboxUtils.ts | 6 +- src/core/model/ContextFactory.ts | 136 ++- src/core/model/Line.ts | 4 + src/core/model/TreeNode.ts | 6 +- src/core/model/View.ts | 8 +- src/core/model/line/AbstractLine.ts | 31 +- src/core/model/line/CheckboxLine.ts | 82 +- src/core/model/line/ListLine.ts | 14 +- src/core/model/line/TextLine.ts | 9 +- .../PropagateStateToChildrenProcess.ts | 28 +- .../process/PropagateStateToParentProcess.ts | 52 +- src/main.ts | 9 +- src/types.ts | 2 + 25 files changed, 1496 insertions(+), 307 deletions(-) create mode 100644 __tests__/core/CheckboxUtils2.test.ts rename src/{ => core}/checkboxUtils.ts (97%) diff --git a/.gitignore b/.gitignore index 295c0d0..29a06d7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ data.json .DS_Store coverage/ +test-report.html \ No newline at end of file diff --git a/__tests__/TextSyncPipeline.test.ts b/__tests__/TextSyncPipeline.test.ts index 180f243..1789b2a 100644 --- a/__tests__/TextSyncPipeline.test.ts +++ b/__tests__/TextSyncPipeline.test.ts @@ -1,4 +1,4 @@ -import { CheckboxUtils } from "src/checkboxUtils"; +import { CheckboxUtils } from "src/core/checkboxUtils"; import { FileFilter } from "src/FileFilter"; import TextSyncPipeline from "src/TextSyncPipeline"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types"; diff --git a/__tests__/checkboxUtils.base.test.ts b/__tests__/checkboxUtils.base.test.ts index c277cec..2a73eaf 100644 --- a/__tests__/checkboxUtils.base.test.ts +++ b/__tests__/checkboxUtils.base.test.ts @@ -1,4 +1,4 @@ -import { CheckboxLineInfo, CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу +import { CheckboxLineInfo, CheckboxUtils } from '../src/core/checkboxUtils'; // Путь к вашему файлу import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов // Вспомогательная функция для создания настроек с переопределениями diff --git a/__tests__/checkboxUtils.plainLists.test.ts b/__tests__/checkboxUtils.plainLists.test.ts index 45bd743..29ae833 100644 --- a/__tests__/checkboxUtils.plainLists.test.ts +++ b/__tests__/checkboxUtils.plainLists.test.ts @@ -1,6 +1,6 @@ // checkboxUtils.plainLists.test.ts -import { CheckboxUtils } from '../src/checkboxUtils'; +import { CheckboxUtils } from '../src/core/checkboxUtils'; import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Вспомогательная функция для создания настроек с переопределениями diff --git a/__tests__/core/CheckboxUtils2.test.ts b/__tests__/core/CheckboxUtils2.test.ts new file mode 100644 index 0000000..e85bffd --- /dev/null +++ b/__tests__/core/CheckboxUtils2.test.ts @@ -0,0 +1,312 @@ +// import { CheckboxLineInfo, CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу +import { CheckboxUtils2 } from 'src/core/CheckboxUtils2'; +import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from 'src/types'; // Путь к вашему файлу типов + +// Вспомогательная функция для создания настроек с переопределениями +const createSettings = (overrides: Partial = {}): Readonly => { + return { ...DEFAULT_SETTINGS, ...overrides } as Readonly; +}; + +describe('CheckboxUtils2', () => { + // --- Тесты для syncText --- + describe('syncText', () => { + it('should propagate down only if child sync enabled and diff matches', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: false, // Parent sync off + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterParentChecked = [ // Simulate user checking parent + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child', // Propagated down + ].join('\n'); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected); + }); + + it('should propagate up only if parent sync enabled', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: false, // Child sync off + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterChildChecked = [ // Simulate user checking child + '- [ ] Parent', + ' - [x] Child', + ].join('\n'); + const expected = [ + '- [x] Parent', // Propagated up + ' - [x] Child', + ].join('\n'); + // syncText calls propagateFromChildren unconditionally if enabled + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected); + }); + + it('should propagate down then up if both enabled', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterParentChecked = [ // Simulate user checking parent + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + // 1. User checks parent: '- [x] Parent', ' - [ ] Child' + // 2. Propagate down: '- [x] Parent', ' - [x] Child' + // 3. Propagate up: (no change needed as parent is already checked) + const expectedDown = [ + '- [x] Parent', + ' - [x] Child', // Propagated down + ].join('\n'); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown); + + const textAfterChildChecked = [ // Simulate user checking child + '- [ ] Parent', + ' - [x] Child', + ].join('\n'); + // 1. User checks child: '- [ ] Parent', ' - [x] Child' + // 2. Propagate down: (no change, as only one line changed, and it wasn't the parent) + // 3. Propagate up: '- [x] Parent', ' - [x] Child' + const expectedUp = [ + '- [x] Parent', // Propagated up + ' - [x] Child', + ].join('\n'); + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp); + }); + + it('should do nothing if both syncs disabled', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: false, + enableAutomaticParentState: false, + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterParentChecked = [ // Simulate user checking parent + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + const textAfterChildChecked = [ // Simulate user checking child + '- [ ] Parent', + ' - [x] Child', + ].join('\n'); + expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked); + expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked); + }); + + it('should not propagate down if diff is not a single checkbox state change', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, // Down propagation enabled + enableAutomaticParentState: false, + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + const textMultipleChanges = [ + '- [x] Parent Changed', // Text changed too + ' - [x] Child also changed', + ].join('\n'); + const textIndentChange = [ + ' - [x] Parent', // Indent changed + ' - [ ] Child', + ].join('\n'); + + // Multiple lines changed + expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges); + // Indent changed (diff > 1 line index) + expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange); + }); + + it('equals texts', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, // Down propagation enabled + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + + // Multiple lines changed + expect(utils.syncText(textBefore, textBefore)).toBe(textBefore); + }); + + it('checkbox, list', ()=> { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [ ] Parent', + ' - Child1', + ' - [ ] Child2', + ].join('\n'); + const actualText = [ + '- [ ] Parent', + ' - Child1', + ' - [x] Child2', + ].join('\n'); + const expectedText = [ + '- [x] Parent', + ' - Child1', + ' - [x] Child2', + ].join('\n'); + + // Multiple lines changed + expect(utils.syncText(actualText, textBefore)).toBe(expectedText); + }); + + it('checkbox, plain text', ()=> { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [ ] Parent', + ' Child1', + ' - [ ] Child2', + ].join('\n'); + const actualText = [ + '- [ ] Parent', + ' Child1', + ' - [x] Child2', + ].join('\n'); + const expectedText = [ + '- [x] Parent', + ' Child1', + ' - [x] Child2', + ].join('\n'); + + // Multiple lines changed + expect(utils.syncText(actualText, textBefore)).toBe(expectedText); + }); + + describe('without textBefore', () => { + it('done from child to parent', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const actualText = [ + '- [ ] Parent', + ' - [x] Child', + ].join('\n'); + const expected = [ + '- [x] Parent', + ' - [x] Child', + ].join('\n'); + expect(utils.syncText(actualText, undefined)).toBe(expected); + }); + it('todo from child to parent', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const actualText = [ + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + const expected = [ + '- [ ] Parent', + ' - [ ] Child', + ].join('\n'); + expect(utils.syncText(actualText, undefined)).toBe(expected); + }); + }); + describe('many roots', () => { + it('two roots witout text before', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const actualText = [ + '- [x] Parent1', + ' - [ ] Child1', + '- [ ] Parent2', + ' - [x] Child2', + ].join('\n'); + const expected = [ + '- [ ] Parent1', + ' - [ ] Child1', + '- [x] Parent2', + ' - [x] Child2', + ].join('\n'); + expect(utils.syncText(actualText, undefined)).toBe(expected); + }); + + it('two roots', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [x] Parent1', + ' - [x] Child1', + '- [ ] Parent2', + ' - [ ] Child2', + ].join('\n'); + const actualText = [ + '- [x] Parent1', + ' - [ ] Child1', + '- [ ] Parent2', + ' - [x] Child2', + ].join('\n'); + const expected = [ + '- [ ] Parent1', + ' - [ ] Child1', + '- [x] Parent2', + ' - [x] Child2', + ].join('\n'); + expect(utils.syncText(actualText, textBefore)).toBe(expected); + }); + + it('all type roots', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const textBefore = [ + '- [x] Parent1', + ' - [x] Child1', + '- Parent2', + ' - Child2', + 'Parent3', + ' Child3', + ].join('\n'); + const actualText = [ + '- [x] Parent1', + ' - [ ] Child1', + '- Parent2', + ' - Child2', + 'Parent3', + ' Child3', + ].join('\n'); + const expected = [ + '- [ ] Parent1', + ' - [ ] Child1', + '- Parent2', + ' - Child2', + 'Parent3', + ' Child3', + ].join('\n'); + expect(utils.syncText(actualText, textBefore)).toBe(expected); + }); + }); + + }); +}); \ No newline at end of file diff --git a/__tests__/fakes/InMemoryFilePathStateHolder.ts b/__tests__/fakes/InMemoryFilePathStateHolder.ts index 9861498..a89d3e3 100644 --- a/__tests__/fakes/InMemoryFilePathStateHolder.ts +++ b/__tests__/fakes/InMemoryFilePathStateHolder.ts @@ -8,13 +8,13 @@ export class InMemoryFilePathStateHolder implements IFilePathStateHolder { } public setByPath(filePath: string, text: string): void { - console.log(`InMemoryFileStateHolder: Setting state for "${filePath}"`); + // console.log(`InMemoryFileStateHolder: Setting state for "${filePath}"`); this.states.set(filePath, text); } public getByPath(filePath: string): string | undefined { const state = this.states.get(filePath); - console.log(`InMemoryFileStateHolder: Getting state for "${filePath}". Found: ${!!state}`); + // console.log(`InMemoryFileStateHolder: Getting state for "${filePath}". Found: ${!!state}`); return state; } diff --git a/jest.config.ts b/jest.config.ts index 27a022a..07e2ce5 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,10 +3,18 @@ * https://jestjs.io/docs/configuration */ -import type {Config} from 'jest'; +import type { Config } from 'jest'; const config: Config = { + reporters: [ + 'default', + ['jest-html-reporter', { + pageTitle: 'Test Report', + outputPath: './test-report.html', + }] + ], + // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, @@ -18,14 +26,14 @@ const config: Config = { // Indicates which provider should be used to instrument code for coverage // coverageProvider: "v8", - coverageProvider: "babel", + coverageProvider: "babel", // An array of file extensions your modules use moduleFileExtensions: [ "ts", "tsx", "js", - "jsx", + "jsx", "json", "node" ], @@ -43,11 +51,11 @@ const config: Config = { '^src/(.*)$': '/src/$1', }, - testPathIgnorePatterns: [ + testPathIgnorePatterns: [ "/node_modules/", // Хорошо иметь это явно, хотя Jest часто делает это по умолчанию "/__tests__/fakes/" // Исключаем папку fakes внутри __tests__ ], - + }; export default config; diff --git a/package-lock.json b/package-lock.json index 6d81217..092c3b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "builtin-modules": "3.3.0", "esbuild": "^0.25.0", "jest": "^29.7.0", + "jest-html-reporter": "^4.3.0", "obsidian": "^1.7.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", @@ -42,24 +43,24 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "dev": true, "license": "MIT", "engines": { @@ -67,22 +68,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -108,16 +109,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -125,14 +126,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -151,30 +152,40 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -194,9 +205,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -204,9 +215,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -214,9 +225,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -224,27 +235,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -493,58 +504,48 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1149,6 +1150,109 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1409,6 +1513,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1559,18 +1687,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1583,16 +1707,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1601,9 +1715,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1657,6 +1771,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2077,8 +2202,7 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/acorn": { "version": "8.14.0", @@ -2379,9 +2503,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2403,9 +2527,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "dev": true, "funding": [ { @@ -2423,10 +2547,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -2499,9 +2623,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001700", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", - "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "dev": true, "funding": [ { @@ -2680,6 +2804,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz", + "integrity": "sha512-EelsCzH0gMC2YmXuMeaZ3c6md1sUJQxyb1XXc4xaisi/K6qKukqZhKPrEQyRkdNIncgYyLoDTReq0nNyuKerTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2778,6 +2912,13 @@ "node": ">=6.0.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2795,9 +2936,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.102", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz", - "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==", + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", "dev": true, "license": "ISC" }, @@ -3185,6 +3326,16 @@ "node": ">= 0.8.0" } }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -3300,9 +3451,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3377,6 +3528,36 @@ "license": "ISC", "peer": true }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3852,6 +4033,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -4125,6 +4322,442 @@ "fsevents": "^2.3.2" } }, + "node_modules/jest-html-reporter": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jest-html-reporter/-/jest-html-reporter-4.3.0.tgz", + "integrity": "sha512-lq4Zx35yc6Ehw513CXJ1ok3wUmkSiOImWcyLAmylfzrz7DAqtrhDF9V73F4qfstmGxlr8X0QrEjWsl/oqhf4sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/reporters": "^30.0.2", + "@jest/test-result": "^30.0.2", + "@jest/types": "^30.0.1", + "dateformat": "3.0.2", + "mkdirp": "^1.0.3", + "strip-ansi": "6.0.1", + "xmlbuilder": "15.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "jest": "19.x - 30.x", + "typescript": "^3.7.x || ^4.3.x || ^5.x" + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/console": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.4.tgz", + "integrity": "sha512-tMLCDvBJBwPqMm4OAiuKm2uF5y5Qe26KgcMn+nrDSWpEW+eeFmqA0iO4zJfL16GP7gE3bUUQ3hIuUJ22AqVRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.0.2", + "jest-util": "30.0.2", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/reporters": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.4.tgz", + "integrity": "sha512-6ycNmP0JSJEEys1FbIzHtjl9BP0tOZ/KN6iMeAKrdvGmUsa1qfRdlQRUDKJ4P84hJ3xHw1yTqJt4fvPNHhyE+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.0.2", + "jest-util": "30.0.2", + "jest-worker": "30.0.2", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/schemas": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/test-result": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.4.tgz", + "integrity": "sha512-Mfpv8kjyKTHqsuu9YugB6z1gcdB3TSSOaKlehtVaiNlClMkEHY+5ZqCY2CrEE3ntpBMlstX/ShDAf84HKWsyIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.0.4", + "@jest/types": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/transform": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.4.tgz", + "integrity": "sha512-atvy4hRph/UxdCIBp+UB2jhEA/jJiUeGZ7QPgBi9jUUKNgi3WEoMXGNG7zbbELG2+88PMabUNCDchmqgJy3ELg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.0.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.2", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.2", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/@jest/types": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", + "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/@sinclair/typebox": { + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-html-reporter/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-html-reporter/node_modules/babel-plugin-istanbul": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", + "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-html-reporter/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-html-reporter/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-html-reporter/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-html-reporter/node_modules/jest-haste-map": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.2.tgz", + "integrity": "sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.2", + "jest-worker": "30.0.2", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-html-reporter/node_modules/jest-message-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", + "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/jest-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", + "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/jest-worker": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.2.tgz", + "integrity": "sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.0.2", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-html-reporter/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-html-reporter/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-html-reporter/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-html-reporter/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-html-reporter/node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -4714,6 +5347,29 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -4878,6 +5534,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4948,6 +5611,30 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4979,9 +5666,9 @@ } }, "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", "engines": { @@ -5447,6 +6134,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5460,6 +6163,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -5755,9 +6472,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -5881,6 +6598,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5902,6 +6638,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xmlbuilder": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.0.0.tgz", + "integrity": "sha512-KLu/G0DoWhkncQ9eHSI6s0/w+T4TM7rQaLhtCaL6tORv8jFlJPlnGumsgTcGfYeS1qZ/IHqrvDG7zJZ4d7e+nw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 310d583..f5f59f5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "builtin-modules": "3.3.0", "esbuild": "^0.25.0", "jest": "^29.7.0", + "jest-html-reporter": "^4.3.0", "obsidian": "^1.7.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", diff --git a/src/SyncController.ts b/src/SyncController.ts index 7ab4eff..0e3708a 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -1,18 +1,15 @@ import { Mutex } from "async-mutex"; import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian"; -import { CheckboxUtils } from "./checkboxUtils"; import TextSyncPipeline from "./TextSyncPipeline"; export default class SyncController { private vault: Vault; - private checkboxUtils: CheckboxUtils; textSyncPipeline: TextSyncPipeline private mutex: Mutex; - constructor(vault: Vault, checkboxUtils: CheckboxUtils, textSyncPipeline: TextSyncPipeline) { + constructor(vault: Vault, textSyncPipeline: TextSyncPipeline) { this.vault = vault; - this.checkboxUtils = checkboxUtils; this.textSyncPipeline = textSyncPipeline; this.mutex = new Mutex(); } @@ -47,7 +44,7 @@ export default class SyncController { const newLines = newText.split("\n"); const oldLines = oldText.split("\n"); - const diffIndexes = this.checkboxUtils.findDifferentLineIndexes(oldLines, newLines); + const diffIndexes = this.findDifferentLineIndexes(oldLines, newLines); const changes: EditorChange[] = []; @@ -73,4 +70,19 @@ export default class SyncController { } } + private findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { + if (lines1.length !== lines2.length) { + throw new Error("the length of the lines must be equal"); + } + + const length = lines1.length; + const result: number[] = []; + for (let i = 0; i < length; i++) { + if (lines1[i] !== lines2[i]) { + result.push(i); + } + } + return result; + } + } diff --git a/src/TextSyncPipeline.ts b/src/TextSyncPipeline.ts index d02fc31..cac317f 100644 --- a/src/TextSyncPipeline.ts +++ b/src/TextSyncPipeline.ts @@ -1,14 +1,14 @@ -import { CheckboxUtils } from "./checkboxUtils"; +import { ICheckboxUtils } from "./core/interface/ICheckboxUtils"; import { FileFilter } from "./FileFilter"; import { IFilePathStateHolder } from "./IFilePathStateHolder"; export default class TextSyncPipeline { - private checkboxUtils: CheckboxUtils; + private checkboxUtils: ICheckboxUtils; private fileStateHolder: IFilePathStateHolder; private fileFilter: FileFilter; - constructor(checkboxUtils: CheckboxUtils, fileStateHolder: IFilePathStateHolder, fileFilter: FileFilter) { + constructor(checkboxUtils: ICheckboxUtils, fileStateHolder: IFilePathStateHolder, fileFilter: FileFilter) { this.checkboxUtils = checkboxUtils; this.fileStateHolder = fileStateHolder; this.fileFilter = fileFilter; diff --git a/src/core/CheckboxUtils2.ts b/src/core/CheckboxUtils2.ts index 1131a03..d70f9b8 100644 --- a/src/core/CheckboxUtils2.ts +++ b/src/core/CheckboxUtils2.ts @@ -1,26 +1,18 @@ -import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; +import { CheckboxSyncPluginSettings } from "src/types"; import { ICheckboxUtils } from "./interface/ICheckboxUtils"; import { ContextFactory } from "./model/ContextFactory"; import { PropagateStateToChildrenProcess } from "./process/PropagateStateToChildrenProcess"; import { PropagateStateToParentProcess } from "./process/PropagateStateToParentProcess"; -export interface CheckboxLineInfo { - indent: number; - marker: string; - checkChar?: string; - checkboxCharPosition?: number; - checkboxState: CheckboxState; - isChecked?: boolean | undefined; - listItemText?: string; -} - export class CheckboxUtils2 implements ICheckboxUtils { private settings: Readonly; - private propagateStateToChildrenProcces: PropagateStateToChildrenProcess; - private propagateStateToParentProcces: PropagateStateToParentProcess; + private propagateStateToChildrenProcess: PropagateStateToChildrenProcess; + private propagateStateToParentProcess: PropagateStateToParentProcess; constructor(settings: Readonly) { this.settings = settings; + this.propagateStateToChildrenProcess = new PropagateStateToChildrenProcess(); + this.propagateStateToParentProcess = new PropagateStateToParentProcess(); } syncText(text: string, textBefore: string | undefined): string { @@ -29,8 +21,8 @@ export class CheckboxUtils2 implements ICheckboxUtils { } const context = ContextFactory.createContext(text, textBefore, this.settings); - this.propagateStateToChildrenProcces.process(context); - this.propagateStateToParentProcces.process(context); + this.propagateStateToChildrenProcess.process(context); + this.propagateStateToParentProcess.process(context); return context.getResultText(); } diff --git a/src/checkboxUtils.ts b/src/core/checkboxUtils.ts similarity index 97% rename from src/checkboxUtils.ts rename to src/core/checkboxUtils.ts index 7bdbc85..72e39d2 100644 --- a/src/checkboxUtils.ts +++ b/src/core/checkboxUtils.ts @@ -1,4 +1,6 @@ -import { CheckboxState, CheckboxSyncPluginSettings } from "./types"; +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; +import { ICheckboxUtils } from "./interface/ICheckboxUtils"; + export interface CheckboxLineInfo { indent: number; @@ -10,7 +12,7 @@ export interface CheckboxLineInfo { listItemText?: string; } -export class CheckboxUtils { +export class CheckboxUtils implements ICheckboxUtils { private settings: Readonly; constructor(settings: Readonly) { this.settings = settings; diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts index 4aace2d..888091b 100644 --- a/src/core/model/ContextFactory.ts +++ b/src/core/model/ContextFactory.ts @@ -3,66 +3,42 @@ import { Context } from "./Context"; import { TreeNode } from "./TreeNode"; import { Line } from "./Line"; import { View } from "./View"; +import { CheckboxLine } from "./line/CheckboxLine"; +import { ListLine } from "./line/ListLine"; +import { TextLine } from "./line/TextLine"; export class ContextFactory { + static readonly CHECKBOX_REGEXP = /^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/; + static readonly LIST_ITEM_REGEXP = /^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/; + static readonly TEXT_REGEXP = /^(\s*)(.*)$/; + private constructor() { } - public static createContext(text: string, textBefore: string | undefined, settings: Readonly): Context { + static createContext(text: string, textBefore: string | undefined, settings: Readonly): Context { return new Context(text, textBefore, settings); } - static createLines(context: Context): Line[] { - const settings = context.getSettings(); - const textLines = context.getTextLines(); - const lines = this.createLinesFromTextLines(context.getTextLines(), settings); - if (context.textBeforeChangeIsPresent()) { - const textBeforeLines = context.getTextBeforeChangeLines()!; - if (textLines.length === textBeforeLines.length) { - const diffIndex = this.findSingleDiffLineIndex(textLines, textBeforeLines); - if (diffIndex) { - const oldLine = new Line(textBeforeLines[diffIndex], settings); - const actualLine = lines[diffIndex]; - - // если новая и старая строка чекбокс - // если текст остался старым - // если изменилось состояние чекбокса - if (oldLine.isCheckbox() && - actualLine.isCheckbox() && - oldLine.getText() === actualLine.getText() && - oldLine.getState() !== actualLine.getState() - ) { - actualLine.setChange(true); - } - } - } - } - return lines; - } - static createView(context: Context) { - const settings = context.getSettings(); - const lines = context.getLines(); - - // построить дерево - const treeNodes = this.createNodesFromLines(lines); - - // проверить ноды на modify - let isModify = false; - for (const treeNode of treeNodes) { - isModify = isModify || treeNode.isModify(); - } - - return new View(treeNodes, isModify); + const treeNodes = this.createRootTreeNodesFromLines(lines); + return new View(treeNodes); } - private static createNodesFromLines(textLines: Line[]): TreeNode[] { + // создаёт TreeNode из Line + // возвращает только корневые узлы + private static createRootTreeNodesFromLines(lines: Line[]): TreeNode[] { + const flatTreeNodes = this.createFlatNodesFromLines(lines); + return flatTreeNodes.filter(node => !node.getParent()); + } + + // создаёт TreeNode из Line + // осторожно, возвращает ВСЕ ноды в порядке линий + private static createFlatNodesFromLines(lines: Line[]): TreeNode[] { const nodes: TreeNode[] = []; const stack: TreeNode[] = []; - for (let index = 0; index < textLines.length; index++) { - const line = textLines[index]; + for (const line of lines) { const node = new TreeNode(line); // найти родителя @@ -86,15 +62,83 @@ export class ContextFactory { return nodes; } + static createLines(context: Context): Line[] { + const settings = context.getSettings(); + const lines = this.createLinesFromTextLines(context.getTextLines(), settings); + this.markChangeIfNeeded(lines, context); + return lines; + } + // помечает line как изменённую при необходимости + static markChangeIfNeeded(lines: Line[], context: Context) { + if (!context.textBeforeChangeIsPresent()) { + return; + } + + const textLines = context.getTextLines(); + const textBeforeLines = context.getTextBeforeChangeLines()!; + if (textLines.length !== textBeforeLines.length) { + return; + } + + const diffIndex = this.findSingleDiffLineIndex(textLines, textBeforeLines); + if (diffIndex === undefined) { + return; + } + + const oldLine = this.createLineFromTextLine(textBeforeLines[diffIndex], context.getSettings()); + const actualLine = lines[diffIndex]; + + // если новая и старая строка чекбокс + if (oldLine instanceof CheckboxLine && actualLine instanceof CheckboxLine) { + // если текст остался старым + // если изменилось состояние чекбокса + if ( + oldLine.getText() === actualLine.getText() && + oldLine.getState() !== actualLine.getState() + ) { + actualLine.setChange(true); + } + } + + + } // создаёт Line[] из текста private static createLinesFromTextLines(textLines: string[], settings: Readonly): Line[] { return textLines.map(line => { - return new Line(line, settings); + return this.createLineFromTextLine(line, settings); }); } + // создаёт объект из одного из классов, кто реализует Line в зависимости от строки. + static createLineFromTextLine(textLine: string, settings: Readonly): Line { + const checkboxMatch = textLine.match(this.CHECKBOX_REGEXP); + if (checkboxMatch) { + const indentString = checkboxMatch[1]; + const marker = checkboxMatch[2]; + const checkChar = checkboxMatch[3]; + + const listItemText = checkboxMatch[4].trimStart(); + + return new CheckboxLine(indentString, marker, checkChar, listItemText, settings); + } + const listItemMatch = textLine.match(this.LIST_ITEM_REGEXP); + if (listItemMatch) { + const indentString = listItemMatch[1]; + const marker = listItemMatch[2]; + const listItemText = listItemMatch[3].trimStart(); + + return new ListLine(indentString, marker, listItemText, settings.tabSize); + } + + const textLineMatch = textLine.match(this.TEXT_REGEXP)!; + const indentString = textLineMatch[1]; + const itemText = textLineMatch[2]; + return new TextLine(indentString, itemText, settings.tabSize); + + } + private static findSingleDiffLineIndex(lines1: string[], lines2: string[]): number | undefined { const diffLines = this.findDifferentLineIndexes(lines1, lines2); if (diffLines.length == 1) { diff --git a/src/core/model/Line.ts b/src/core/model/Line.ts index dce5e2b..4dc1593 100644 --- a/src/core/model/Line.ts +++ b/src/core/model/Line.ts @@ -1,3 +1,5 @@ +import { CheckboxState } from "src/types"; + export interface Line { getIndent(): number; @@ -5,4 +7,6 @@ export interface Line { toResultText(): string; + getState(): CheckboxState; + } \ No newline at end of file diff --git a/src/core/model/TreeNode.ts b/src/core/model/TreeNode.ts index 83fb4de..4e7b56d 100644 --- a/src/core/model/TreeNode.ts +++ b/src/core/model/TreeNode.ts @@ -1,15 +1,17 @@ import { Line } from "./Line"; +import { CheckboxLine } from "./line/CheckboxLine"; export class TreeNode { private parent?: TreeNode; - private childrens: TreeNode[]; + private childrens: TreeNode[] = []; private line: Line; + // метка того, что Единичное изменение произошло в этой ноде или дочерних нодах private modify = false; constructor(line: Line) { this.line = line; - if (line.isChange()) { + if (line instanceof CheckboxLine && line.isChange()) { this.modify = true; } } diff --git a/src/core/model/View.ts b/src/core/model/View.ts index 3d9b203..501e75f 100644 --- a/src/core/model/View.ts +++ b/src/core/model/View.ts @@ -2,10 +2,16 @@ import { TreeNode } from "./TreeNode"; export class View { private treeNodes: TreeNode[]; + // метка того, что Единичное изменение произошло в этой View private modify: boolean; - constructor(treeNodes: TreeNode[], isModify: boolean) { + constructor(treeNodes: TreeNode[]) { this.treeNodes = treeNodes; + // проверить ноды на modify + let isModify = false; + for (const treeNode of treeNodes) { + isModify = isModify || treeNode.isModify(); + } this.modify = isModify; } diff --git a/src/core/model/line/AbstractLine.ts b/src/core/model/line/AbstractLine.ts index 6b2a90f..19a9bf3 100644 --- a/src/core/model/line/AbstractLine.ts +++ b/src/core/model/line/AbstractLine.ts @@ -1,16 +1,33 @@ +import { CheckboxState } from "src/types"; import { Line } from "../Line"; export abstract class AbstractLine implements Line { + protected indentString: string; + protected tabSize: number; // длина отступа protected indent: number; // текст после чекбокса - protected listText: string; + protected listText: string; - constructor(indent:number, lineText: string){ - this.indent = indent; + constructor(indentString: string, lineText: string, tabSize: number) { + this.indentString = indentString; this.listText = lineText; - + this.tabSize = tabSize; + + this.indent = this.getIndentFromString(indentString, tabSize); + } + + private getIndentFromString(indentString: string, tabSize: number): number { + let indent = 0; + for (const char of indentString) { + if (char === '\t') { + indent += tabSize; + } else if (char === ' ') { + indent += 1; + } + } + return indent; } getIndent(): number { @@ -21,9 +38,7 @@ export abstract class AbstractLine implements Line { return this.listText; } - setText(lineText: string){ - this.listText = lineText; - } - abstract toResultText(): string; + + abstract getState(): CheckboxState; } \ No newline at end of file diff --git a/src/core/model/line/CheckboxLine.ts b/src/core/model/line/CheckboxLine.ts index 3d71851..00b9e9e 100644 --- a/src/core/model/line/CheckboxLine.ts +++ b/src/core/model/line/CheckboxLine.ts @@ -2,32 +2,31 @@ import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; import { AbstractLine } from "./AbstractLine"; export class CheckboxLine extends AbstractLine { - + // символы перед чекбоксом private marker: string; // символ в чекбоксе private checkChar: string; // позиция символа чекбокса в исходной строке - private checkboxCharPosition: number; + // private checkboxCharPosition: number; // интерпретация символа чекбокса(или его отсутствия) private checkboxState: CheckboxState; // интерпретация состояния чекбокса(или его отсутствия) // private isChecked?: boolean | undefined; - - private hasChange = false; + // метка того, что Единичное изменение произошло в этой строке + private hasChange = false; protected settings: Readonly; - constructor(indent: number, marker: string, checkChar: string, lineText: string, settings: Readonly) { - super(indent, lineText); + constructor(indentString:string, marker: string, checkChar: string, listItemText: string, settings: Readonly) { + super(indentString, listItemText, settings.tabSize); this.marker = marker; this.checkChar = checkChar; this.settings = settings; - this.checkboxCharPosition = ; - this.checkboxState = ; - throw new Error("Constructor not implemented."); + // this.checkboxCharPosition = indent + marker.length + 2; + this.checkboxState = this.getCheckboxState(checkChar); } isChange(): boolean { @@ -38,36 +37,12 @@ export class CheckboxLine extends AbstractLine { this.hasChange = hasChange; } - - - isCheckbox(): boolean { - return this.checkboxState !== CheckboxState.NoCheckbox; - } setState(state: CheckboxState): void { // обновить checkboxState this.checkboxState = state; // обновить checkChar - switch (state) { - case CheckboxState.Checked: - this.checkChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт - break; - case CheckboxState.Unchecked: - this.checkChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт - break; - case CheckboxState.Ignore: - if (this.settings.ignoreSymbols.length > 0) { - this.checkChar = this.settings.ignoreSymbols[0]; - } else { - throw new Error("Not found ignore char."); - } - break; - case CheckboxState.NoCheckbox: - this.checkChar = undefined; - break; - default: - throw new Error(`Unexpected value for parameter [state] = ${state}.`); - } + this.checkChar = this.getCharFromState(state); } setStateIfNotEquals(newState: CheckboxState) { @@ -80,5 +55,42 @@ export class CheckboxLine extends AbstractLine { return this.checkboxState; } - + toResultText(): string { + // const spaces = ' '.repeat(this.indent); + const resultText = this.indentString + this.marker + " [" + this.checkChar + "] " + this.listText; + return resultText; + } + + private getCheckboxState(text: string): CheckboxState { + if (this.settings.checkedSymbols.includes(text)) { + return CheckboxState.Checked; + } + if (this.settings.uncheckedSymbols.includes(text)) { + return CheckboxState.Unchecked; + } + if (this.settings.ignoreSymbols.includes(text)) { + return CheckboxState.Ignore; + } + return this.settings.unknownSymbolPolicy; + } + + private getCharFromState(state: CheckboxState): string { + switch (state) { + case CheckboxState.Checked: + return this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт + case CheckboxState.Unchecked: + return this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт + case CheckboxState.Ignore: + if (this.settings.ignoreSymbols.length > 0) { + return this.settings.ignoreSymbols[0]; + } else { + throw new Error("Not found ignore char."); + } + case CheckboxState.NoCheckbox: + throw new Error(`Unexpected value for parameter [state] = ${state}. "NoCheckbox".`); + default: + throw new Error(`Unexpected value for parameter [state] = ${state}.`); + } + } + } \ No newline at end of file diff --git a/src/core/model/line/ListLine.ts b/src/core/model/line/ListLine.ts index 66a05c8..87b5c61 100644 --- a/src/core/model/line/ListLine.ts +++ b/src/core/model/line/ListLine.ts @@ -1,3 +1,4 @@ +import { CheckboxState } from "src/types"; import { AbstractLine } from "./AbstractLine"; export class ListLine extends AbstractLine { @@ -5,8 +6,8 @@ export class ListLine extends AbstractLine { // символы перед текстом private marker: string; - constructor(indent: number, marker: string, listText: string) { - super(indent, listText); + constructor(indentString: string, marker: string, listText: string, tabSize: number) { + super(indentString, listText, tabSize); this.marker = marker; } @@ -19,9 +20,12 @@ export class ListLine extends AbstractLine { } toResultText(): string { - const spaces = ' '.repeat(this.indent); - - const resultText = spaces + this.marker + " " + this.listText; + // const spaces = ' '.repeat(this.indent); + const resultText = this.indentString + this.marker + " " + this.listText; return resultText; } + + getState(): CheckboxState { + return CheckboxState.NoCheckbox; + } } \ No newline at end of file diff --git a/src/core/model/line/TextLine.ts b/src/core/model/line/TextLine.ts index 8d51a04..e56ea53 100644 --- a/src/core/model/line/TextLine.ts +++ b/src/core/model/line/TextLine.ts @@ -1,9 +1,14 @@ +import { CheckboxState } from "src/types"; import { AbstractLine } from "./AbstractLine"; export class TextLine extends AbstractLine { toResultText(): string { - const spaces = ' '.repeat(this.indent); - const resultText = spaces + this.listText; + // const spaces = ' '.repeat(this.indent); + const resultText = this.indentString + this.listText; return resultText; } + + getState(): CheckboxState { + return CheckboxState.NoCheckbox; + } } \ No newline at end of file diff --git a/src/core/process/PropagateStateToChildrenProcess.ts b/src/core/process/PropagateStateToChildrenProcess.ts index fb3f93c..d8416c3 100644 --- a/src/core/process/PropagateStateToChildrenProcess.ts +++ b/src/core/process/PropagateStateToChildrenProcess.ts @@ -2,6 +2,7 @@ import { CheckboxState } from "src/types"; import { CheckboxProcess } from "../interface/CheckboxProcess"; import { Context } from "../model/Context"; import { TreeNode } from "../model/TreeNode"; +import { CheckboxLine } from "../model/line/CheckboxLine"; export class PropagateStateToChildrenProcess implements CheckboxProcess { @@ -21,46 +22,51 @@ export class PropagateStateToChildrenProcess implements CheckboxProcess { const nodes = view.getTreeNodes(); for (const node of nodes) { - this.propageteStateToChildrenFromChangeLineNode(node); + this.propagateStateToChildrenFromChangeLineNode(node); } } // находит изменённую Line, если она существует и вызывает от неё propagateStateToChildren - propageteStateToChildrenFromChangeLineNode(node: TreeNode) { + propagateStateToChildrenFromChangeLineNode(node: TreeNode) { if (!node.isModify()) { return; } const line = node.getLine(); // если line изменён - if (line.isChange()) { + if (line instanceof CheckboxLine && line.isChange()) { // значит мы нашли нужную ноду - this.propagateStateToChildren(node); + this.propagateStateToChildrenFromNode(node); } else { // иначе ищем вглубь среди детей const childrens = node.getChildrenNodes(); for (const children of childrens) { - this.propageteStateToChildrenFromChangeLineNode(children); + this.propagateStateToChildrenFromChangeLineNode(children); } } } - propagateStateToChildren(modifiedNode: TreeNode) { - const state = modifiedNode.getLine().getState(); + propagateStateToChildrenFromNode(modifiedNode: TreeNode) { + const line = modifiedNode.getLine(); + if (!(line instanceof CheckboxLine)) { + console.warn(`propagateStateToChildren from !CheckboxLine.`); + return; + } + const state = line.getState(); if (state === CheckboxState.Ignore || state === CheckboxState.NoCheckbox) { console.warn(`propagateStateToChildren ${state.toString()}`); return; } - this.propagateToChildren(modifiedNode, state); + this.propagateStateToChildren(modifiedNode, state); } - propagateToChildren(node: TreeNode, state: CheckboxState) { + propagateStateToChildren(node: TreeNode, state: CheckboxState) { const line = node.getLine(); - if (line.isCheckbox()) { + if (line instanceof CheckboxLine) { line.setStateIfNotEquals(state); } for (const childrenNode of node.getChildrenNodes()) { - this.propagateToChildren(childrenNode, state); + this.propagateStateToChildren(childrenNode, state); } } } diff --git a/src/core/process/PropagateStateToParentProcess.ts b/src/core/process/PropagateStateToParentProcess.ts index 214a649..b26becc 100644 --- a/src/core/process/PropagateStateToParentProcess.ts +++ b/src/core/process/PropagateStateToParentProcess.ts @@ -2,6 +2,7 @@ import { CheckboxState } from "src/types"; import { CheckboxProcess } from "../interface/CheckboxProcess"; import { Context } from "../model/Context"; import { TreeNode } from "../model/TreeNode"; +import { CheckboxLine } from "../model/line/CheckboxLine"; export class PropagateStateToParentProcess implements CheckboxProcess { @@ -16,24 +17,42 @@ export class PropagateStateToParentProcess implements CheckboxProcess { const nodes = view.getTreeNodes(); for (const node of nodes) { - this.propagateStateFromChildrens(node); + this.propagateStateToParent(node); } } // возврат значения нужен для того, чтобы правильно обрабатывать случаи с "не чекбоксам", чтобы они передавали значения детей - propagateStateFromChildrens(node: TreeNode): CheckboxState { + propagateStateToParent(node: TreeNode): CheckboxState { const line = node.getLine(); - if (!node.hasChildren()) { - return line.getState(); + // обходим всех детей + const childrenStates: CheckboxState[] = []; + for (const childrenNode of node.getChildrenNodes()) { + const childrenState = this.propagateStateToParent(childrenNode); + childrenStates.push(childrenState); } + const newState = this.getNewState(line.getState(), childrenStates); + + if (line instanceof CheckboxLine){ + line.setStateIfNotEquals(newState); + } + + return newState; + } + + getNewState(actualState: CheckboxState, childrenStates: CheckboxState[]): CheckboxState { + if (actualState === CheckboxState.Ignore) { + return actualState; + } + + // обходим всех детей + // если есть релевантные дети, то помечаем это и обновляем "состояние" Line + // "состояние" - потому что даже не чекбоксы могут иметь состояние через своих детей let resultIfHasRelevantChildren = CheckboxState.Checked; let hasRelevantChildren = false; - for (const childrenNode of node.getChildrenNodes()) { - const childrenState = this.propagateStateFromChildrens(childrenNode); - - if (childrenState === CheckboxState.Checked || childrenState === CheckboxState.Unchecked) { + for (const childrenState of childrenStates) { + if (childrenState !== CheckboxState.Ignore && childrenState !== CheckboxState.NoCheckbox) { hasRelevantChildren = true; } @@ -41,17 +60,12 @@ export class PropagateStateToParentProcess implements CheckboxProcess { resultIfHasRelevantChildren = CheckboxState.Unchecked; } } - // делаем эту проверку только после обработки детей - if (line.getState() == CheckboxState.Ignore) { - return line.getState(); - } - // проверка для нод, все дети которых не релевантны - if (!hasRelevantChildren) { - return line.getState(); - } - line.setStateIfNotEquals(resultIfHasRelevantChildren); - - return resultIfHasRelevantChildren; + if (hasRelevantChildren) { + return resultIfHasRelevantChildren; + } else { + return actualState; + } } + } diff --git a/src/main.ts b/src/main.ts index 8d9c097..c240324 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ import { Plugin, TFile } from "obsidian"; -import { CheckboxUtils } from "./checkboxUtils"; import { FileChangeEventHandler } from "./events/FileChangeEventHandler"; import { FileLoadEventHandler } from "./events/FileLoadEventHandler"; import FileStateHolder from "./FileStateHolder"; @@ -8,6 +7,8 @@ import SyncController from "./SyncController"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types"; import { FileFilter } from "./FileFilter"; import TextSyncPipeline from "./TextSyncPipeline"; +import { ICheckboxUtils } from "./core/interface/ICheckboxUtils"; +import { CheckboxUtils2 } from "./core/CheckboxUtils2"; const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG'; @@ -23,7 +24,7 @@ export default class CheckboxSyncPlugin extends Plugin { private _settings: CheckboxSyncPluginSettings; private syncController: SyncController; - private checkboxUtils: CheckboxUtils; + private checkboxUtils: ICheckboxUtils; private fileStateHolder: FileStateHolder; private fileLoadEventHandler: FileLoadEventHandler; private fileChangeEventHandler: FileChangeEventHandler; @@ -35,9 +36,9 @@ export default class CheckboxSyncPlugin extends Plugin { this.fileFilter = new FileFilter(this.settings); this.fileStateHolder = new FileStateHolder(this.app.vault); - this.checkboxUtils = new CheckboxUtils(this.settings); + this.checkboxUtils = new CheckboxUtils2(this.settings); this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils,this.fileStateHolder, this.fileFilter); - this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.textSyncPipeline); + this.syncController = new SyncController(this.app.vault, this.textSyncPipeline); this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); diff --git a/src/types.ts b/src/types.ts index 6c16730..674d810 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ export interface CheckboxSyncPluginSettings { + tabSize: number, enableAutomaticParentState: boolean; enableAutomaticChildState: boolean; checkedSymbols: string[]; @@ -18,6 +19,7 @@ export enum CheckboxState { } export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { + tabSize: 4, enableAutomaticParentState: true, enableAutomaticChildState: true, checkedSymbols: ['x'], From 2be5325eaf6911a2aac8623804296e3e5c727264 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 5 Jul 2025 15:28:47 +0300 Subject: [PATCH 19/28] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=D0=B0=20=D1=81=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/SyncController.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SyncController.ts b/src/SyncController.ts index 0e3708a..31f54a4 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -22,7 +22,7 @@ export default class SyncController { const currentText = editor.getValue(); const resultingText = this.textSyncPipeline.applySyncLogic(currentText, info.file!.path); if (resultingText !== currentText) { - this.editEditor(editor, currentText, resultingText); + await this.editEditor(editor, info as MarkdownView, currentText, resultingText); } }); } @@ -32,13 +32,13 @@ export default class SyncController { return; } await this.mutex.runExclusive(async () => { - this.vault.process(file, (currentText) => { + await this.vault.process(file, (currentText) => { return this.textSyncPipeline.applySyncLogic(currentText, file.path); }); }); } - private editEditor(editor: Editor, oldText: string, newText: string) { + private async editEditor(editor: Editor, info: MarkdownView, oldText: string, newText: string) { const cursor = editor.getCursor(); const newLines = newText.split("\n"); @@ -68,6 +68,7 @@ export default class SyncController { to: { line: lastDifferentLineIndex, ch: 0 } }); } + await info.save(); } private findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { From 6e404140830ef38eefc9f9120d011b0a1f2d906c Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 5 Jul 2025 16:28:28 +0300 Subject: [PATCH 20/28] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20?= =?UTF-8?q?=D1=81=20=D1=87=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=D0=BE=D0=BC?= =?UTF-8?q?,=20=D1=80=D0=B0=D0=B4=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=20Tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/model/ContextFactory.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts index 888091b..8221782 100644 --- a/src/core/model/ContextFactory.ts +++ b/src/core/model/ContextFactory.ts @@ -91,10 +91,8 @@ export class ContextFactory { // если новая и старая строка чекбокс if (oldLine instanceof CheckboxLine && actualLine instanceof CheckboxLine) { - // если текст остался старым // если изменилось состояние чекбокса if ( - oldLine.getText() === actualLine.getText() && oldLine.getState() !== actualLine.getState() ) { actualLine.setChange(true); From 93bb79bb3e6eebede706a9e4f1c355093c000025 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 6 Jul 2025 14:59:15 +0300 Subject: [PATCH 21/28] =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20Tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/GlobalVars.ts | 3 ++ src/core/Regexp.ts | 3 ++ src/core/model/ContextFactory.ts | 34 ++++++--------------- src/core/model/line/AbstractLine.ts | 8 ++--- src/core/model/line/CheckboxLine.ts | 46 +++++++++++++++++++++++++++-- src/core/model/line/ListLine.ts | 18 +++++++++-- src/core/model/line/TextLine.ts | 14 ++++++++- src/main.ts | 6 ++-- 8 files changed, 95 insertions(+), 37 deletions(-) create mode 100644 src/GlobalVars.ts create mode 100644 src/core/Regexp.ts diff --git a/src/GlobalVars.ts b/src/GlobalVars.ts new file mode 100644 index 0000000..21e8403 --- /dev/null +++ b/src/GlobalVars.ts @@ -0,0 +1,3 @@ +export class GlobalVars{ + static APP:any; +} \ No newline at end of file diff --git a/src/core/Regexp.ts b/src/core/Regexp.ts new file mode 100644 index 0000000..be9019e --- /dev/null +++ b/src/core/Regexp.ts @@ -0,0 +1,3 @@ +export const TEXT_REGEXP = /^(\s*)(.*)$/; +export const LIST_ITEM_REGEXP = /^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/; +export const CHECKBOX_REGEXP = /^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/; \ No newline at end of file diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts index 8221782..5d843c6 100644 --- a/src/core/model/ContextFactory.ts +++ b/src/core/model/ContextFactory.ts @@ -9,9 +9,6 @@ import { TextLine } from "./line/TextLine"; export class ContextFactory { - static readonly CHECKBOX_REGEXP = /^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/; - static readonly LIST_ITEM_REGEXP = /^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/; - static readonly TEXT_REGEXP = /^(\s*)(.*)$/; private constructor() { } @@ -110,31 +107,18 @@ export class ContextFactory { } // создаёт объект из одного из классов, кто реализует Line в зависимости от строки. - static createLineFromTextLine(textLine: string, settings: Readonly): Line { - const checkboxMatch = textLine.match(this.CHECKBOX_REGEXP); - if (checkboxMatch) { - const indentString = checkboxMatch[1]; - const marker = checkboxMatch[2]; - const checkChar = checkboxMatch[3]; - - const listItemText = checkboxMatch[4].trimStart(); - - return new CheckboxLine(indentString, marker, checkChar, listItemText, settings); + static createLineFromTextLine(stringLine: string, settings: Readonly): Line { + const checkboxLine = CheckboxLine.createFromLine(stringLine, settings); + if (checkboxLine) { + return checkboxLine; } - const listItemMatch = textLine.match(this.LIST_ITEM_REGEXP); - if (listItemMatch) { - const indentString = listItemMatch[1]; - const marker = listItemMatch[2]; - const listItemText = listItemMatch[3].trimStart(); - - return new ListLine(indentString, marker, listItemText, settings.tabSize); + const listLine = ListLine.createFromLine(stringLine, settings); + if (listLine) { + return listLine; } - const textLineMatch = textLine.match(this.TEXT_REGEXP)!; - const indentString = textLineMatch[1]; - const itemText = textLineMatch[2]; - return new TextLine(indentString, itemText, settings.tabSize); - + const textLine = TextLine.createFromLine(stringLine, settings)!; + return textLine; } private static findSingleDiffLineIndex(lines1: string[], lines2: string[]): number | undefined { diff --git a/src/core/model/line/AbstractLine.ts b/src/core/model/line/AbstractLine.ts index 19a9bf3..3ad2150 100644 --- a/src/core/model/line/AbstractLine.ts +++ b/src/core/model/line/AbstractLine.ts @@ -1,4 +1,4 @@ -import { CheckboxState } from "src/types"; +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; import { Line } from "../Line"; export abstract class AbstractLine implements Line { @@ -10,12 +10,12 @@ export abstract class AbstractLine implements Line { // текст после чекбокса protected listText: string; - constructor(indentString: string, lineText: string, tabSize: number) { + constructor(indentString: string, lineText: string, settings: Readonly) { this.indentString = indentString; this.listText = lineText; - this.tabSize = tabSize; + this.tabSize = settings.tabSize; - this.indent = this.getIndentFromString(indentString, tabSize); + this.indent = this.getIndentFromString(indentString, this.tabSize); } private getIndentFromString(indentString: string, tabSize: number): number { diff --git a/src/core/model/line/CheckboxLine.ts b/src/core/model/line/CheckboxLine.ts index 00b9e9e..e54fc5e 100644 --- a/src/core/model/line/CheckboxLine.ts +++ b/src/core/model/line/CheckboxLine.ts @@ -1,5 +1,7 @@ import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; import { AbstractLine } from "./AbstractLine"; +import { GlobalVars } from "src/GlobalVars"; +import { CHECKBOX_REGEXP } from "src/core/Regexp"; export class CheckboxLine extends AbstractLine { @@ -19,8 +21,8 @@ export class CheckboxLine extends AbstractLine { protected settings: Readonly; - constructor(indentString:string, marker: string, checkChar: string, listItemText: string, settings: Readonly) { - super(indentString, listItemText, settings.tabSize); + constructor(indentString: string, marker: string, checkChar: string, listItemText: string, settings: Readonly) { + super(indentString, listItemText, settings); this.marker = marker; this.checkChar = checkChar; this.settings = settings; @@ -29,6 +31,18 @@ export class CheckboxLine extends AbstractLine { this.checkboxState = this.getCheckboxState(checkChar); } + static createFromLine(textLine: string, settings: Readonly): CheckboxLine | null { + const checkboxMatch = textLine.match(CHECKBOX_REGEXP); + if (checkboxMatch) { + const indentString = checkboxMatch[1]; + const marker = checkboxMatch[2]; + const checkChar = checkboxMatch[3]; + const listItemText = checkboxMatch[4].trimStart(); + return new CheckboxLine(indentString, marker, checkChar, listItemText, settings); + } + return null; + } + isChange(): boolean { return this.hasChange; } @@ -38,7 +52,35 @@ export class CheckboxLine extends AbstractLine { } + private static getNewCheckboxLineFromTasksApi(api: any, checkboxLine: CheckboxLine, targetState: CheckboxState): CheckboxLine|null { + let line: CheckboxLine | null = checkboxLine; + const startChar = checkboxLine.checkChar; + while (true) { + const textLine = line.toResultText(); + const newTextLine = api.executeToggleTaskDoneCommand(textLine, null); + const newBox = CheckboxLine.createFromLine(newTextLine, line.settings); + if (!newBox) { + return null; + } + if (newBox.checkboxState === targetState){ + return newBox; + } + if (newBox.checkChar === startChar){ + return null; + } + line = newBox; + } + } + setState(state: CheckboxState): void { + const taskPlugin = GlobalVars.APP?.plugins.plugins['obsidian-tasks-plugin']; + if (taskPlugin) { + const api = taskPlugin.apiV1; + const res = CheckboxLine.getNewCheckboxLineFromTasksApi(api, this, state); + if (res !== null){ + this.listText = res.listText; + } + } // обновить checkboxState this.checkboxState = state; // обновить checkChar diff --git a/src/core/model/line/ListLine.ts b/src/core/model/line/ListLine.ts index 87b5c61..e1c2474 100644 --- a/src/core/model/line/ListLine.ts +++ b/src/core/model/line/ListLine.ts @@ -1,16 +1,28 @@ -import { CheckboxState } from "src/types"; +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; import { AbstractLine } from "./AbstractLine"; +import { LIST_ITEM_REGEXP } from "src/core/Regexp"; export class ListLine extends AbstractLine { // символы перед текстом private marker: string; - constructor(indentString: string, marker: string, listText: string, tabSize: number) { - super(indentString, listText, tabSize); + constructor(indentString: string, marker: string, listText: string, settings: Readonly) { + super(indentString, listText, settings); this.marker = marker; } + static createFromLine(textLine: string, settings: Readonly): ListLine | null { + const listItemMatch = textLine.match(LIST_ITEM_REGEXP); + if (listItemMatch) { + const indentString = listItemMatch[1]; + const marker = listItemMatch[2]; + const listItemText = listItemMatch[3].trimStart(); + return new ListLine(indentString, marker, listItemText, settings); + } + return null; + } + getMarker(): string { return this.marker; } diff --git a/src/core/model/line/TextLine.ts b/src/core/model/line/TextLine.ts index e56ea53..2fe01cc 100644 --- a/src/core/model/line/TextLine.ts +++ b/src/core/model/line/TextLine.ts @@ -1,7 +1,19 @@ -import { CheckboxState } from "src/types"; +import { CheckboxState, CheckboxSyncPluginSettings } from "src/types"; import { AbstractLine } from "./AbstractLine"; +import { TEXT_REGEXP } from "src/core/Regexp"; export class TextLine extends AbstractLine { + + + static createFromLine(textLine: string, settings: Readonly): TextLine | null { + const textLineMatch = textLine.match(TEXT_REGEXP); + if (textLineMatch) { + const indentString = textLineMatch[1]; + const itemText = textLineMatch[2]; + return new TextLine(indentString, itemText, settings); + } + return null; + } toResultText(): string { // const spaces = ' '.repeat(this.indent); const resultText = this.indentString + this.listText; diff --git a/src/main.ts b/src/main.ts index c240324..ce2e31b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { FileFilter } from "./FileFilter"; import TextSyncPipeline from "./TextSyncPipeline"; import { ICheckboxUtils } from "./core/interface/ICheckboxUtils"; import { CheckboxUtils2 } from "./core/CheckboxUtils2"; +import { GlobalVars } from "./GlobalVars"; const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG'; @@ -32,12 +33,13 @@ export default class CheckboxSyncPlugin extends Plugin { private textSyncPipeline: TextSyncPipeline; async onload() { + GlobalVars.APP = this.app; await this.loadSettings(); this.fileFilter = new FileFilter(this.settings); this.fileStateHolder = new FileStateHolder(this.app.vault); this.checkboxUtils = new CheckboxUtils2(this.settings); - this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils,this.fileStateHolder, this.fileFilter); + this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils, this.fileStateHolder, this.fileFilter); this.syncController = new SyncController(this.app.vault, this.textSyncPipeline); this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder); @@ -62,7 +64,7 @@ export default class CheckboxSyncPlugin extends Plugin { this.fileFilter.updateSettings(this.settings); await this.saveData(this.settings); - + if (this.settings.enableAutomaticFileSync) { //надо пересинхронизировать все файлы в кеше From c64f943ca8a1213aa9da5ce018d785c079ac8fb1 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 13 Jul 2025 21:03:02 +0300 Subject: [PATCH 22/28] fix bug propagete modify to grandparent --- __tests__/core/CheckboxUtils2.test.ts | 49 +++++++++++++++++-- src/core/CheckboxUtils2.ts | 11 +++++ src/core/model/ContextFactory.ts | 14 ++++++ src/core/model/TreeNode.ts | 6 ++- .../PropagateStateToChildrenProcess.ts | 2 + 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/__tests__/core/CheckboxUtils2.test.ts b/__tests__/core/CheckboxUtils2.test.ts index e85bffd..f3d72bf 100644 --- a/__tests__/core/CheckboxUtils2.test.ts +++ b/__tests__/core/CheckboxUtils2.test.ts @@ -146,7 +146,7 @@ describe('CheckboxUtils2', () => { expect(utils.syncText(textBefore, textBefore)).toBe(textBefore); }); - it('checkbox, list', ()=> { + it('checkbox, list', () => { const utils = new CheckboxUtils2(createSettings({ enableAutomaticChildState: true, enableAutomaticParentState: true, @@ -171,7 +171,7 @@ describe('CheckboxUtils2', () => { expect(utils.syncText(actualText, textBefore)).toBe(expectedText); }); - it('checkbox, plain text', ()=> { + it('checkbox, plain text', () => { const utils = new CheckboxUtils2(createSettings({ enableAutomaticChildState: true, enableAutomaticParentState: true, @@ -305,7 +305,50 @@ describe('CheckboxUtils2', () => { ' Child3', ].join('\n'); expect(utils.syncText(actualText, textBefore)).toBe(expected); - }); + }); + }); + + describe('bags', () => { + it('bag 1', () => { + const utils = new CheckboxUtils2(createSettings({ + enableAutomaticChildState: true, + enableAutomaticParentState: true, + })); + const oldText = [ + '- [ ] 3д детали', + ' - [ ] грузики для веревки ^be5c11', + ' - [ ] привод жалюзи ^202ccf', + ' - [ ] 3д детали', + ' - [ ] корпус', + ' - [ ] бобина для веревки', + ' - [ ] основной редуктор', + ' - [ ] червячный редуктор', + ' - [ ] подшипник', + ].join('\n'); + const actualText = [ + '- [ ] 3д детали', + ' - [ ] грузики для веревки ^be5c11', + ' - [ ] привод жалюзи ^202ccf', + ' - [x] 3д детали', + ' - [ ] корпус', + ' - [ ] бобина для веревки', + ' - [ ] основной редуктор', + ' - [ ] червячный редуктор', + ' - [ ] подшипник', + ].join('\n'); + const expected = [ + '- [ ] 3д детали', + ' - [ ] грузики для веревки ^be5c11', + ' - [ ] привод жалюзи ^202ccf', + ' - [x] 3д детали', + ' - [x] корпус', + ' - [x] бобина для веревки', + ' - [ ] основной редуктор', + ' - [ ] червячный редуктор', + ' - [ ] подшипник', + ].join('\n'); + expect(utils.syncText(actualText, oldText)).toBe(expected); + }); }); }); diff --git a/src/core/CheckboxUtils2.ts b/src/core/CheckboxUtils2.ts index d70f9b8..319b9dc 100644 --- a/src/core/CheckboxUtils2.ts +++ b/src/core/CheckboxUtils2.ts @@ -19,11 +19,22 @@ export class CheckboxUtils2 implements ICheckboxUtils { if (text === textBefore) { return text; } + console.log(`text before:`); + console.log(text); + console.log("___"); + const context = ContextFactory.createContext(text, textBefore, this.settings); this.propagateStateToChildrenProcess.process(context); + + this.propagateStateToParentProcess.process(context); + console.log(`text after:`); + const res = context.getResultText() + console.log(res); + console.log("___"); + return context.getResultText(); } } diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts index 5d843c6..911d7d4 100644 --- a/src/core/model/ContextFactory.ts +++ b/src/core/model/ContextFactory.ts @@ -35,8 +35,14 @@ export class ContextFactory { private static createFlatNodesFromLines(lines: Line[]): TreeNode[] { const nodes: TreeNode[] = []; const stack: TreeNode[] = []; + + let changeNode: null | TreeNode = null; + for (const line of lines) { const node = new TreeNode(line); + if (line instanceof CheckboxLine && line.isChange()) { + changeNode = node; + } // найти родителя let parent = undefined; @@ -56,6 +62,12 @@ export class ContextFactory { nodes.push(node); stack.push(node); } + + let parentChangeNode = changeNode?.getParent(); + while (parentChangeNode) { + parentChangeNode.setModify(true); + parentChangeNode = parentChangeNode?.getParent(); + } return nodes; } @@ -93,6 +105,8 @@ export class ContextFactory { oldLine.getState() !== actualLine.getState() ) { actualLine.setChange(true); + const text = actualLine.toResultText(); + console.log(`ContextFactory:markChangeIfNeeded: ${text} is change`); } } diff --git a/src/core/model/TreeNode.ts b/src/core/model/TreeNode.ts index 4e7b56d..5f8d593 100644 --- a/src/core/model/TreeNode.ts +++ b/src/core/model/TreeNode.ts @@ -24,6 +24,10 @@ export class TreeNode { return this.modify; } + setModify(isModify:boolean){ + this.modify = isModify; + } + private setParent(parent: TreeNode) { this.parent = parent; } @@ -35,8 +39,6 @@ export class TreeNode { addChildren(children: TreeNode) { this.childrens.push(children); children.setParent(this); - - this.modify = this.modify || children.isModify(); } hasChildren(): boolean { diff --git a/src/core/process/PropagateStateToChildrenProcess.ts b/src/core/process/PropagateStateToChildrenProcess.ts index d8416c3..2259619 100644 --- a/src/core/process/PropagateStateToChildrenProcess.ts +++ b/src/core/process/PropagateStateToChildrenProcess.ts @@ -11,12 +11,14 @@ export class PropagateStateToChildrenProcess implements CheckboxProcess { return; } if (!context.textBeforeChangeIsPresent()) { + console.log("PropagateStateToChildrenProcess: textBeforeChange is not present"); return; } // получить представление текста const view = context.getView(); if (!view.isModify()) { + console.log("PropagateStateToChildrenProcess: not modify"); return; } From cf6b3d4b8079922acc002474031fe50185d26ad1 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 13 Jul 2025 21:03:42 +0300 Subject: [PATCH 23/28] ref --- __tests__/core/CheckboxUtils2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/core/CheckboxUtils2.test.ts b/__tests__/core/CheckboxUtils2.test.ts index f3d72bf..0d7dbc2 100644 --- a/__tests__/core/CheckboxUtils2.test.ts +++ b/__tests__/core/CheckboxUtils2.test.ts @@ -309,7 +309,7 @@ describe('CheckboxUtils2', () => { }); describe('bags', () => { - it('bag 1', () => { + it('bag 1 propagete modify to grandparent', () => { const utils = new CheckboxUtils2(createSettings({ enableAutomaticChildState: true, enableAutomaticParentState: true, From 02760704456f2c4a547983a698f933d7aa336452 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Mon, 14 Jul 2025 09:08:59 +0300 Subject: [PATCH 24/28] =?UTF-8?q?ref:=20=D1=83=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/model/ContextFactory.ts | 25 +++++++---------- src/core/model/View.ts | 19 ++++++------- .../PropagateStateToChildrenProcess.ts | 28 ++----------------- 3 files changed, 22 insertions(+), 50 deletions(-) diff --git a/src/core/model/ContextFactory.ts b/src/core/model/ContextFactory.ts index 911d7d4..68910b1 100644 --- a/src/core/model/ContextFactory.ts +++ b/src/core/model/ContextFactory.ts @@ -19,20 +19,21 @@ export class ContextFactory { static createView(context: Context) { const lines = context.getLines(); - const treeNodes = this.createRootTreeNodesFromLines(lines); - return new View(treeNodes); + const [rootNodes, changeNode] = this.createRootTreeNodesFromLines(lines); + return new View(rootNodes, changeNode); } // создаёт TreeNode из Line - // возвращает только корневые узлы - private static createRootTreeNodesFromLines(lines: Line[]): TreeNode[] { - const flatTreeNodes = this.createFlatNodesFromLines(lines); - return flatTreeNodes.filter(node => !node.getParent()); + // возвращает только корневые узлы и изменённую строку + private static createRootTreeNodesFromLines(lines: Line[]): [roots: TreeNode[], changeNode: TreeNode | null] { + const [flatTreeNodes, changeNode] = this.createFlatNodesFromLines(lines); + const rootNodes = flatTreeNodes.filter(node => !node.getParent()); + return [rootNodes, changeNode]; } - // создаёт TreeNode из Line + // создаёт TreeNode из Line и возвращает изменённую строку если есть. // осторожно, возвращает ВСЕ ноды в порядке линий - private static createFlatNodesFromLines(lines: Line[]): TreeNode[] { + private static createFlatNodesFromLines(lines: Line[]): [treeNodes: TreeNode[], changeNode: TreeNode | null] { const nodes: TreeNode[] = []; const stack: TreeNode[] = []; @@ -62,13 +63,7 @@ export class ContextFactory { nodes.push(node); stack.push(node); } - - let parentChangeNode = changeNode?.getParent(); - while (parentChangeNode) { - parentChangeNode.setModify(true); - parentChangeNode = parentChangeNode?.getParent(); - } - return nodes; + return [nodes, changeNode]; } static createLines(context: Context): Line[] { diff --git a/src/core/model/View.ts b/src/core/model/View.ts index 501e75f..e0444ca 100644 --- a/src/core/model/View.ts +++ b/src/core/model/View.ts @@ -3,20 +3,19 @@ import { TreeNode } from "./TreeNode"; export class View { private treeNodes: TreeNode[]; // метка того, что Единичное изменение произошло в этой View - private modify: boolean; + private changeNode: TreeNode | null; - constructor(treeNodes: TreeNode[]) { + constructor(treeNodes: TreeNode[], changeNode: TreeNode | null) { this.treeNodes = treeNodes; - // проверить ноды на modify - let isModify = false; - for (const treeNode of treeNodes) { - isModify = isModify || treeNode.isModify(); - } - this.modify = isModify; + this.changeNode = changeNode; } - isModify() { - return this.modify; + isModify(): boolean { + return this.changeNode ? true : false; + } + + getChangeNode(): TreeNode | null { + return this.changeNode; } getTreeNodes(): TreeNode[] { diff --git a/src/core/process/PropagateStateToChildrenProcess.ts b/src/core/process/PropagateStateToChildrenProcess.ts index 2259619..4aed79a 100644 --- a/src/core/process/PropagateStateToChildrenProcess.ts +++ b/src/core/process/PropagateStateToChildrenProcess.ts @@ -21,33 +21,11 @@ export class PropagateStateToChildrenProcess implements CheckboxProcess { console.log("PropagateStateToChildrenProcess: not modify"); return; } + const changeNode = view.getChangeNode()!; + this.propagateStateToChildrenFromNode(changeNode); - const nodes = view.getTreeNodes(); - for (const node of nodes) { - this.propagateStateToChildrenFromChangeLineNode(node); - } } - // находит изменённую Line, если она существует и вызывает от неё propagateStateToChildren - propagateStateToChildrenFromChangeLineNode(node: TreeNode) { - if (!node.isModify()) { - return; - } - const line = node.getLine(); - // если line изменён - if (line instanceof CheckboxLine && line.isChange()) { - // значит мы нашли нужную ноду - this.propagateStateToChildrenFromNode(node); - } else { - // иначе ищем вглубь среди детей - const childrens = node.getChildrenNodes(); - for (const children of childrens) { - this.propagateStateToChildrenFromChangeLineNode(children); - } - } - } - - propagateStateToChildrenFromNode(modifiedNode: TreeNode) { const line = modifiedNode.getLine(); if (!(line instanceof CheckboxLine)) { @@ -56,7 +34,7 @@ export class PropagateStateToChildrenProcess implements CheckboxProcess { } const state = line.getState(); if (state === CheckboxState.Ignore || state === CheckboxState.NoCheckbox) { - console.warn(`propagateStateToChildren ${state.toString()}`); + console.warn(`propagateStateToChildren from uncorrect state: ${state.toString()}`); return; } this.propagateStateToChildren(modifiedNode, state); From 235f1cb9427733d0950995463102df9f4726230d Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 8 Aug 2025 12:59:00 +0300 Subject: [PATCH 25/28] update doc --- docs/Integration with Tasks plugin.md | 5 +++++ docs/changelog.md | 4 +++- docs/index.md | 2 ++ docs/roadmap.md | 3 --- docs/settings.md | 6 ++++++ 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 docs/Integration with Tasks plugin.md diff --git a/docs/Integration with Tasks plugin.md b/docs/Integration with Tasks plugin.md new file mode 100644 index 0000000..3e2603d --- /dev/null +++ b/docs/Integration with Tasks plugin.md @@ -0,0 +1,5 @@ +There is partial integration with the Tasks plugin. +The goal is to add or remove a timestamp on checkboxes modified by the current plugin. In theory, if other tags appear, it should work as well. + +Unfortunately, their current Tasks API does not provide a method to set a specific symbol or checkbox state, but it does offer a method to emulate a “click” on the checkbox. +Therefore, under certain specific transition settings in the Tasks plugin, this will not work — for example, when it is impossible to transition from the current state to the completed state. \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index 16fd3ff..55cccef 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,7 +7,9 @@ nav_order: 3 ## [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. +- Added Feature **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active. +- Added Feature: **Support Non-Checkbox Nodes:** Added support for list-item and plain text inside the checkbox tree +- Added [Integration with Tasks plugin]("Integration with Tasks plugin.md") ## [1.1.0] - 2025-05-05 ### Added diff --git a/docs/index.md b/docs/index.md index ce8db06..c4bc6f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ This provides flexibility in how you manage your task lists, allowing you to cus * [Roadmap](roadmap.md) * [Installation Guide](installation.md) * [Contributing Guide](contributing.md) +* [Integration with Tasks plugin]("Integration with Tasks plugin.md") ### Acknowledgments * [Obsidian](https://obsidian.md/) — a platform for creating and organizing notes. @@ -48,6 +49,7 @@ This provides flexibility in how you manage your task lists, allowing you to cus * [Планы (Roadmap)](roadmap.md) * [Руководство по установке](installation.md) * [Руководство для контрибьюторов](contributing.md) +* [Интеграция с Tasks plugin]("Integration with Tasks plugin.md") ### Благодарности * [Obsidian](https://obsidian.md/) — платформа для создания и организации заметок. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0ee9534..afdf003 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,14 +14,11 @@ This document outlines the development status and future plans for the Checkbox * [Feature] **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active. * [Feature] **Logging Toggle:** Add a setting to enable/disable detailed logging for debugging purposes. -* [Feature] **Support Non-Checkbox Nodes:** Allow regular list items (e.g., `- Parent Item` without `[ ]`) to function as structural nodes within the hierarchy for synchronization logic. * [Feature] **Configurable Checkbox Character (Auto-Update):** Add a setting to define which character is used to mark checkboxes when automatically updated by the plugin. -* [Tech] **Overhaul List Parsing Logic:** Replace the current line-based parsing with a more robust tree-based approach to improve accuracy and maintainability. ## Future Ideas / Backlog * [Feature] **List Reordering Functionality:** Explore and potentially implement list item reordering features, possibly inspired by approaches like [obsidian-checkboxReorder](https://github.com/Erl-koenig/obsidian-checkboxReorder) (e.g., moving completed items). -* [Tech] **Improve Change Detection:** Investigate using a library like `jsdiff` to more accurately detect and react to specific changes within files, potentially improving performance and reliability. * [Tech] **Refactor Settings Handling:** Change how settings are used internally. Instead of passing the settings object by reference, create a new instance of `CheckboxUtils` (or relevant class) when settings are modified to ensure proper state isolation and updates. --- diff --git a/docs/settings.md b/docs/settings.md index 212cf8d..afa5f34 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -118,6 +118,12 @@ These settings control when and how the synchronization logic runs. - **Включено:** Автоматически синхронизирует состояния чекбоксов при загрузке/открытии файлов и сразу после применения настроек плагина. Это обеспечивает консистентность, но может влиять на производительность в очень больших хранилищах или файлах. *(Требует перезапуска Obsidian или перезагрузки настроек для полного вступления в силу)*. - **Отключено (По умолчанию):** Синхронизация происходит только тогда, когда вы *вручную* изменяете состояние чекбокса в Obsidian. Это поведение по умолчанию для минимизации потенциального влияния на производительность. +### File Scope & Filtering +- **Правила игнорирования файлов (стиль .gitignore)** + - Этот параметр позволяет определить, какие файлы и папки должен обрабатывать Checkbox Sync. Если список шаблонов пуст, плагин будет обрабатывать все файлы Markdown в вашем хранилище. + - Фильтрация использует **синтаксис .gitignore** и работает на основе библиотеки [`ignore`](https://github.com/kaelzhang/node-ignore). + + ### Разработка / Отладка - **Включить Отладочное Логирование (Enable console log)** From b685655e516787317dd05352a5d6e2c35c677389 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 8 Aug 2025 13:06:19 +0300 Subject: [PATCH 26/28] fix infinite loop with tasks plugin --- src/core/model/line/CheckboxLine.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/model/line/CheckboxLine.ts b/src/core/model/line/CheckboxLine.ts index e54fc5e..d7f2a6f 100644 --- a/src/core/model/line/CheckboxLine.ts +++ b/src/core/model/line/CheckboxLine.ts @@ -52,9 +52,11 @@ export class CheckboxLine extends AbstractLine { } - private static getNewCheckboxLineFromTasksApi(api: any, checkboxLine: CheckboxLine, targetState: CheckboxState): CheckboxLine|null { + private static getNewCheckboxLineFromTasksApi(api: any, checkboxLine: CheckboxLine, targetState: CheckboxState): CheckboxLine | null { let line: CheckboxLine | null = checkboxLine; - const startChar = checkboxLine.checkChar; + const seenChars = new Set(); + seenChars.add(checkboxLine.checkChar); + while (true) { const textLine = line.toResultText(); const newTextLine = api.executeToggleTaskDoneCommand(textLine, null); @@ -62,12 +64,14 @@ export class CheckboxLine extends AbstractLine { if (!newBox) { return null; } - if (newBox.checkboxState === targetState){ + if (newBox.checkboxState === targetState) { return newBox; } - if (newBox.checkChar === startChar){ + if (seenChars.has(newBox.checkChar)) { return null; } + + seenChars.add(newBox.checkChar); line = newBox; } } @@ -77,7 +81,7 @@ export class CheckboxLine extends AbstractLine { if (taskPlugin) { const api = taskPlugin.apiV1; const res = CheckboxLine.getNewCheckboxLineFromTasksApi(api, this, state); - if (res !== null){ + if (res !== null) { this.listText = res.listText; } } From 97550348706a85c60e0ea3712841a092816e8578 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 8 Aug 2025 13:09:09 +0300 Subject: [PATCH 27/28] update doc --- docs/roadmap.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index afdf003..17574d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,8 +12,6 @@ This document outlines the development status and future plans for the Checkbox *This section lists the main features and technical work currently planned or in progress for the next release.* -* [Feature] **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active. -* [Feature] **Logging Toggle:** Add a setting to enable/disable detailed logging for debugging purposes. * [Feature] **Configurable Checkbox Character (Auto-Update):** Add a setting to define which character is used to mark checkboxes when automatically updated by the plugin. ## Future Ideas / Backlog From e701cd68287aa2889836849d4db03de89622b410 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 8 Aug 2025 13:15:01 +0300 Subject: [PATCH 28/28] Finalize for v1.2.0 --- docs/changelog.md | 4 +++- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 55cccef..fb546a0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,12 +4,14 @@ nav_order: 3 --- # Changelog -## [1.2.0] - 2025-XX-XX +## [1.2.0] - 2025-08-08 ### 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. - Added Feature: **Support Non-Checkbox Nodes:** Added support for list-item and plain text inside the checkbox tree - Added [Integration with Tasks plugin]("Integration with Tasks plugin.md") +### Changed +- Transition to tree structure ## [1.1.0] - 2025-05-05 ### Added diff --git a/manifest.json b/manifest.json index eea1db8..b9f1485 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "checkbox-sync", "name": "Checkbox Sync", - "version": "1.1.0", + "version": "1.2.0", "minAppVersion": "0.12.0", "description": "Automatically checks the parent checkbox if all child checkboxes are completed, and unchecks it otherwise.", "author": "Grol", diff --git a/package-lock.json b/package-lock.json index 092c3b3..24ce8c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "checkbox-sync", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "checkbox-sync", - "version": "1.0.0", + "version": "1.2.0", "license": "0BSD", "dependencies": { "async-mutex": "^0.5.0", diff --git a/package.json b/package.json index f5f59f5..51e4c75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "checkbox-sync", - "version": "1.0.0", + "version": "1.2.0", "description": "Automatically checks the parent checkbox if all child checkboxes are completed, and unchecks it otherwise", "main": "main.js", "scripts": {