From 5074bf646467de7a224f972b23aa547613680053 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 26 Apr 2025 21:35:26 +0300 Subject: [PATCH 01/14] refactor(settings): Add base structures for settings tab architecture --- src/ui/SettingGroup.ts | 56 ++++++++++++++++++++++ src/ui/components/BaseSettingComponent.ts | 27 +++++++++++ src/ui/interfaces/ISettingComponent.ts | 58 +++++++++++++++++++++++ src/ui/validation/SettingsValidator.ts | 40 ++++++++++++++++ src/ui/validation/types.ts | 8 ++++ 5 files changed, 189 insertions(+) create mode 100644 src/ui/SettingGroup.ts create mode 100644 src/ui/components/BaseSettingComponent.ts create mode 100644 src/ui/interfaces/ISettingComponent.ts create mode 100644 src/ui/validation/SettingsValidator.ts create mode 100644 src/ui/validation/types.ts diff --git a/src/ui/SettingGroup.ts b/src/ui/SettingGroup.ts new file mode 100644 index 0000000..4677159 --- /dev/null +++ b/src/ui/SettingGroup.ts @@ -0,0 +1,56 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { ISettingComponent } from "./interfaces/ISettingComponent"; + +/** + * Представляет группу связанных настроек в UI. + */ +export class SettingGroup { + title: string; + description?: string; + components: ISettingComponent[]; + + /** + * Создает экземпляр группы настроек. + * @param title - Заголовок группы (отображается как h3). + * @param components - Массив компонентов настроек, входящих в эту группу. + * @param description - Опциональное описание группы (отображается как p). + */ + constructor(title: string, components: ISettingComponent[], description?: string) { + this.title = title; + this.components = components; + this.description = description; + } + + /** + * Рендерит заголовок группы и все ее компоненты. + * @param container - HTML-элемент, куда рендерить группу. + * @param currentSettings - Объект текущих настроек плагина. + */ + render(container: HTMLElement, currentSettings: CheckboxSyncPluginSettings): void { + container.createEl('h3', { text: this.title }); + if (this.description) { + container.createEl('p', { text: this.description, cls: 'setting-item-description' }); + } + + // Рендерим каждый компонент, передавая ему текущее значение + for (const component of this.components) { + try { + const key = component.getSettingKey(); + // Проверяем, существует ли ключ в настройках, чтобы избежать ошибок + if (key in currentSettings) { + component.render(container, currentSettings[key]); + } else { + console.warn(`Setting key "${key}" not found in current settings for component in group "${this.title}". Rendering with default or undefined value might occur.`); + // Можно передать undefined или значение по умолчанию, если getSettingKey гарантированно работает + // component.render(container, component.getDefaultValue()); + component.render(container, undefined); // Или рендерить с undefined + } + + } catch (error) { + console.error(`Error rendering component for key "${component.getSettingKey()}" in group "${this.title}":`, error); + // Можно добавить placeholder или сообщение об ошибке в UI + container.createDiv({ text: `Error rendering setting: ${component.getSettingKey()}`, cls: 'checkbox-sync-settings-error' }); + } + } + } +} \ No newline at end of file diff --git a/src/ui/components/BaseSettingComponent.ts b/src/ui/components/BaseSettingComponent.ts new file mode 100644 index 0000000..fb319ad --- /dev/null +++ b/src/ui/components/BaseSettingComponent.ts @@ -0,0 +1,27 @@ +import { Setting } from 'obsidian'; +import { CheckboxSyncPluginSettings } from '../../types'; +import { ISettingComponent } from '../interfaces/ISettingComponent'; +import { ValidationError } from '../validation/types'; +// Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам + +export abstract class BaseSettingComponent implements ISettingComponent { + protected setting: Setting; // Инициализируется в render конкретного компонента + protected onChangeCallback: () => void = () => {}; + + // Конструктор теперь пустой или с минимальными общими зависимостями + constructor() {} + + // --- Методы для реализации наследниками --- + abstract getSettingKey(): keyof CheckboxSyncPluginSettings; + abstract getDefaultValue(): any; + // render теперь должен сам создавать Setting и сохранять ссылку в this.setting + abstract render(container: HTMLElement, currentValue: any): void; + abstract getValueFromUi(): any; + abstract setValueInUi(value: any): void; + abstract validate(value: any): ValidationError | null; + + // --- Общая реализация --- + public setChangeListener(listener: () => void): void { + this.onChangeCallback = listener; + } +} \ No newline at end of file diff --git a/src/ui/interfaces/ISettingComponent.ts b/src/ui/interfaces/ISettingComponent.ts new file mode 100644 index 0000000..15ab5a4 --- /dev/null +++ b/src/ui/interfaces/ISettingComponent.ts @@ -0,0 +1,58 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { ValidationError } from "../validation/types"; + + +/** + * Интерфейс для компонента, отвечающего за одну настройку в UI. + */ +export interface ISettingComponent { + /** + * Получает ключ настройки, за которую отвечает этот компонент. + * @returns Ключ из CheckboxSyncPluginSettings. + */ + getSettingKey(): keyof CheckboxSyncPluginSettings; + + /** + * Получает значение по умолчанию для этой настройки. + * @returns Значение по умолчанию. + */ + getDefaultValue(): any; + + /** + * Рендерит UI-элементы для этой настройки в указанный контейнер. + * @param container - HTML-элемент, куда добавлять настройку. + * @param currentValue - Текущее сохраненное значение настройки. + */ + render(container: HTMLElement, currentValue: any): void; + + /** + * Считывает значение из UI-элементов компонента. + * Может включать парсинг (например, JSON). + * @returns Текущее значение из UI. + * @throws Error если значение в UI некорректно для извлечения (например, невалидный JSON). + */ + getValueFromUi(): any; + + /** + * Устанавливает значение в UI-элементы компонента. + * Может включать форматирование (например, JSON.stringify). + * @param value - Значение для установки в UI. + */ + setValueInUi(value: any): void; + + /** + * Выполняет *индивидуальную* валидацию значения. + * **Важно:** Этот метод должен быть тестируемым и не зависеть от Obsidian API. + * Он принимает чистое значение и возвращает ошибку или null. + * @param value - Чистое значение для валидации (обычно результат getValueFromUi). + * @returns Объект ValidationError, если значение невалидно, иначе null. + */ + validate(value: any): ValidationError | null; + + /** + * Устанавливает колбэк, который будет вызван при изменении значения в UI. + * Используется для управления флагом isDirty. + * @param listener - Функция обратного вызова. + */ + setChangeListener(listener: () => void): void; +} \ No newline at end of file diff --git a/src/ui/validation/SettingsValidator.ts b/src/ui/validation/SettingsValidator.ts new file mode 100644 index 0000000..e7e9b54 --- /dev/null +++ b/src/ui/validation/SettingsValidator.ts @@ -0,0 +1,40 @@ +// src/settings/validation/SettingsValidator.ts + +import { CheckboxSyncPluginSettings } from '../../types'; // Путь к типам +import { ValidationError } from './types'; // Путь к типам ошибок + +/** + * Отвечает за валидацию настроек, требующую проверки нескольких полей (перекрестная валидация). + * **Важно:** Логика внутри методов не должна зависеть от Obsidian API для тестируемости. + */ +export class SettingsValidator { + + /** + * Выполняет перекрестную валидацию предоставленных данных настроек. + * @param settingsData - Объект с текущими значениями настроек (или их частью). + * @returns Массив объектов ValidationError, если найдены ошибки, иначе пустой массив. + */ + public validate(settingsData: Partial): ValidationError[] { + const errors: ValidationError[] = []; + + // Пример: Проверка пересечения списков символов (будет реализована позже) + // if (settingsData.checkedSymbols && settingsData.uncheckedSymbols) { + // const intersection = settingsData.checkedSymbols.filter(symbol => settingsData.uncheckedSymbols!.includes(symbol)); + // if (intersection.length > 0) { + // errors.push({ + // // Можно не указывать поле, т.к. ошибка затрагивает несколько + // message: `Symbols found in both Checked and Unchecked lists: ${JSON.stringify(intersection)}` + // }); + // } + // } + // ... другие перекрестные проверки ... + + // Пока возвращаем пустой массив, логика будет добавлена на Шаге 6 + return errors; + } + + // Сюда можно добавить статические вспомогательные методы для валидации, если нужно + // public static validateSymbolListsIntersection(listA: string[], listB: string[]): string[] { + // return listA.filter(symbol => listB.includes(symbol)); + // } +} \ No newline at end of file diff --git a/src/ui/validation/types.ts b/src/ui/validation/types.ts new file mode 100644 index 0000000..29b74eb --- /dev/null +++ b/src/ui/validation/types.ts @@ -0,0 +1,8 @@ +import { CheckboxSyncPluginSettings } from "src/types"; + +export interface ValidationError { + /** Ключ поля настройки, с которым связана ошибка (опционально). */ + field?: keyof CheckboxSyncPluginSettings; + /** Сообщение об ошибке, понятное пользователю. */ + message: string; +} \ No newline at end of file From 0f4b53f8dd010b246ade2f0187b779e116eae3f9 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 26 Apr 2025 21:38:18 +0300 Subject: [PATCH 02/14] fix license --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6672a35..09e08ff 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "keywords": [], "author": "", - "license": "MIT", + "license": "0BSD", "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^16.11.6", From 840a50ae41bf9e5f9c34decb7d00a38038bd2741 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 26 Apr 2025 23:25:41 +0300 Subject: [PATCH 03/14] refactor(settings): Add EnableParentSync component and testable validator --- .../components/validation/validators.test.ts | 70 +++++++++++++++++++ jest.config.ts | 6 ++ src/ui/components/BaseSettingComponent.ts | 19 ++++- .../EnableParentSyncSettingComponent.ts | 70 +++++++++++++++++++ src/ui/interfaces/ISettingComponent.ts | 2 +- src/ui/validation/validators.ts | 49 +++++++++++++ 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 __tests__/ui/components/validation/validators.test.ts create mode 100644 src/ui/components/EnableParentSyncSettingComponent.ts create mode 100644 src/ui/validation/validators.ts diff --git a/__tests__/ui/components/validation/validators.test.ts b/__tests__/ui/components/validation/validators.test.ts new file mode 100644 index 0000000..d22c1a8 --- /dev/null +++ b/__tests__/ui/components/validation/validators.test.ts @@ -0,0 +1,70 @@ +import { validateValueIsBoolean } from "src/ui/validation/validators"; + + +// Группа тестов для всего файла валидаторов +describe('Validation Utility Functions', () => { + + // Подгруппа тестов для конкретной функции validateValueIsBoolean + describe('validateValueIsBoolean', () => { + + // Тест кейсы для валидных значений (должны возвращать null) + test('should return null for true', () => { + expect(validateValueIsBoolean(true)).toBeNull(); + }); + + test('should return null for false', () => { + expect(validateValueIsBoolean(false)).toBeNull(); + }); + + // Тест кейсы для невалидных значений (должны возвращать объект ошибки) + test('should return error object for null', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean(null)).toEqual(expectedError); + }); + + test('should return error object for undefined', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean(undefined)).toEqual(expectedError); + }); + + test('should return error object for numbers', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean(0)).toEqual(expectedError); + expect(validateValueIsBoolean(1)).toEqual(expectedError); + expect(validateValueIsBoolean(123)).toEqual(expectedError); + expect(validateValueIsBoolean(-10)).toEqual(expectedError); + }); + + test('should return error object for strings', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean('')).toEqual(expectedError); + expect(validateValueIsBoolean('true')).toEqual(expectedError); + expect(validateValueIsBoolean('false')).toEqual(expectedError); + expect(validateValueIsBoolean('any string')).toEqual(expectedError); + }); + + test('should return error object for objects', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean({})).toEqual(expectedError); + expect(validateValueIsBoolean({ a: 1 })).toEqual(expectedError); + }); + + test('should return error object for arrays', () => { + const expectedError = { message: 'Value must be a boolean.' }; + expect(validateValueIsBoolean([])).toEqual(expectedError); + expect(validateValueIsBoolean([true])).toEqual(expectedError); + }); + }); + + // --- Сюда можно будет добавлять describe-блоки для других функций валидации --- + /* + describe('validateJsonStringArray', () => { + // ... тесты для валидации JSON ... + }); + + describe('validateNotEmptyArray', () => { + // ... тесты для проверки на пустой массив ... + }); + */ + +}); \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index 048dde7..38d873b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -36,6 +36,12 @@ const config: Config = { // Limit the number of workers that run tests in parallel maxWorkers: 1, // <--- Задает последовательное выполнение + moduleNameMapper: { + // Этот паттерн говорит: если импорт начинается с 'src/', + // замени 'src/' на '/src/' при поиске файла. + '^src/(.*)$': '/src/$1', + }, + }; export default config; diff --git a/src/ui/components/BaseSettingComponent.ts b/src/ui/components/BaseSettingComponent.ts index fb319ad..ec138d3 100644 --- a/src/ui/components/BaseSettingComponent.ts +++ b/src/ui/components/BaseSettingComponent.ts @@ -1,5 +1,5 @@ import { Setting } from 'obsidian'; -import { CheckboxSyncPluginSettings } from '../../types'; +import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../../types'; import { ISettingComponent } from '../interfaces/ISettingComponent'; import { ValidationError } from '../validation/types'; // Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам @@ -13,13 +13,26 @@ export abstract class BaseSettingComponent implements ISettingComponent { // --- Методы для реализации наследниками --- abstract getSettingKey(): keyof CheckboxSyncPluginSettings; - abstract getDefaultValue(): any; // render теперь должен сам создавать Setting и сохранять ссылку в this.setting abstract render(container: HTMLElement, currentValue: any): void; abstract getValueFromUi(): any; abstract setValueInUi(value: any): void; - abstract validate(value: any): ValidationError | null; + abstract validate(): ValidationError | null; + + public getDefaultValue(): any { + const key = this.getSettingKey(); // Получаем ключ от наследника + if (key in DEFAULT_SETTINGS) { + // Возвращаем значение из импортированного объекта + return DEFAULT_SETTINGS[key]; + } else { + // Логируем предупреждение, если ключ не найден (ошибка конфигурации) + console.warn( + `[${this.constructor.name}] Default value for key "${key}" not found in DEFAULT_SETTINGS. Returning undefined.` + ); + return undefined; // Или null, или выбросить ошибку, но undefined безопаснее + } + } // --- Общая реализация --- public setChangeListener(listener: () => void): void { this.onChangeCallback = listener; diff --git a/src/ui/components/EnableParentSyncSettingComponent.ts b/src/ui/components/EnableParentSyncSettingComponent.ts new file mode 100644 index 0000000..02b8e8a --- /dev/null +++ b/src/ui/components/EnableParentSyncSettingComponent.ts @@ -0,0 +1,70 @@ +import { Setting, ToggleComponent } from 'obsidian'; +import { CheckboxSyncPluginSettings } from '../../types'; +import { BaseSettingComponent } from './BaseSettingComponent'; +import { ValidationError } from '../validation/types'; +import { validateValueIsBoolean } from '../validation/validators'; + +export class EnableParentSyncSettingComponent extends BaseSettingComponent { + private toggleComponent: ToggleComponent; + + constructor() { + super(); + } + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'enableAutomaticParentState'; // Ключ этой настройки + } + + render(container: HTMLElement, currentValue: any): void { + + this.setting = new Setting(container) + .setName('Update parent checkbox state automatically') + .setDesc('If enabled, the state of a parent checkbox will be automatically updated based on the state of its children (all children checked = parent checked). If disabled, only manually changing a parent will affect its children.') + .addToggle(toggle => { + this.toggleComponent = toggle; + // Используем as boolean, т.к. знаем тип для этой настройки + toggle + .setValue(currentValue as boolean) + .onChange(this.onChangeCallback); // Просто передаем колбэк + }); + } + + getValueFromUi(): boolean { + if (this.toggleComponent) { + return this.toggleComponent.getValue(); + } + console.warn(`getValueFromUi called before render for ${this.getSettingKey()}`); + // Используем унаследованный getDefaultValue() для консистентности + return this.getDefaultValue(); + } + + setValueInUi(value: any): void { + if (this.toggleComponent) { + this.toggleComponent.setValue(value as boolean); + } + } + + validate(): ValidationError | null { + let valueFromUi: boolean; + try { + valueFromUi = this.getValueFromUi(); + } catch (error) { + console.error(`Error getting value from UI for ${this.getSettingKey()}:`, error); + return { + field: this.getSettingKey(), + message: `Internal error getting value from UI: ${error instanceof Error ? error.message : String(error)}` + }; + } + + // Вызываем внешнюю чистую функцию валидации + const validationResult = validateValueIsBoolean(valueFromUi); + + if (validationResult) { + return { + field: this.getSettingKey(), + message: validationResult.message + }; + } + return null; + } +} \ No newline at end of file diff --git a/src/ui/interfaces/ISettingComponent.ts b/src/ui/interfaces/ISettingComponent.ts index 15ab5a4..9d26ce5 100644 --- a/src/ui/interfaces/ISettingComponent.ts +++ b/src/ui/interfaces/ISettingComponent.ts @@ -47,7 +47,7 @@ export interface ISettingComponent { * @param value - Чистое значение для валидации (обычно результат getValueFromUi). * @returns Объект ValidationError, если значение невалидно, иначе null. */ - validate(value: any): ValidationError | null; + validate(): ValidationError | null; /** * Устанавливает колбэк, который будет вызван при изменении значения в UI. diff --git a/src/ui/validation/validators.ts b/src/ui/validation/validators.ts new file mode 100644 index 0000000..2257793 --- /dev/null +++ b/src/ui/validation/validators.ts @@ -0,0 +1,49 @@ +// src/settings/validation/validators.ts + +/** + * Проверяет, является ли предоставленное значение булевым типом (boolean). + * Эта функция не зависит от контекста Obsidian или конкретного компонента. + * + * @param value - Значение любого типа для проверки. + * @returns Объект с сообщением об ошибке, если значение не является boolean, иначе null. + */ +export function validateValueIsBoolean(value: any): { message: string } | null { + if (typeof value !== 'boolean') { + return { message: 'Value must be a boolean.' }; + } + return null; +} + +// --- Сюда в будущем можно будет добавлять другие чистые функции валидации --- + +/* +// Пример для будущей валидации JSON-массива строк +export function validateJsonStringArray(rawJson: string): { message: string } | null { + try { + const parsed = JSON.parse(rawJson); + if (!Array.isArray(parsed)) { + return { message: 'Input must be a valid JSON array (e.g., ["x", " "]).' }; + } + for (let i = 0; i < parsed.length; i++) { + const element = parsed[i]; + if (typeof element !== 'string') { + return { message: `Array element at index ${i} is not a string.` }; + } + if (element.length !== 1) { + return { message: `Element "${element}" at index ${i} must be a single character.` }; + } + } + return null; // Все проверки пройдены + } catch (e: any) { + return { message: `Invalid JSON format: ${e.message}` }; + } +} + +// Пример для проверки, что массив не пустой +export function validateNotEmptyArray(arr: any[]): { message: string } | null { + if (!Array.isArray(arr) || arr.length === 0) { + return { message: 'The list cannot be empty.' }; + } + return null; +} +*/ \ No newline at end of file From 7c78611f1c7ea69d8afb0d1366fca6307ea906d3 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sat, 26 Apr 2025 23:35:01 +0300 Subject: [PATCH 04/14] refactor(settings): Integrate first component into display --- src/ui/CheckboxSyncPluginSettingTab.ts | 40 +++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 5e4e362..6b5f3fe 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -3,6 +3,8 @@ import CheckboxSyncPlugin from "../main"; import { CheckboxState, DEFAULT_SETTINGS } from "../types"; import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { Mutex } from "async-mutex"; +import { SettingGroup } from "./SettingGroup"; +import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -13,17 +15,47 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { private parentToggle: ToggleComponent; private childToggle: ToggleComponent; private enableAutomaticFileSyncToggle: ToggleComponent; + + private settingGroups: SettingGroup[] = [] + private applyButton: ButtonComponent; private resetToDefaultButton: ButtonComponent; + private errorDisplayEl: HTMLElement; + private isDirty: boolean = false; private actionMutex = new Mutex(); constructor(app: App, plugin: CheckboxSyncPlugin) { super(app, plugin); this.plugin = plugin; + this.initializeSettingGroups(); } + // Метод для создания и конфигурации групп и компонентов + private initializeSettingGroups(): void { + // 1. Создаем экземпляр нашего компонента + const parentToggleComp = new EnableParentSyncSettingComponent(); + + // 2. Устанавливаем ему обработчик изменений, чтобы он мог обновить флаг isDirty + // Мы передаем лямбда-функцию, которая вызовет наш метод settingChanged + parentToggleComp.setChangeListener(() => this.settingChanged()); + + // 3. Создаем группу настроек + const behaviorGroup = new SettingGroup( + "Synchronization Behavior", // Заголовок группы + [parentToggleComp] // Массив компонентов для этой группы (пока один) + // Можно добавить описание группы третьим аргументом, если нужно + ); + + // 4. Сохраняем созданную группу (или группы) в поле класса + this.settingGroups = [ + behaviorGroup + // Сюда позже добавятся другие группы (например, для символов) + ]; + } + + /** * Parses a string expecting a JSON array of single-character strings. */ @@ -154,6 +186,11 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { }); + for (const group of this.settingGroups) { + // Вызываем метод render группы, передавая контейнер и текущие настройки + group.render(containerEl, this.plugin.settings); + } + /* containerEl.createEl('h3', { text: 'Synchronization Behavior' }); // --- Parent State Toggle --- new Setting(containerEl) @@ -166,7 +203,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { .onChange(() => this.settingChanged()) }); - + */ // --- Child State Toggle --- new Setting(containerEl) .setName('Update child checkbox state automatically') @@ -189,6 +226,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { }); + // --- Область для вывода ошибок --- this.errorDisplayEl = containerEl.createDiv({ cls: 'checkbox-sync-settings-error' }); // Используем стандартные CSS переменные Obsidian для цвета ошибки From d4e23856c1deeaece3bc0fefa72fb13fe6bad439 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 00:26:43 +0300 Subject: [PATCH 05/14] refactor(settings): Implement component save/load logic and remove getDefaultValue --- src/ui/CheckboxSyncPluginSettingTab.ts | 68 ++++++++++++++++--- src/ui/components/BaseSettingComponent.ts | 42 ++++-------- .../EnableParentSyncSettingComponent.ts | 7 +- src/ui/interfaces/ISettingComponent.ts | 6 -- 4 files changed, 76 insertions(+), 47 deletions(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 6b5f3fe..0a83935 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -1,10 +1,11 @@ import { App, ButtonComponent, DropdownComponent, Notice, PluginSettingTab, Setting, TextComponent, ToggleComponent } from "obsidian"; import CheckboxSyncPlugin from "../main"; -import { CheckboxState, DEFAULT_SETTINGS } from "../types"; +import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types"; import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { Mutex } from "async-mutex"; import { SettingGroup } from "./SettingGroup"; import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; +import { ValidationError } from "./validation/types"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -12,7 +13,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { private uncheckedSymbolsInput: TextComponent; private ignoreSymbolsInput: TextComponent; private unknownPolicyDropdown: DropdownComponent; - private parentToggle: ToggleComponent; + // private parentToggle: ToggleComponent; private childToggle: ToggleComponent; private enableAutomaticFileSyncToggle: ToggleComponent; @@ -307,12 +308,25 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { 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); + } + } + } + } + // Обновляем значения всех полей ввода this.checkedSymbolsInput.setValue(this.arrayToJsonString(settings.checkedSymbols)); this.uncheckedSymbolsInput.setValue(this.arrayToJsonString(settings.uncheckedSymbols)); this.ignoreSymbolsInput.setValue(this.arrayToJsonString(settings.ignoreSymbols)); this.unknownPolicyDropdown.setValue(settings.unknownSymbolPolicy); - this.parentToggle.setValue(settings.enableAutomaticParentState); + // this.parentToggle.setValue(settings.enableAutomaticParentState); this.childToggle.setValue(settings.enableAutomaticChildState); this.enableAutomaticFileSyncToggle.setValue(settings.enableAutomaticFileSync); @@ -396,12 +410,36 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { * Does NOT interact with UI feedback (buttons, notices, error display). */ private async validateAndSaveSettings(): Promise { + + 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}` }); + } + } + } + // 1. Считываем значения из UI const checkedValue = this.checkedSymbolsInput.getValue(); const uncheckedValue = this.uncheckedSymbolsInput.getValue(); const ignoreValue = this.ignoreSymbolsInput.getValue(); const policyValue = this.unknownPolicyDropdown.getValue() as CheckboxState; - const parentValue = this.parentToggle.getValue(); + // const parentValue = this.parentToggle.getValue(); const childValue = this.childToggle.getValue(); const automaticFileSyncValue = this.enableAutomaticFileSyncToggle.getValue(); @@ -447,15 +485,23 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { throw new Error("Unchecked symbols list cannot be empty."); } + newSettingsData.checkedSymbols = checkedSymbolsArray; + newSettingsData.uncheckedSymbols = uncheckedSymbolsArray; + newSettingsData.ignoreSymbols = ignoreSymbolsArray; + newSettingsData.unknownSymbolPolicy = policyValue; + newSettingsData.enableAutomaticChildState = childValue; + newSettingsData.enableAutomaticFileSync = automaticFileSyncValue; + + if (errors.length > 0) { + // Формируем сообщение об ошибке + const errorMessage = errors.map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); + throw new Error(errorMessage); + } + + // 6. Вызываем сохранение -> await (может кинуть ошибку) await this.plugin.updateSettings(settings => { - settings.checkedSymbols = checkedSymbolsArray; - settings.uncheckedSymbols = uncheckedSymbolsArray; - settings.ignoreSymbols = ignoreSymbolsArray; - settings.unknownSymbolPolicy = policyValue; - settings.enableAutomaticParentState = parentValue; - settings.enableAutomaticChildState = childValue; - settings.enableAutomaticFileSync = automaticFileSyncValue; + Object.assign(settings, newSettingsData); }); } } \ No newline at end of file diff --git a/src/ui/components/BaseSettingComponent.ts b/src/ui/components/BaseSettingComponent.ts index ec138d3..85de0bf 100644 --- a/src/ui/components/BaseSettingComponent.ts +++ b/src/ui/components/BaseSettingComponent.ts @@ -5,36 +5,24 @@ import { ValidationError } from '../validation/types'; // Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам export abstract class BaseSettingComponent implements ISettingComponent { - protected setting: Setting; // Инициализируется в render конкретного компонента - protected onChangeCallback: () => void = () => {}; + protected setting: Setting; // Инициализируется в render конкретного компонента + protected onChangeCallback: () => void = () => { }; - // Конструктор теперь пустой или с минимальными общими зависимостями - constructor() {} + // Конструктор теперь пустой или с минимальными общими зависимостями + constructor() { } - // --- Методы для реализации наследниками --- - abstract getSettingKey(): keyof CheckboxSyncPluginSettings; - // render теперь должен сам создавать Setting и сохранять ссылку в this.setting - abstract render(container: HTMLElement, currentValue: any): void; - abstract getValueFromUi(): any; - abstract setValueInUi(value: any): void; - abstract validate(): ValidationError | null; + // --- Методы для реализации наследниками --- + abstract getSettingKey(): keyof CheckboxSyncPluginSettings; + // render теперь должен сам создавать Setting и сохранять ссылку в this.setting + abstract render(container: HTMLElement, currentValue: any): void; + abstract getValueFromUi(): any; + abstract setValueInUi(value: any): void; + abstract validate(): ValidationError | null; - public getDefaultValue(): any { - const key = this.getSettingKey(); // Получаем ключ от наследника - if (key in DEFAULT_SETTINGS) { - // Возвращаем значение из импортированного объекта - return DEFAULT_SETTINGS[key]; - } else { - // Логируем предупреждение, если ключ не найден (ошибка конфигурации) - console.warn( - `[${this.constructor.name}] Default value for key "${key}" not found in DEFAULT_SETTINGS. Returning undefined.` - ); - return undefined; // Или null, или выбросить ошибку, но undefined безопаснее - } + + // --- Общая реализация --- + public setChangeListener(listener: () => void): void { + this.onChangeCallback = listener; } - // --- Общая реализация --- - public setChangeListener(listener: () => void): void { - this.onChangeCallback = listener; - } } \ No newline at end of file diff --git a/src/ui/components/EnableParentSyncSettingComponent.ts b/src/ui/components/EnableParentSyncSettingComponent.ts index 02b8e8a..96e0139 100644 --- a/src/ui/components/EnableParentSyncSettingComponent.ts +++ b/src/ui/components/EnableParentSyncSettingComponent.ts @@ -33,15 +33,16 @@ export class EnableParentSyncSettingComponent extends BaseSettingComponent { if (this.toggleComponent) { return this.toggleComponent.getValue(); } - console.warn(`getValueFromUi called before render for ${this.getSettingKey()}`); - // Используем унаследованный getDefaultValue() для консистентности - return this.getDefaultValue(); + // Если toggleComponent все еще не инициализирован (render не вызывался?) + // выбрасываем ошибку, сигнализируя о неправильном использовании + 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); } + throw new Error(`[${this.getSettingKey()}] Attempted to set value before component is rendered.`); } validate(): ValidationError | null { diff --git a/src/ui/interfaces/ISettingComponent.ts b/src/ui/interfaces/ISettingComponent.ts index 9d26ce5..33a63cd 100644 --- a/src/ui/interfaces/ISettingComponent.ts +++ b/src/ui/interfaces/ISettingComponent.ts @@ -12,12 +12,6 @@ export interface ISettingComponent { */ getSettingKey(): keyof CheckboxSyncPluginSettings; - /** - * Получает значение по умолчанию для этой настройки. - * @returns Значение по умолчанию. - */ - getDefaultValue(): any; - /** * Рендерит UI-элементы для этой настройки в указанный контейнер. * @param container - HTML-элемент, куда добавлять настройку. From 400ce20193cf0ccf360f17fa10c3f76807a3d563 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 04:18:56 +0300 Subject: [PATCH 06/14] fix tab --- .editorconfig | 4 ++-- .eslintrc | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index 84b8a66..a921f07 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,5 +6,5 @@ charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = tab -indent_size = 4 -tab_width = 4 +indent_size = 2 +tab_width = 2 diff --git a/.eslintrc b/.eslintrc index 0807290..bb6bf71 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,6 +18,7 @@ "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "indent": ["error", 2] } } \ No newline at end of file From cdcf3a9db392704341e13ffb4de3bb02716d275e Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 05:16:18 +0300 Subject: [PATCH 07/14] refactor(settings): Migrate all remaining settings to component architecture --- .../components/validation/validators.test.ts | 115 +++++++++- src/ui/CheckboxSyncPluginSettingTab.ts | 216 ++++-------------- .../BaseTextArraySettingComponent.ts | 130 +++++++++++ .../CheckedSymbolsSettingComponent.ts | 76 ++++++ .../EnableChildSyncSettingComponent.ts | 58 +++++ .../EnableFileSyncSettingComponent.ts | 72 ++++++ .../IgnoreSymbolsSettingComponent.ts | 38 +++ .../UncheckedSymbolsSettingComponent.ts | 64 ++++++ .../UnknownPolicySettingComponent.ts | 72 ++++++ src/ui/validation/validators.ts | 98 +++++--- 10 files changed, 738 insertions(+), 201 deletions(-) create mode 100644 src/ui/components/BaseTextArraySettingComponent.ts create mode 100644 src/ui/components/CheckedSymbolsSettingComponent.ts create mode 100644 src/ui/components/EnableChildSyncSettingComponent.ts create mode 100644 src/ui/components/EnableFileSyncSettingComponent.ts create mode 100644 src/ui/components/IgnoreSymbolsSettingComponent.ts create mode 100644 src/ui/components/UncheckedSymbolsSettingComponent.ts create mode 100644 src/ui/components/UnknownPolicySettingComponent.ts diff --git a/__tests__/ui/components/validation/validators.test.ts b/__tests__/ui/components/validation/validators.test.ts index d22c1a8..a9e1087 100644 --- a/__tests__/ui/components/validation/validators.test.ts +++ b/__tests__/ui/components/validation/validators.test.ts @@ -1,4 +1,5 @@ -import { validateValueIsBoolean } from "src/ui/validation/validators"; +import { CheckboxState } from "src/types"; +import { validateIsCheckboxState, validateJsonStringArray, validateNotEmptyArray, validateValueIsBoolean } from "src/ui/validation/validators"; // Группа тестов для всего файла валидаторов @@ -56,15 +57,117 @@ describe('Validation Utility Functions', () => { }); }); - // --- Сюда можно будет добавлять describe-блоки для других функций валидации --- - /* + describe('validateIsCheckboxState', () => { + it('should return null for valid CheckboxState values', () => { + expect(validateIsCheckboxState(CheckboxState.Checked)).toBeNull(); + expect(validateIsCheckboxState(CheckboxState.Unchecked)).toBeNull(); + expect(validateIsCheckboxState(CheckboxState.Ignore)).toBeNull(); + }); + + it('should return error object for invalid values', () => { + const expectedError = { message: `Invalid checkbox state selected. Must be one of: ${Object.values(CheckboxState).join(', ')}.` }; + expect(validateIsCheckboxState('checked ')).toEqual(expectedError); // Лишний пробел + expect(validateIsCheckboxState('CHECKED')).toEqual(expectedError); // Не тот регистр + expect(validateIsCheckboxState(null)).toEqual(expectedError); + expect(validateIsCheckboxState(undefined)).toEqual(expectedError); + expect(validateIsCheckboxState(1)).toEqual(expectedError); + expect(validateIsCheckboxState({})).toEqual(expectedError); + expect(validateIsCheckboxState('')).toEqual(expectedError); + }); + }); + describe('validateJsonStringArray', () => { - // ... тесты для валидации JSON ... + // Валидные случаи + test('should return null for valid single-element array', () => { + expect(validateJsonStringArray('["x"]')).toBeNull(); + expect(validateJsonStringArray('[" "]')).toBeNull(); + }); + + test('should return null for valid multi-element array', () => { + expect(validateJsonStringArray('["x", " ", "✓"]')).toBeNull(); + }); + + test('should return null for empty array string "[]"', () => { + expect(validateJsonStringArray('[]')).toBeNull(); + }); + + test('should return null for empty string ""', () => { + // Мы решили, что пустая строка - валидный пустой массив + expect(validateJsonStringArray('')).toBeNull(); + }); + + test('should return null for string with whitespace around valid JSON', () => { + expect(validateJsonStringArray(' ["x", "o"] ')).toBeNull(); + }); + + + // Невалидные случаи: Не JSON + test('should return error for non-JSON string', () => { + expect(validateJsonStringArray('["x"')).toEqual({ message: expect.stringContaining('JSON format') }); + expect(validateJsonStringArray('abc')).toEqual({ message: expect.stringContaining('JSON format') }); + expect(validateJsonStringArray('[x]')).toEqual({ message: expect.stringContaining('JSON format') }); // 'x' должен быть в кавычках + }); + + // Невалидные случаи: Не массив + test('should return error for valid JSON that is not an array', () => { + expect(validateJsonStringArray('{"a": 1}')).toEqual({ message: expect.stringContaining('must be a valid JSON array') }); + expect(validateJsonStringArray('"string"')).toEqual({ message: expect.stringContaining('must be a valid JSON array') }); + expect(validateJsonStringArray('123')).toEqual({ message: expect.stringContaining('must be a valid JSON array') }); + expect(validateJsonStringArray('null')).toEqual({ message: expect.stringContaining('must be a valid JSON array') }); + }); + + // Невалидные случаи: Содержимое массива - не строки + test('should return error if array contains non-string elements', () => { + expect(validateJsonStringArray('[1]')).toEqual({ message: expect.stringContaining('not a string') }); + expect(validateJsonStringArray('["x", 2]')).toEqual({ message: expect.stringContaining('not a string') }); + expect(validateJsonStringArray('[null]')).toEqual({ message: expect.stringContaining('not a string') }); + expect(validateJsonStringArray('[[]]')).toEqual({ message: expect.stringContaining('not a string') }); + }); + + // Невалидные случаи: Строки не из одного символа + test('should return error if array contains strings of incorrect length', () => { + expect(validateJsonStringArray('["xx"]')).toEqual({ message: expect.stringContaining('must be a single character') }); + expect(validateJsonStringArray('["x", "ab"]')).toEqual({ message: expect.stringContaining('must be a single character') }); + expect(validateJsonStringArray('[""]')).toEqual({ message: expect.stringContaining('must be a single character') }); // Пустая строка тоже не 1 символ + }); + + // Невалидные случаи: Дубликаты символов + test('should return error if array contains duplicate symbols', () => { + expect(validateJsonStringArray('["x", "x"]')).toEqual({ message: expect.stringContaining('Duplicate symbol "x"') }); + expect(validateJsonStringArray('["a", "b", "a"]')).toEqual({ message: expect.stringContaining('Duplicate symbol "a"') }); + }); + + // Невалидные случаи: Null или undefined на входе + test('should return error for null or undefined input', () => { + expect(validateJsonStringArray(null)).toEqual({ message: "Input must be a string." }); + expect(validateJsonStringArray(undefined)).toEqual({ message: "Input must be a string." }); + }); }); describe('validateNotEmptyArray', () => { - // ... тесты для проверки на пустой массив ... + // Валидные случаи (не пустой массив) + test('should return null for non-empty arrays', () => { + expect(validateNotEmptyArray(['a'])).toBeNull(); + expect(validateNotEmptyArray([1, 2])).toBeNull(); + expect(validateNotEmptyArray([null])).toBeNull(); + expect(validateNotEmptyArray([undefined])).toBeNull(); + expect(validateNotEmptyArray([[]])).toBeNull(); // Массив, содержащий пустой массив + }); + + // Невалидные случаи: Пустой массив + test('should return error object for an empty array', () => { + expect(validateNotEmptyArray([])).toEqual({ message: 'The list cannot be empty.' }); + }); + + // Невалидные случаи: Не массив + test('should return error object for non-array values', () => { + const expectedError = { message: 'Value must be an array.' }; + expect(validateNotEmptyArray(null)).toEqual(expectedError); + expect(validateNotEmptyArray(undefined)).toEqual(expectedError); + expect(validateNotEmptyArray('abc')).toEqual(expectedError); + expect(validateNotEmptyArray(123)).toEqual(expectedError); + expect(validateNotEmptyArray({})).toEqual(expectedError); + }); }); - */ }); \ No newline at end of file diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 0a83935..9992062 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -1,21 +1,20 @@ -import { App, ButtonComponent, DropdownComponent, Notice, PluginSettingTab, Setting, TextComponent, ToggleComponent } from "obsidian"; -import CheckboxSyncPlugin from "../main"; -import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types"; -import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { Mutex } from "async-mutex"; +import { App, ButtonComponent, Notice, PluginSettingTab, Setting, TextComponent } from "obsidian"; +import CheckboxSyncPlugin from "../main"; +import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types"; import { SettingGroup } from "./SettingGroup"; +import { EnableChildSyncSettingComponent } from "./components/EnableChildSyncSettingComponent"; +import { EnableFileSyncSettingComponent } from "./components/EnableFileSyncSettingComponent"; import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; +import { UnknownPolicySettingComponent } from "./components/UnknownPolicySettingComponent"; +import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { ValidationError } from "./validation/types"; +import { CheckedSymbolsSettingComponent } from "./components/CheckedSymbolsSettingComponent"; +import { UncheckedSymbolsSettingComponent } from "./components/UncheckedSymbolsSettingComponent"; +import { IgnoreSymbolsSettingComponent } from "./components/IgnoreSymbolsSettingComponent"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; - private checkedSymbolsInput: TextComponent; - private uncheckedSymbolsInput: TextComponent; - private ignoreSymbolsInput: TextComponent; - private unknownPolicyDropdown: DropdownComponent; - // private parentToggle: ToggleComponent; - private childToggle: ToggleComponent; - private enableAutomaticFileSyncToggle: ToggleComponent; private settingGroups: SettingGroup[] = [] @@ -35,22 +34,45 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { // Метод для создания и конфигурации групп и компонентов private initializeSettingGroups(): void { - // 1. Создаем экземпляр нашего компонента - const parentToggleComp = new EnableParentSyncSettingComponent(); + // 1. Создаем экземпляры наших компонентов - // 2. Устанавливаем ему обработчик изменений, чтобы он мог обновить флаг isDirty - // Мы передаем лямбда-функцию, которая вызовет наш метод settingChanged + const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); + checkedSymbolsComp.setChangeListener(() => this.settingChanged()); + + const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); + uncheckedSymbolsComp.setChangeListener(() => this.settingChanged()); + + const unknownPolicyComp = new UnknownPolicySettingComponent(); + unknownPolicyComp.setChangeListener(() => this.settingChanged()); + + const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent(); + ignoreSymbolsComp.setChangeListener(() => this.settingChanged()); + + 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(); parentToggleComp.setChangeListener(() => this.settingChanged()); - // 3. Создаем группу настроек + const childrenToggleComp = new EnableChildSyncSettingComponent(); + childrenToggleComp.setChangeListener(() => this.settingChanged()); + + const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent(); + automaticFileSyncToggleComp.setChangeListener(() => this.settingChanged()); + const behaviorGroup = new SettingGroup( "Synchronization Behavior", // Заголовок группы - [parentToggleComp] // Массив компонентов для этой группы (пока один) + [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] // Массив компонентов для этой группы // Можно добавить описание группы третьим аргументом, если нужно ); - // 4. Сохраняем созданную группу (или группы) в поле класса + // 3. Сохраняем созданную группу (или группы) в поле класса this.settingGroups = [ + symbolGroup, behaviorGroup // Сюда позже добавятся другие группы (например, для символов) ]; @@ -133,100 +155,10 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { const containerEl = this.containerEl; containerEl.empty(); containerEl.createEl('h2', { text: 'Checkbox Sync Settings' }); - containerEl.createEl('h3', { text: 'Checkbox Symbol Configuration (Advanced: JSON)' }); - containerEl.createEl('p', { text: 'Warning: Requires valid JSON format. Use double quotes for strings.' }); - - // --- Checked Symbols --- - new Setting(containerEl) - .setName("Checked Symbols") - .setDesc("Enter symbols as a JSON array of single-character strings. Example: [\"x\", \"✓\", \" \"]") - .addText((text) => { - this.checkedSymbolsInput = text; - text - .setPlaceholder("e.g., [\"x\", \" \"]") - .setValue(this.arrayToJsonString(this.plugin.settings.checkedSymbols)) - .onChange(() => this.settingChanged()) - }); - - // --- Unchecked Symbols --- - new Setting(containerEl) - .setName("Unchecked Symbols") - .setDesc("Enter symbols as a JSON array of single-character strings. Example: [\" \", \"?\", \",\"]") - .addText((text) => { - this.uncheckedSymbolsInput = text; - text - .setPlaceholder("e.g., [\" \", \"?\", \",\"]") - .setValue(this.arrayToJsonString(this.plugin.settings.uncheckedSymbols)) - .onChange(() => this.settingChanged()) - }); - - // --- Ignore Symbols --- - new Setting(containerEl) - .setName("Ignore Symbols") - .setDesc("Checkboxes with these symbols will be ignored during automatic parent/child state updates. Example: [\"-\", \"~\"]") - .addText((text) => { - this.ignoreSymbolsInput = text; // Сохраняем ссылку - text - .setPlaceholder("e.g., [\"-\", \"~\"]") - .setValue(this.arrayToJsonString(this.plugin.settings.ignoreSymbols)) - .onChange(() => this.settingChanged()) - }); - - // --- Unknown Symbol Policy --- - new Setting(containerEl) - .setName('Unknown Symbol Policy') - .setDesc('How to treat symbols not in Checked or Unchecked lists.') - .addDropdown(dropdown => { - this.unknownPolicyDropdown = dropdown; - dropdown - .addOption(CheckboxState.Checked, 'Treat as Checked') - .addOption(CheckboxState.Unchecked, 'Treat as Unchecked') - .addOption(CheckboxState.Ignore, 'Ignore') - .setValue(this.plugin.settings.unknownSymbolPolicy) - .onChange(() => this.settingChanged()) - }); - - + for (const group of this.settingGroups) { - // Вызываем метод render группы, передавая контейнер и текущие настройки group.render(containerEl, this.plugin.settings); } - /* - containerEl.createEl('h3', { text: 'Synchronization Behavior' }); - // --- Parent State Toggle --- - new Setting(containerEl) - .setName('Update parent checkbox state automatically') - .setDesc('If enabled, the state of a parent checkbox will be automatically updated based on the state of its children (all children checked = parent checked). If disabled, only manually changing a parent will affect its children.') - .addToggle(toggle => { - this.parentToggle = toggle; - toggle - .setValue(this.plugin.settings.enableAutomaticParentState) - .onChange(() => this.settingChanged()) - }); - - */ - // --- Child State Toggle --- - new Setting(containerEl) - .setName('Update child checkbox state automatically') - .setDesc('If enabled, changing the state of a parent checkbox will automatically update the state of all its direct and nested children. If disabled, changing a parent checkbox will not affect its children.') - .addToggle(toggle => { - this.childToggle = toggle; - toggle - .setValue(this.plugin.settings.enableAutomaticChildState) - .onChange(() => this.settingChanged()) - }); - - new Setting(containerEl) - .setName("Enable automatic file synchronization") - .setDesc("If enabled (requires restart or settings reload), automatically syncs checkbox states when files are loaded/opened and after settings changes. If disabled (default), sync only occurs when you manually change a checkbox.") - .addToggle(toggle => { - this.enableAutomaticFileSyncToggle = toggle; - toggle - .setValue(this.plugin.settings.enableAutomaticFileSync) - .onChange(() => this.settingChanged()); - }); - - // --- Область для вывода ошибок --- this.errorDisplayEl = containerEl.createDiv({ cls: 'checkbox-sync-settings-error' }); @@ -283,16 +215,10 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { try { await this.validateAndSaveSettings(); - - this.checkedSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.checkedSymbols)); - this.uncheckedSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.uncheckedSymbols)); - this.ignoreSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.ignoreSymbols)); - this.settingSaved(); this.errorDisplayEl.setText(''); new Notice("Checkbox Sync settings applied!", 3000); } catch (error: any) { - // --- Ошибка --- console.error("Error applying checkbox sync settings:", error); // Выводим ошибку в специальный div this.errorDisplayEl.setText(`❌ ${error.message || "An unknown error occurred."}`); @@ -321,15 +247,6 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } } - // Обновляем значения всех полей ввода - this.checkedSymbolsInput.setValue(this.arrayToJsonString(settings.checkedSymbols)); - this.uncheckedSymbolsInput.setValue(this.arrayToJsonString(settings.uncheckedSymbols)); - this.ignoreSymbolsInput.setValue(this.arrayToJsonString(settings.ignoreSymbols)); - this.unknownPolicyDropdown.setValue(settings.unknownSymbolPolicy); - // this.parentToggle.setValue(settings.enableAutomaticParentState); - this.childToggle.setValue(settings.enableAutomaticChildState); - this.enableAutomaticFileSyncToggle.setValue(settings.enableAutomaticFileSync); - // Очищаем ошибку и сбрасываем состояние "грязный" this.errorDisplayEl?.setText(''); this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply @@ -434,64 +351,25 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } } - // 1. Считываем значения из UI - const checkedValue = this.checkedSymbolsInput.getValue(); - const uncheckedValue = this.uncheckedSymbolsInput.getValue(); - const ignoreValue = this.ignoreSymbolsInput.getValue(); - const policyValue = this.unknownPolicyDropdown.getValue() as CheckboxState; - // const parentValue = this.parentToggle.getValue(); - const childValue = this.childToggle.getValue(); - const automaticFileSyncValue = this.enableAutomaticFileSyncToggle.getValue(); + const checkedSymbolsFromComponent = newSettingsData.checkedSymbols || []; + const uncheckedSymbolsFromComp = newSettingsData.uncheckedSymbols || []; + const ignoreSymbolsFromComp = newSettingsData.ignoreSymbols || []; - // 2. Парсим JSON - const parsedChecked = this.parseJsonStringArray(checkedValue); - const parsedUnchecked = this.parseJsonStringArray(uncheckedValue); - const parsedIgnore = this.parseJsonStringArray(ignoreValue); - - // 3. Проверяем ошибки парсинга -> throw - if (parsedChecked.error) { - throw new Error(`Checked Symbols Error: ${parsedChecked.error}`); - } - if (parsedUnchecked.error) { - throw new Error(`Unchecked Symbols Error: ${parsedUnchecked.error}`); - } - if (parsedIgnore.error) { - throw new Error(`Ignore Symbols Error: ${parsedIgnore.error}`); - } - - const checkedSymbolsArray = parsedChecked.result; - const uncheckedSymbolsArray = parsedUnchecked.result; - const ignoreSymbolsArray = parsedIgnore.result; // 4. Валидируем пересечение -> throw - const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsArray, uncheckedSymbolsArray); + const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, uncheckedSymbolsFromComp); if (!intersectionValidation1.isValid) { throw new Error(`Lists: checked and unchecked. ${intersectionValidation1.error!}`); } - const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsArray, ignoreSymbolsArray); + const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, ignoreSymbolsFromComp); if (!intersectionValidation2.isValid) { throw new Error(`Lists: checked and ignore. ${intersectionValidation2.error!}`); } - const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsArray, ignoreSymbolsArray); + const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsFromComp, ignoreSymbolsFromComp); if (!intersectionValidation3.isValid) { throw new Error(`Lists: unchecked and ignore. ${intersectionValidation3.error!}`); } - // 5. Валидируем на пустые списки -> throw - if (checkedSymbolsArray.length === 0) { - throw new Error("Checked symbols list cannot be empty."); - } - if (uncheckedSymbolsArray.length === 0) { - throw new Error("Unchecked symbols list cannot be empty."); - } - - newSettingsData.checkedSymbols = checkedSymbolsArray; - newSettingsData.uncheckedSymbols = uncheckedSymbolsArray; - newSettingsData.ignoreSymbols = ignoreSymbolsArray; - newSettingsData.unknownSymbolPolicy = policyValue; - newSettingsData.enableAutomaticChildState = childValue; - newSettingsData.enableAutomaticFileSync = automaticFileSyncValue; - if (errors.length > 0) { // Формируем сообщение об ошибке const errorMessage = errors.map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); diff --git a/src/ui/components/BaseTextArraySettingComponent.ts b/src/ui/components/BaseTextArraySettingComponent.ts new file mode 100644 index 0000000..a68ea81 --- /dev/null +++ b/src/ui/components/BaseTextArraySettingComponent.ts @@ -0,0 +1,130 @@ +import { TextComponent } from "obsidian"; +import { ValidationError } from "../validation/types"; +import { validateJsonStringArray } from "../validation/validators"; +import { BaseSettingComponent } from "./BaseSettingComponent"; + +/** + * Абстрактный базовый класс для компонентов настроек, + * которые управляют массивом строк через текстовое поле с JSON. + */ +export abstract class BaseTextArraySettingComponent extends BaseSettingComponent { + /** Ссылка на текстовое поле ввода */ + protected textInput: TextComponent; + + constructor() { + super(); + } + + /** + * Форматирует массив строк в JSON строку для отображения в UI. + * @param symbols Массив строк. + * @returns Форматированная JSON строка. + */ + protected arrayToJsonString(symbols: string[] | undefined): string { + if (!symbols) { + return '[]'; + } + try { + return JSON.stringify(symbols); + } catch (e) { + console.error(`[${this.getSettingKey()}] Error stringifying array to JSON:`, e); + return '[]'; // Возвращаем безопасное значение + } + } + + /** + * Получает JSON строку из UI и парсит ее в массив строк. + * @returns Распарсенный массив строк. + * @throws Error если компонент не отрисован или если строка не является валидным JSON. + */ + getValueFromUi(): string[] { + if (this.textInput) { + const rawJson = this.textInput.getValue(); + const trimmedValue = rawJson.trim(); + + // Считаем пустую строку или '[]' валидным представлением пустого массива + if (trimmedValue === '' || trimmedValue === '[]') { + return []; + } + + try { + const parsed = JSON.parse(trimmedValue); + // Дополнительная проверка, что результат парсинга - массив + if (!Array.isArray(parsed)) { + // Эта ошибка также будет поймана validateJsonStringArray, + // но для надежности можно оставить. + throw new Error("Input must be a valid JSON array."); + } + // Предполагаем, что содержимое массива будет проверено валидатором. + // Возвращаем как string[] для TypeScript. + return parsed as string[]; + } catch (e: any) { + // Оборачиваем ошибку парсинга + throw new Error(`Invalid JSON format: ${e.message}`); + } + } + // Если компонент не был отрисован + throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`); + } + + /** + * Устанавливает значение (массив строк) в UI, предварительно форматируя его в JSON. + * @param value Массив строк для установки. + * @throws Error если компонент не отрисован. + */ + setValueInUi(value: any): void { + if (this.textInput) { + // Убедимся, что на вход пришел массив, прежде чем его форматировать + if (!Array.isArray(value)) { + console.warn(`[${this.getSettingKey()}] Attempted to set non-array value in TextArray component. Value:`, value); + // Можно установить пустой массив или выбросить ошибку? + // Установим пустой массив для безопасности UI. + this.textInput.setValue('[]'); + return; + } + const jsonStringValue = this.arrayToJsonString(value as string[]); + this.textInput.setValue(jsonStringValue); + } else { + // Если компонент не был отрисован + throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`); + } + } + + /** + * Выполняет базовую валидацию: проверяет, что строка в UI является + * валидным JSON-массивом строк по одному символу без дубликатов. + * Не проверяет на пустоту массива. + * @returns Объект ValidationError при ошибке, иначе null. + */ + validate(): ValidationError | null { + let rawValue: string; + try { + if (this.textInput) { + rawValue = this.textInput.getValue(); + } else { + // Если UI нет, валидация невозможна + throw new Error("UI component not available for validation."); + } + + // Вызываем внешний валидатор для проверки формата JSON и содержимого + const jsonError = validateJsonStringArray(rawValue); + + if (jsonError) { + // Добавляем ключ поля к ошибке + return { + field: this.getSettingKey(), + message: jsonError.message + }; + } + } catch (error: any) { + // Ловим ошибки доступа к UI + return { + field: this.getSettingKey(), + message: error instanceof Error ? error.message : String(error) + }; + } + + // Базовая валидация JSON и содержимого прошла успешно + return null; + } +} \ No newline at end of file diff --git a/src/ui/components/CheckedSymbolsSettingComponent.ts b/src/ui/components/CheckedSymbolsSettingComponent.ts new file mode 100644 index 0000000..661e054 --- /dev/null +++ b/src/ui/components/CheckedSymbolsSettingComponent.ts @@ -0,0 +1,76 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { ValidationError } from "../validation/types"; +import { validateNotEmptyArray } from "../validation/validators"; +import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; + +// ИЗМЕНЕНИЕ: Наследуемся от BaseTextArraySettingComponent +export class CheckedSymbolsSettingComponent extends BaseTextArraySettingComponent { + + constructor() { + super(); // Вызываем конструктор родителя + } + + // --- Реализация специфичных для этого компонента методов --- + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'checkedSymbols'; + } + + + render(container: HTMLElement, currentValue: any): void { + // Используем унаследованный arrayToJsonString + const jsonStringValue = this.arrayToJsonString(currentValue as string[]); + + this.setting = new Setting(container) + // Уникальные тексты для этого компонента + .setName("Checked Symbols") + .setDesc("Enter symbols as a JSON array of single-character strings. Example: [\"x\", \"✓\", \" \"]") + .addText(text => { + // Используем унаследованное поле textInput + this.textInput = text; + text + .setPlaceholder("e.g., [\"x\", \" \"]") // Уникальный плейсхолдер? + .setValue(jsonStringValue) + // Используем унаследованный onChangeCallback + .onChange(this.onChangeCallback); + }); + } + + // --- Переопределение validate для добавления проверки на пустоту --- + validate(): ValidationError | null { + // 1. Выполняем базовую валидацию JSON из родительского класса + const baseError = super.validate(); // Вызов BaseTextArraySettingComponent.validate() + if (baseError) { + // Если базовый JSON невалиден, сразу возвращаем ошибку + return baseError; + } + + // 2. Если базовый JSON валиден, получаем распарсенное значение + let parsedValue: string[]; + try { + // Вызов getValueFromUi из BaseTextArraySettingComponent + // Теперь это безопасно, так как super.validate() гарантирует валидный JSON + parsedValue = this.getValueFromUi(); + } catch (error: any) { + // На всякий случай ловим ошибку, хотя не должны сюда попасть после super.validate() + return { + field: this.getSettingKey(), + message: error instanceof Error ? error.message : String(error) + }; + } + + // 3. Выполняем дополнительную проверку на непустой массив + const notEmptyError = validateNotEmptyArray(parsedValue); + if (notEmptyError) { + // Возвращаем ошибку "непустого массива", добавляя ключ поля + return { + field: this.getSettingKey(), + message: notEmptyError.message + }; + } + + // Все проверки (базовая + специфичная) пройдены + return null; + } +} \ No newline at end of file diff --git a/src/ui/components/EnableChildSyncSettingComponent.ts b/src/ui/components/EnableChildSyncSettingComponent.ts new file mode 100644 index 0000000..cb6b75d --- /dev/null +++ b/src/ui/components/EnableChildSyncSettingComponent.ts @@ -0,0 +1,58 @@ +import { ToggleComponent, Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { ValidationError } from "../validation/types"; +import { validateValueIsBoolean } from "../validation/validators"; +import { BaseSettingComponent } from "./BaseSettingComponent"; + + +export class EnableChildSyncSettingComponent extends BaseSettingComponent { + private toggleComponent: ToggleComponent; + + constructor() { + super(); + } + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'enableAutomaticChildState'; + } + + render(container: HTMLElement, currentValue: any): void { + this.setting = new Setting(container) + .setName('Update child checkbox state automatically') + .setDesc('If enabled, changing the state of a parent checkbox will automatically update the state of all its direct and nested children. If disabled, changing a parent checkbox will not affect its children.') + .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; + } +} \ No newline at end of file diff --git a/src/ui/components/EnableFileSyncSettingComponent.ts b/src/ui/components/EnableFileSyncSettingComponent.ts new file mode 100644 index 0000000..eb6f79e --- /dev/null +++ b/src/ui/components/EnableFileSyncSettingComponent.ts @@ -0,0 +1,72 @@ +// src/settings/components/EnableFileSyncSettingComponent.ts +import { Setting, ToggleComponent } from 'obsidian'; +import { CheckboxSyncPluginSettings } from '../../types'; +import { BaseSettingComponent } from './BaseSettingComponent'; +import { ValidationError } from '../validation/types'; +import { validateValueIsBoolean } from '../validation/validators'; // Используем тот же валидатор + +export class EnableFileSyncSettingComponent extends BaseSettingComponent { + private toggleComponent: ToggleComponent; + + constructor() { + super(); + } + + getSettingKey(): keyof CheckboxSyncPluginSettings { + // ИЗМЕНЕНИЕ: Другой ключ + return 'enableAutomaticFileSync'; + } + + render(container: HTMLElement, currentValue: any): void { + this.setting = new Setting(container) + // ИЗМЕНЕНИЕ: Другие тексты + .setName("Enable automatic file synchronization") + .setDesc("If enabled (requires restart or settings reload), automatically syncs checkbox states when files are loaded/opened and after settings changes. If disabled (default), sync only occurs when you manually change a checkbox.") + .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 { + let valueFromUi: boolean; + try { + valueFromUi = this.getValueFromUi(); // Получаем значение + } catch (error) { + // Если getValueFromUi выбросил ошибку (маловероятно до рендера) + return { + field: this.getSettingKey(), + message: error instanceof Error ? error.message : String(error) + }; + } + + const validationResult = validateValueIsBoolean(valueFromUi); // Используем внешний валидатор + + if (validationResult) { + return { + field: this.getSettingKey(), + message: validationResult.message + }; + } + return null; + } +} \ No newline at end of file diff --git a/src/ui/components/IgnoreSymbolsSettingComponent.ts b/src/ui/components/IgnoreSymbolsSettingComponent.ts new file mode 100644 index 0000000..d2b6919 --- /dev/null +++ b/src/ui/components/IgnoreSymbolsSettingComponent.ts @@ -0,0 +1,38 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; + +export class IgnoreSymbolsSettingComponent extends BaseTextArraySettingComponent { + + constructor() { + super(); + } + + // --- Реализация специфичных методов --- + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'ignoreSymbols'; + } + + render(container: HTMLElement, currentValue: any): void { + const jsonStringValue = this.arrayToJsonString(currentValue as string[]); + + this.setting = new Setting(container) + .setName("Ignore Symbols") + .setDesc("Checkboxes with these symbols will be ignored during automatic parent/child state updates. Example: [\"-\", \"~\"]") + .addText(text => { + this.textInput = text; + text + .setPlaceholder("e.g., [\"-\", \"~\"]") + .setValue(jsonStringValue) + .onChange(this.onChangeCallback); + }); + } + + // validate() НЕ переопределяем. Используется базовая реализация + // из BaseTextArraySettingComponent, которая вызывает только + // validateJsonStringArray (проверка формата JSON и содержимого). + // Пустой массив здесь допустим. + + // getValueFromUi, setValueInUi, arrayToJsonString унаследованы +} \ No newline at end of file diff --git a/src/ui/components/UncheckedSymbolsSettingComponent.ts b/src/ui/components/UncheckedSymbolsSettingComponent.ts new file mode 100644 index 0000000..652f5a8 --- /dev/null +++ b/src/ui/components/UncheckedSymbolsSettingComponent.ts @@ -0,0 +1,64 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { ValidationError } from "../validation/types"; +import { validateNotEmptyArray } from "../validation/validators"; +import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; + + +export class UncheckedSymbolsSettingComponent extends BaseTextArraySettingComponent { + + constructor() { + super(); + } + + // --- Реализация специфичных методов --- + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'uncheckedSymbols'; + } + + render(container: HTMLElement, currentValue: any): void { + const jsonStringValue = this.arrayToJsonString(currentValue as string[]); + + this.setting = new Setting(container) + .setName("Unchecked Symbols") + .setDesc("Enter symbols as a JSON array of single-character strings. Example: [\" \", \"?\", \",\"]") + .addText(text => { + this.textInput = text; + text + .setPlaceholder("e.g., [\" \", \"?\", \",\"]") + .setValue(jsonStringValue) + .onChange(this.onChangeCallback); + }); + } + + // Переопределяем validate, чтобы добавить проверку на пустоту + validate(): ValidationError | null { + // 1. Базовая валидация JSON из родителя + const baseError = super.validate(); + if (baseError) { + return baseError; + } + + // 2. Получаем значение (теперь безопасно) + let parsedValue: string[]; + try { + parsedValue = this.getValueFromUi(); + } catch (error: any) { + return { field: this.getSettingKey(), message: error.message }; + } + + // 3. Дополнительная проверка на непустой массив + const notEmptyError = validateNotEmptyArray(parsedValue); + if (notEmptyError) { + return { + field: this.getSettingKey(), + message: notEmptyError.message + }; + } + + return null; // Все проверки пройдены + } + + // getValueFromUi, setValueInUi, arrayToJsonString унаследованы +} \ No newline at end of file diff --git a/src/ui/components/UnknownPolicySettingComponent.ts b/src/ui/components/UnknownPolicySettingComponent.ts new file mode 100644 index 0000000..f8455cf --- /dev/null +++ b/src/ui/components/UnknownPolicySettingComponent.ts @@ -0,0 +1,72 @@ +import { DropdownComponent, Setting } from "obsidian"; +import { CheckboxSyncPluginSettings, CheckboxState } from "src/types"; +import { ValidationError } from "../validation/types"; +import { validateIsCheckboxState } from "../validation/validators"; +import { BaseSettingComponent } from "./BaseSettingComponent"; + + +export class UnknownPolicySettingComponent extends BaseSettingComponent { + private dropdownComponent: DropdownComponent; + + constructor() { + super(); + } + + getSettingKey(): keyof CheckboxSyncPluginSettings { + return 'unknownSymbolPolicy'; + } + + render(container: HTMLElement, currentValue: any): void { + this.setting = new Setting(container) + .setName('Unknown Symbol Policy') + .setDesc('How to treat symbols not in Checked, Unchecked or Ignore lists.') + .addDropdown(dropdown => { + this.dropdownComponent = dropdown; + dropdown + .addOption(CheckboxState.Checked, 'Treat as Checked') + .addOption(CheckboxState.Unchecked, 'Treat as Unchecked') + .addOption(CheckboxState.Ignore, 'Ignore') + .setValue(currentValue as string) // Dropdown работает со строками + .onChange(this.onChangeCallback); + }); + } + + getValueFromUi(): CheckboxState { + if (this.dropdownComponent) { + // Значение из dropdown - строка, кастуем к CheckboxState + return this.dropdownComponent.getValue() as CheckboxState; + } + throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`); + } + + setValueInUi(value: any): void { + if (this.dropdownComponent) { + this.dropdownComponent.setValue(value as string); + } else { + throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`); + } + } + + validate(): ValidationError | null { + let valueFromUi: CheckboxState; + try { + valueFromUi = this.getValueFromUi(); + } catch (error) { + return { + field: this.getSettingKey(), + message: error instanceof Error ? error.message : String(error) + }; + } + + // Вызываем внешний валидатор + const validationResult = validateIsCheckboxState(valueFromUi); + + if (validationResult) { + return { + field: this.getSettingKey(), + message: validationResult.message + }; + } + return null; + } +} \ No newline at end of file diff --git a/src/ui/validation/validators.ts b/src/ui/validation/validators.ts index 2257793..3b954e1 100644 --- a/src/ui/validation/validators.ts +++ b/src/ui/validation/validators.ts @@ -1,5 +1,7 @@ // src/settings/validation/validators.ts +import { CheckboxState } from "src/types"; + /** * Проверяет, является ли предоставленное значение булевым типом (boolean). * Эта функция не зависит от контекста Obsidian или конкретного компонента. @@ -9,41 +11,85 @@ */ export function validateValueIsBoolean(value: any): { message: string } | null { if (typeof value !== 'boolean') { - return { message: 'Value must be a boolean.' }; + return { message: 'Value must be a boolean.' }; } return null; } -// --- Сюда в будущем можно будет добавлять другие чистые функции валидации --- +/** + * Проверяет, является ли значение допустимым состоянием CheckboxState. + * @param value Значение для проверки. + * @returns Объект с сообщением об ошибке, если значение недопустимо, иначе null. + */ +export function validateIsCheckboxState(value: any): { message: string } | null { + // Получаем все возможные значения из enum CheckboxState + const validStates = Object.values(CheckboxState); + if (validStates.includes(value as CheckboxState)) { + return null; // Значение допустимо + } else { + return { message: `Invalid checkbox state selected. Must be one of: ${validStates.join(', ')}.` }; + } +} -/* -// Пример для будущей валидации JSON-массива строк -export function validateJsonStringArray(rawJson: string): { message: string } | null { +/** + * Проверяет, является ли строка валидным JSON-массивом строк, + * где каждая строка состоит ровно из одного символа. + * Также проверяет на уникальность символов. + * @param rawJson Строка для проверки. + * @returns Объект с сообщением об ошибке, если невалидно, иначе null. + */ +export function validateJsonStringArray(rawJson: string | null | undefined): { message: string } | null { + if (rawJson == null || typeof rawJson !== 'string') { + return { message: "Input must be a string." }; // Или можно вернуть null, если пустая строка допустима как пустой массив? Зависит от требований. + } + const trimmedValue = rawJson.trim(); + // Считаем пустую строку валидным представлением пустого массива [] + if (trimmedValue === '' || trimmedValue === '[]') { + return null; + } + + let parsed: any; try { - const parsed = JSON.parse(rawJson); - if (!Array.isArray(parsed)) { - return { message: 'Input must be a valid JSON array (e.g., ["x", " "]).' }; - } - for (let i = 0; i < parsed.length; i++) { - const element = parsed[i]; - if (typeof element !== 'string') { - return { message: `Array element at index ${i} is not a string.` }; - } - if (element.length !== 1) { - return { message: `Element "${element}" at index ${i} must be a single character.` }; - } - } - return null; // Все проверки пройдены + parsed = JSON.parse(trimmedValue); } catch (e: any) { - return { message: `Invalid JSON format: ${e.message}` }; + return { message: `Invalid JSON format: ${e.message}` }; } + + if (!Array.isArray(parsed)) { + return { message: "Invalid format: Input must be a valid JSON array (e.g., [\"x\", \" \"])." }; + } + + const symbols = new Set(); + for (let i = 0; i < parsed.length; i++) { + const element = parsed[i]; + if (typeof element !== 'string') { + return { message: `Invalid content: Array element at index ${i} is not a string.` }; + } + if (element.length !== 1) { + return { message: `Invalid content: Element "${element}" at index ${i} must be a single character.` }; + } + if (symbols.has(element)) { + return { message: `Invalid content: Duplicate symbol "${element}" found.` }; + } + symbols.add(element); + } + + return null; // Все проверки пройдены } -// Пример для проверки, что массив не пустой -export function validateNotEmptyArray(arr: any[]): { message: string } | null { - if (!Array.isArray(arr) || arr.length === 0) { - return { message: 'The list cannot be empty.' }; + +/** +* Проверяет, является ли предоставленный массив непустым. +* @param arr Массив для проверки. +* @returns Объект с сообщением об ошибке, если массив пуст или не является массивом, иначе null. +*/ +export function validateNotEmptyArray(arr: any): { message: string } | null { + // Добавим проверку, что это действительно массив + if (!Array.isArray(arr)) { + return { message: 'Value must be an array.' }; // Ошибка типа данных + } + if (arr.length === 0) { + return { message: 'The list cannot be empty.' }; } return null; -} -*/ \ No newline at end of file +} \ No newline at end of file From eb5e52ca1f5d6f5578f675e98bfd9c1307046b83 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 05:51:26 +0300 Subject: [PATCH 08/14] refactor(settings): Implement cross-field validation using SettingsValidator --- .../validation/SettingsValidator.test.ts | 78 +++++++++++++++ src/ui/CheckboxSyncPluginSettingTab.ts | 95 +++---------------- src/ui/validation/SettingsValidator.ts | 70 ++++++++++---- 3 files changed, 145 insertions(+), 98 deletions(-) create mode 100644 __tests__/ui/components/validation/SettingsValidator.test.ts diff --git a/__tests__/ui/components/validation/SettingsValidator.test.ts b/__tests__/ui/components/validation/SettingsValidator.test.ts new file mode 100644 index 0000000..754a154 --- /dev/null +++ b/__tests__/ui/components/validation/SettingsValidator.test.ts @@ -0,0 +1,78 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { SettingsValidator } from "src/ui/validation/SettingsValidator"; + +describe('SettingsValidator', () => { + let validator: SettingsValidator; + + beforeEach(() => { + validator = new SettingsValidator(); + }); + + // Хелпер для создания Partial настроек + const createSettings = (checked: string[], unchecked: string[], ignore: string[]): Partial => ({ + checkedSymbols: checked, + uncheckedSymbols: unchecked, + ignoreSymbols: ignore, + }); + + test('should return no errors for valid lists with no intersections', () => { + const settings = createSettings(['x'], [' '], ['-']); + const errors = validator.validate(settings); + expect(errors).toEqual([]); + }); + + test('should return no errors for empty ignore list', () => { + const settings = createSettings(['x'], [' '], []); + const errors = validator.validate(settings); + expect(errors).toEqual([]); + }); + + test('should return no errors when lists are missing (treated as empty)', () => { + const settings: Partial = { checkedSymbols: ['x'] }; // unchecked и ignore отсутствуют + const errors = validator.validate(settings); + expect(errors).toEqual([]); + }); + + + test('should return error for intersection between checked and unchecked', () => { + const settings = createSettings(['x', 'a'], [' ', 'a'], ['-']); + const errors = validator.validate(settings); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Checked and Unchecked'); + expect(errors[0].message).toContain('["a"]'); + }); + + test('should return error for intersection between checked and ignore', () => { + const settings = createSettings(['x', '-'], [' '], ['-', 'o']); + const errors = validator.validate(settings); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Checked and Ignore'); + expect(errors[0].message).toContain('["-"]'); + }); + + test('should return error for intersection between unchecked and ignore', () => { + const settings = createSettings(['x'], [' ', '~'], ['-', '~']); + const errors = validator.validate(settings); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Unchecked and Ignore'); + expect(errors[0].message).toContain('["~"]'); + }); + + test('should return multiple errors for multiple intersections', () => { + const settings = createSettings(['x', 'a'], [' ', 'a'], ['-', 'x']); // a: checked/unchecked, x: checked/ignore + const errors = validator.validate(settings); + expect(errors).toHaveLength(2); + // Проверяем наличие обоих сообщений (порядок может быть разным) + expect(errors.some(e => e.message.includes('Checked and Unchecked') && e.message.includes('["a"]'))).toBe(true); + expect(errors.some(e => e.message.includes('Checked and Ignore') && e.message.includes('["x"]'))).toBe(true); + }); + + test('should return all three errors if one symbol intersects all lists', () => { + const settings = createSettings(['x'], ['x'], ['x']); + const errors = validator.validate(settings); + expect(errors).toHaveLength(3); + expect(errors.some(e => e.message.includes('Checked and Unchecked'))).toBe(true); + expect(errors.some(e => e.message.includes('Checked and Ignore'))).toBe(true); + expect(errors.some(e => e.message.includes('Unchecked and Ignore'))).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 9992062..4919181 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -12,6 +12,7 @@ import { ValidationError } from "./validation/types"; import { CheckedSymbolsSettingComponent } from "./components/CheckedSymbolsSettingComponent"; import { UncheckedSymbolsSettingComponent } from "./components/UncheckedSymbolsSettingComponent"; import { IgnoreSymbolsSettingComponent } from "./components/IgnoreSymbolsSettingComponent"; +import { SettingsValidator } from "./validation/SettingsValidator"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -41,7 +42,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); uncheckedSymbolsComp.setChangeListener(() => this.settingChanged()); - + const unknownPolicyComp = new UnknownPolicySettingComponent(); unknownPolicyComp.setChangeListener(() => this.settingChanged()); @@ -78,68 +79,6 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { ]; } - - /** - * Parses a string expecting a JSON array of single-character strings. - */ - private parseJsonStringArray(value: string): { result: string[], error?: string } { - if (value == null || typeof value !== 'string') { - return { result: [], error: "Input must be a string." }; - } - const trimmedValue = value.trim(); - if (trimmedValue === '') { - return { result: [] }; - } - - let parsed: any; - try { - parsed = JSON.parse(trimmedValue); - } catch (e: any) { - return { result: [], error: `Invalid JSON format: ${e.message}` }; - } - - if (!Array.isArray(parsed)) { - return { result: [], error: "Invalid format: Input must be a valid JSON array (e.g., [\"x\", \" \"] )." }; - } - - const symbols = new Set(); - for (let i = 0; i < parsed.length; i++) { - const element = parsed[i]; - if (typeof element !== 'string') { - return { result: [], error: `Invalid content: Array element at index ${i} is not a string.` }; - } - if (element.length !== 1) { - return { result: [], error: `Invalid content: Element "${element}" at index ${i} must be a single character.` }; - } - symbols.add(element); - } - - return { result: [...symbols] }; // Возвращаем массив уникальных символов - } - - private arrayToJsonString(symbols: string[] | undefined): string { - if (!symbols || symbols.length === 0) { - return '[]'; - } - try { - // Используем форматирование с отступами для лучшей читаемости в TextArea - return JSON.stringify(symbols); - } catch (e) { - // На случай, если в массиве окажется что-то несериализуемое (хотя не должно) - console.error("Error stringifying array to JSON:", e); - return '[]'; - } - } - - private validateSettingsArraysIntersection(checkedSymbols: string[], uncheckedSymbols: string[]): { isValid: boolean; error?: string } { - const intersection = checkedSymbols.filter(symbol => uncheckedSymbols.includes(symbol)); - if (intersection.length > 0) { - const displayIntersection = this.arrayToJsonString(intersection); // Используем JSON для вывода ошибки - return { isValid: false, error: `Validation error: Symbol(s) found in both lists: ${displayIntersection}` }; - } - return { isValid: true }; - } - private settingChanged() { this.isDirty = true; this.applyButton.setDisabled(false).setCta(); @@ -155,7 +94,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { 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); } @@ -351,25 +290,17 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } } - const checkedSymbolsFromComponent = newSettingsData.checkedSymbols || []; - const uncheckedSymbolsFromComp = newSettingsData.uncheckedSymbols || []; - const ignoreSymbolsFromComp = newSettingsData.ignoreSymbols || []; + // --- Перекрестная Валидация + // Выполняем только если НЕТ ошибок от компонентов (индивидуальной валидации/парсинга) + if (errors.length === 0) { + const settingsValidator = new SettingsValidator(); + // Передаем собранные и _провалидированные_ данные + const crossErrors = settingsValidator.validate(newSettingsData); + // Добавляем ошибки перекрестной валидации к общему списку + errors.push(...crossErrors); + } - // 4. Валидируем пересечение -> throw - const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, uncheckedSymbolsFromComp); - if (!intersectionValidation1.isValid) { - throw new Error(`Lists: checked and unchecked. ${intersectionValidation1.error!}`); - } - const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, ignoreSymbolsFromComp); - if (!intersectionValidation2.isValid) { - throw new Error(`Lists: checked and ignore. ${intersectionValidation2.error!}`); - } - const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsFromComp, ignoreSymbolsFromComp); - if (!intersectionValidation3.isValid) { - throw new Error(`Lists: unchecked and ignore. ${intersectionValidation3.error!}`); - } - if (errors.length > 0) { // Формируем сообщение об ошибке const errorMessage = errors.map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); @@ -377,7 +308,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } - // 6. Вызываем сохранение -> await (может кинуть ошибку) + // Вызываем сохранение -> await (может кинуть ошибку) await this.plugin.updateSettings(settings => { Object.assign(settings, newSettingsData); }); diff --git a/src/ui/validation/SettingsValidator.ts b/src/ui/validation/SettingsValidator.ts index e7e9b54..6c2e13c 100644 --- a/src/ui/validation/SettingsValidator.ts +++ b/src/ui/validation/SettingsValidator.ts @@ -17,24 +17,62 @@ export class SettingsValidator { public validate(settingsData: Partial): ValidationError[] { const errors: ValidationError[] = []; - // Пример: Проверка пересечения списков символов (будет реализована позже) - // if (settingsData.checkedSymbols && settingsData.uncheckedSymbols) { - // const intersection = settingsData.checkedSymbols.filter(symbol => settingsData.uncheckedSymbols!.includes(symbol)); - // if (intersection.length > 0) { - // errors.push({ - // // Можно не указывать поле, т.к. ошибка затрагивает несколько - // message: `Symbols found in both Checked and Unchecked lists: ${JSON.stringify(intersection)}` - // }); - // } - // } - // ... другие перекрестные проверки ... + const checked = settingsData.checkedSymbols ?? []; + const unchecked = settingsData.uncheckedSymbols ?? []; + const ignore = settingsData.ignoreSymbols ?? []; + + // --- Проверка пересечений --- + + // Пересечение Checked и Unchecked + const intersection1 = this.findIntersection(checked, unchecked); + if (intersection1.length > 0) { + errors.push({ + message: `Symbols found in both Checked and Unchecked lists: ${this.formatSymbolsForError(intersection1)}` + }); + } + + // Пересечение Checked и Ignore + const intersection2 = this.findIntersection(checked, ignore); + if (intersection2.length > 0) { + errors.push({ + message: `Symbols found in both Checked and Ignore lists: ${this.formatSymbolsForError(intersection2)}` + }); + } + + // Пересечение Unchecked и Ignore + const intersection3 = this.findIntersection(unchecked, ignore); + if (intersection3.length > 0) { + errors.push({ + message: `Symbols found in both Unchecked and Ignore lists: ${this.formatSymbolsForError(intersection3)}` + }); + } - // Пока возвращаем пустой массив, логика будет добавлена на Шаге 6 return errors; } - // Сюда можно добавить статические вспомогательные методы для валидации, если нужно - // public static validateSymbolListsIntersection(listA: string[], listB: string[]): string[] { - // return listA.filter(symbol => listB.includes(symbol)); - // } + /** + * Находит пересечение двух массивов строк. + * @param listA Первый массив. + * @param listB Второй массив. + * @returns Массив строк, присутствующих в обоих списках. + */ + private findIntersection(listA: string[], listB: string[]): string[] { + if (!listA || !listB) return []; // Защита от null/undefined + const setB = new Set(listB); + return listA.filter(symbol => setB.has(symbol)); + } + + /** + * Форматирует массив символов для вывода в сообщении об ошибке (как JSON). + * @param symbols Массив символов. + * @returns Строка JSON. + */ + private formatSymbolsForError(symbols: string[]): string { + try { + return JSON.stringify(symbols); + } catch (e) { + // На случай очень странных ошибок + return symbols.join(', '); + } + } } \ No newline at end of file From eef7023804049592a2501195f11c3e41ac28d4c7 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 05:56:30 +0300 Subject: [PATCH 09/14] refactor(settings): cleanup of CheckboxSyncPluginSettingTab --- src/ui/CheckboxSyncPluginSettingTab.ts | 27 +++++++------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 4919181..83565e7 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -35,7 +35,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { // Метод для создания и конфигурации групп и компонентов private initializeSettingGroups(): void { - // 1. Создаем экземпляры наших компонентов + // Создаем экземпляры наших компонентов const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); checkedSymbolsComp.setChangeListener(() => this.settingChanged()); @@ -50,9 +50,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { ignoreSymbolsComp.setChangeListener(() => this.settingChanged()); const symbolGroup = new SettingGroup( - // Используем старый заголовок/описание "Checkbox Symbol Configuration (Advanced: JSON)", - [checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp], // Пока только один компонент + [checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp], "Warning: Requires valid JSON format. Use double quotes for strings." ); @@ -66,16 +65,14 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { automaticFileSyncToggleComp.setChangeListener(() => this.settingChanged()); const behaviorGroup = new SettingGroup( - "Synchronization Behavior", // Заголовок группы - [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] // Массив компонентов для этой группы - // Можно добавить описание группы третьим аргументом, если нужно + "Synchronization Behavior", + [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] ); - // 3. Сохраняем созданную группу (или группы) в поле класса + // Сохраняем созданную группу (или группы) в поле класса this.settingGroups = [ symbolGroup, behaviorGroup - // Сюда позже добавятся другие группы (например, для символов) ]; } @@ -171,7 +168,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { * Resets the settings UI elements to reflect the currently saved plugin settings. */ private resetInputsToSavedSettings() { - const settings = this.plugin.settings; // Получаем текущие сохраненные настройки + const settings = this.plugin.settings; for (const group of this.settingGroups) { for (const component of group.components) { @@ -270,7 +267,6 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации let newSettingsData: Partial = {}; // Объект для новых данных - for (const group of this.settingGroups) { for (const component of group.components) { const key = component.getSettingKey(); @@ -278,9 +274,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { const value = component.getValueFromUi(); const validationError = component.validate(); if (validationError) { - errors.push(validationError); // Добавляем ошибку + errors.push(validationError); } else { - // Если индивидуальная валидация прошла, сохраняем значение newSettingsData[key] = value; } } catch (err: any) { @@ -290,25 +285,17 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } } - // --- Перекрестная Валидация - // Выполняем только если НЕТ ошибок от компонентов (индивидуальной валидации/парсинга) if (errors.length === 0) { const settingsValidator = new SettingsValidator(); - // Передаем собранные и _провалидированные_ данные const crossErrors = settingsValidator.validate(newSettingsData); - // Добавляем ошибки перекрестной валидации к общему списку errors.push(...crossErrors); } - if (errors.length > 0) { - // Формируем сообщение об ошибке const errorMessage = errors.map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); throw new Error(errorMessage); } - - // Вызываем сохранение -> await (может кинуть ошибку) await this.plugin.updateSettings(settings => { Object.assign(settings, newSettingsData); }); From b025aabea71d5a60f5a9bbb1d62be75f4330af39 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 06:23:41 +0300 Subject: [PATCH 10/14] refactor(settings): Return validation results instead of throwing errors --- src/ui/CheckboxSyncPluginSettingTab.ts | 41 +++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 83565e7..54929e7 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -147,17 +147,24 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { await this.actionMutex.runExclusive(async () => { const originalButtonText = "Apply Changes"; this.applyButton.setDisabled(true).setButtonText("Applying...").removeCta(); - + this.errorDisplayEl.setText(''); try { - await this.validateAndSaveSettings(); - this.settingSaved(); - this.errorDisplayEl.setText(''); - new Notice("Checkbox Sync settings applied!", 3000); + const result = await this.validateAndSaveSettings(); + if (result.success) { + this.settingSaved(); + new Notice("Checkbox Sync settings applied!", 3000); + } else { + console.warn("Validation errors:", result.errors); + const errorMessage = result.errors + .map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`) + .join('\n'); + this.errorDisplayEl.setText(errorMessage); + } } catch (error: any) { - console.error("Error applying checkbox sync settings:", error); - // Выводим ошибку в специальный div - this.errorDisplayEl.setText(`❌ ${error.message || "An unknown error occurred."}`); + console.error("Unexpected error during applyChanges:", error); + this.errorDisplayEl.setText(`❌ An unexpected error occurred: ${error.message || "Unknown error"}`); + this.applyButton?.setDisabled(false).setCta(); } finally { this.applyButton.setButtonText(originalButtonText); // Восстанавливаем текст } @@ -262,7 +269,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { * Throws an error if validation or saving fails. * Does NOT interact with UI feedback (buttons, notices, error display). */ - private async validateAndSaveSettings(): Promise { + private async validateAndSaveSettings(): Promise<{ success: boolean; errors: ValidationError[] }> { let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации let newSettingsData: Partial = {}; // Объект для новых данных @@ -292,12 +299,18 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } if (errors.length > 0) { - const errorMessage = errors.map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); - throw new Error(errorMessage); + return { success: false, errors: errors }; } - await this.plugin.updateSettings(settings => { - Object.assign(settings, newSettingsData); - }); + 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; + } } } \ No newline at end of file From 6014663ac0211a8dc382d2b51cc84e321c604a66 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 07:11:29 +0300 Subject: [PATCH 11/14] refactor(settings): Encapsulate error display logic in ErrorDisplay class --- src/ui/CheckboxSyncPluginSettingTab.ts | 41 +++++++--------- src/ui/ErrorDisplay.ts | 67 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 src/ui/ErrorDisplay.ts diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 54929e7..e19b2b1 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -1,18 +1,19 @@ import { Mutex } from "async-mutex"; -import { App, ButtonComponent, Notice, PluginSettingTab, Setting, TextComponent } from "obsidian"; +import { App, ButtonComponent, Notice, PluginSettingTab, Setting } from "obsidian"; import CheckboxSyncPlugin from "../main"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types"; +import { ErrorDisplay } from "./ErrorDisplay"; import { SettingGroup } from "./SettingGroup"; +import { CheckedSymbolsSettingComponent } from "./components/CheckedSymbolsSettingComponent"; import { EnableChildSyncSettingComponent } from "./components/EnableChildSyncSettingComponent"; import { EnableFileSyncSettingComponent } from "./components/EnableFileSyncSettingComponent"; import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; +import { IgnoreSymbolsSettingComponent } from "./components/IgnoreSymbolsSettingComponent"; +import { UncheckedSymbolsSettingComponent } from "./components/UncheckedSymbolsSettingComponent"; import { UnknownPolicySettingComponent } from "./components/UnknownPolicySettingComponent"; import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; -import { ValidationError } from "./validation/types"; -import { CheckedSymbolsSettingComponent } from "./components/CheckedSymbolsSettingComponent"; -import { UncheckedSymbolsSettingComponent } from "./components/UncheckedSymbolsSettingComponent"; -import { IgnoreSymbolsSettingComponent } from "./components/IgnoreSymbolsSettingComponent"; import { SettingsValidator } from "./validation/SettingsValidator"; +import { ValidationError } from "./validation/types"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; @@ -22,7 +23,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { private applyButton: ButtonComponent; private resetToDefaultButton: ButtonComponent; - private errorDisplayEl: HTMLElement; + private errorDisplay: ErrorDisplay; private isDirty: boolean = false; private actionMutex = new Mutex(); @@ -97,14 +98,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } // --- Область для вывода ошибок --- - this.errorDisplayEl = containerEl.createDiv({ cls: 'checkbox-sync-settings-error' }); - // Используем стандартные CSS переменные Obsidian для цвета ошибки - this.errorDisplayEl.style.color = 'var(--text-error)'; - this.errorDisplayEl.style.marginTop = '10px'; - this.errorDisplayEl.style.marginBottom = '10px'; - this.errorDisplayEl.style.minHeight = '1.5em'; // Резервируем место - this.errorDisplayEl.style.whiteSpace = 'pre-wrap'; // Для переноса длинных ошибок - this.errorDisplayEl.style.userSelect = 'text'; // или 'all' + this.errorDisplay = new ErrorDisplay(containerEl); // Используем Setting для группировки кнопок const buttonGroup = new Setting(containerEl) @@ -147,7 +141,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { await this.actionMutex.runExclusive(async () => { const originalButtonText = "Apply Changes"; this.applyButton.setDisabled(true).setButtonText("Applying...").removeCta(); - this.errorDisplayEl.setText(''); + this.errorDisplay.clear(); try { const result = await this.validateAndSaveSettings(); @@ -156,15 +150,13 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { new Notice("Checkbox Sync settings applied!", 3000); } else { console.warn("Validation errors:", result.errors); - const errorMessage = result.errors - .map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`) - .join('\n'); - this.errorDisplayEl.setText(errorMessage); + this.errorDisplay.displayErrors(result.errors); } } catch (error: any) { console.error("Unexpected error during applyChanges:", error); - this.errorDisplayEl.setText(`❌ An unexpected error occurred: ${error.message || "Unknown error"}`); - this.applyButton?.setDisabled(false).setCta(); + const message = error.message || "Unknown error"; + this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); + this.applyButton.setDisabled(false).setCta(); } finally { this.applyButton.setButtonText(originalButtonText); // Восстанавливаем текст } @@ -191,7 +183,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } // Очищаем ошибку и сбрасываем состояние "грязный" - this.errorDisplayEl?.setText(''); + this.errorDisplay.clear(); this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply } @@ -202,7 +194,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { await this.actionMutex.runExclusive(async () => { this.resetToDefaultButton.setDisabled(true).setButtonText("Resetting..."); - this.errorDisplayEl.setText(''); // Очищаем предыдущие ошибки + this.errorDisplay.clear();// Очищаем предыдущие ошибки try { const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией @@ -219,7 +211,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } catch (error: any) { console.error("Error resetting settings to default:", error); - this.errorDisplayEl.setText(`❌ Error resetting to defaults: ${error.message}`); + const message = error.message || "Unknown error"; + this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`); } finally { // Восстанавливаем кнопку Reset this.resetToDefaultButton.setDisabled(false).setButtonText("Reset to defaults"); diff --git a/src/ui/ErrorDisplay.ts b/src/ui/ErrorDisplay.ts new file mode 100644 index 0000000..219a14f --- /dev/null +++ b/src/ui/ErrorDisplay.ts @@ -0,0 +1,67 @@ +import { ValidationError } from "./validation/types"; + +/** + * Отвечает за создание, стилизацию и обновление + * элемента для отображения ошибок валидации настроек. + */ +export class ErrorDisplay { + private errorElement: HTMLElement; + + /** + * Создает и стилизует элемент для отображения ошибок внутри контейнера. + * @param container - Родительский HTML-элемент. + */ + constructor(container: HTMLElement) { + this.errorElement = container.createDiv({ cls: 'checkbox-sync-settings-error' }); + this.applyStyles(); + } + + /** + * Применяет стили к элементу ошибок. + */ + private applyStyles(): void { + // Используем стандартные CSS переменные Obsidian для цвета ошибки + this.errorElement.style.color = 'var(--text-error)'; + this.errorElement.style.marginTop = '10px'; + this.errorElement.style.marginBottom = '10px'; + this.errorElement.style.minHeight = '1.5em'; // Резервируем место + this.errorElement.style.whiteSpace = 'pre-wrap'; // Для переноса длинных ошибок + this.errorElement.style.userSelect = 'text'; // Позволяет выделять текст ошибки + } + + /** + * Отображает список ошибок валидации. + * @param errors - Массив объектов ValidationError. + */ + public displayErrors(errors: ValidationError[]): void { + if (errors && errors.length > 0) { + const errorMessage = errors + .map(e => `❌ ${e.field ? `[${e.field}]: ` : ''}${e.message}`) + .join('\n'); + this.errorElement.setText(errorMessage); // Используем setText для безопасности + } else { + this.clear(); // Если массив пуст или null/undefined, очищаем + } + } + + /** + * Отображает одно общее сообщение об ошибке. + * @param message - Строка с сообщением. + */ + public displayMessage(message: string): void { + if (message) { + // Добавляем значок ошибки для консистентности + this.errorElement.setText(`❌ ${message}`); + } else { + this.clear(); + } + } + + + /** + * Очищает элемент отображения ошибок. + */ + public clear(): void { + this.errorElement.setText(''); + } +} \ No newline at end of file From ae8740b3f954cdfbaa1b421cc679bb826d6e6e74 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 07:57:53 +0300 Subject: [PATCH 12/14] refactor(settings): Encapsulate button logic in SettingsControls class --- src/ui/CheckboxSyncPluginSettingTab.ts | 78 +++++++--------- src/ui/SettingsControls.ts | 122 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 src/ui/SettingsControls.ts diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index e19b2b1..5311d8b 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -14,17 +14,17 @@ import { UnknownPolicySettingComponent } from "./components/UnknownPolicySetting import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { SettingsValidator } from "./validation/SettingsValidator"; import { ValidationError } from "./validation/types"; +import { ISettingsControlActions, SettingsControls } from "./SettingsControls"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; private settingGroups: SettingGroup[] = [] - private applyButton: ButtonComponent; - private resetToDefaultButton: ButtonComponent; - private errorDisplay: ErrorDisplay; + private settingsControls: SettingsControls; + private isDirty: boolean = false; private actionMutex = new Mutex(); @@ -79,12 +79,12 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { private settingChanged() { this.isDirty = true; - this.applyButton.setDisabled(false).setCta(); + this.settingsControls.setApplyState({ disabled: false, cta: true }); } private settingSaved() { this.isDirty = false; - this.applyButton.setDisabled(true).removeCta(); + this.settingsControls.setApplyState({ disabled: true, cta: false }); } display(): void { @@ -100,47 +100,33 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { // --- Область для вывода ошибок --- this.errorDisplay = new ErrorDisplay(containerEl); - // Используем Setting для группировки кнопок - const buttonGroup = new Setting(containerEl) - .setClass('checkbox-sync-button-group'); // Добавим класс для возможной стилизации + // Создаем экземпляр 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 }); - buttonGroup.addButton(button => { - button - .setButtonText("Reset changes") - .setTooltip("Revert changes to the last applied settings") - .onClick(() => { - this.resetInputsToSavedSettings(); - }); - }); - - buttonGroup.addButton(button => { - this.resetToDefaultButton = button; - button - .setButtonText("Reset to defaults") - .setTooltip("Reset all settings to default values and apply immediately") - .onClick(() => { - new ConfirmModal(this.app, - "Reset all settings to default and apply immediately?\nThis cannot be undone.", // Добавил \n для переноса - async () => { await this.applyDefaultSettings(); } - ).open(); - }); - }); - // --- Кнопка Применить --- - buttonGroup - .addButton(button => { - this.applyButton = button; - button - .setButtonText("Apply Changes") - .setDisabled(!this.isDirty) - .onClick(async () => await this.applyChanges()) - }); + // Начальное состояние кнопки Reset Defaults (всегда включена) + this.settingsControls.setResetDefaultsState({ disabled: false }); } private async applyChanges() { await this.actionMutex.runExclusive(async () => { - const originalButtonText = "Apply Changes"; - this.applyButton.setDisabled(true).setButtonText("Applying...").removeCta(); + this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false }); this.errorDisplay.clear(); try { @@ -156,9 +142,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { console.error("Unexpected error during applyChanges:", error); const message = error.message || "Unknown error"; this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); - this.applyButton.setDisabled(false).setCta(); } finally { - this.applyButton.setButtonText(originalButtonText); // Восстанавливаем текст + this.settingsControls.setApplyState({ disabled: false, cta: true }) } }); } @@ -192,9 +177,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { */ private async applyDefaultSettings() { await this.actionMutex.runExclusive(async () => { - this.resetToDefaultButton.setDisabled(true).setButtonText("Resetting..."); - - this.errorDisplay.clear();// Очищаем предыдущие ошибки + this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' }); + this.errorDisplay.clear(); try { const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией @@ -212,10 +196,10 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { } 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}`); + this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`); } finally { // Восстанавливаем кнопку Reset - this.resetToDefaultButton.setDisabled(false).setButtonText("Reset to defaults"); + this.settingsControls.setResetDefaultsState({ disabled: false }); } }); } diff --git a/src/ui/SettingsControls.ts b/src/ui/SettingsControls.ts new file mode 100644 index 0000000..43b2984 --- /dev/null +++ b/src/ui/SettingsControls.ts @@ -0,0 +1,122 @@ +import { ButtonComponent, Setting } from 'obsidian'; + +/** + * Интерфейс для действий, которые должны выполнять кнопки управления. + */ +export interface ISettingsControlActions { + onApply: () => Promise | void; + onReset: () => Promise | void; + onResetDefaults: () => Promise | void; +} + +/** + * Параметры для обновления состояния кнопки Apply. + */ +export interface ApplyButtonStateOptions { + disabled: boolean; + cta?: boolean; // Является ли кнопка основной (call to action) + text?: string; // Опциональный текст кнопки +} + +/** + * Параметры для обновления состояния кнопки Reset Defaults. + */ +export interface ResetDefaultsButtonStateOptions { + disabled: boolean; + text?: string; // Опциональный текст кнопки +} + + +/** + * Отвечает за создание и управление кнопками + * "Apply Changes", "Reset changes", "Reset to defaults" на вкладке настроек. + */ +export class SettingsControls { + private applyButton: ButtonComponent; + private resetButton: ButtonComponent; // Кнопка "Reset changes" + private resetDefaultsButton: ButtonComponent; + + private readonly defaultApplyText = "Apply Changes"; + private readonly defaultResetDefaultsText = "Reset to defaults"; + + constructor(container: HTMLElement, private actions: ISettingsControlActions) { + const buttonGroup = new Setting(container) + .setClass('checkbox-sync-button-group'); + + // Кнопка "Reset changes" + buttonGroup.addButton(button => { + this.resetButton = button; // Сохраняем ссылку (хотя она пока не используется) + button + .setButtonText("Reset changes") + .setTooltip("Revert changes to the last applied settings") + .onClick(async () => { + // Просто вызываем переданный колбэк + await this.actions.onReset(); + }); + }); + + // Кнопка "Reset to defaults" + buttonGroup.addButton(button => { + this.resetDefaultsButton = button; + button + .setButtonText(this.defaultResetDefaultsText) + .setTooltip("Reset all settings to default values and apply immediately") + .onClick(async () => { + // Вызываем асинхронный колбэк (который внутри покажет ConfirmModal) + await this.actions.onResetDefaults(); + }); + }); + + // Кнопка "Apply Changes" (последняя для акцента) + buttonGroup.addButton(button => { + this.applyButton = button; + button + .setButtonText(this.defaultApplyText) + .setDisabled(true) // По умолчанию выключена + .onClick(async () => { + // Вызываем асинхронный колбэк + await this.actions.onApply(); + }); + }); + } + + /** + * Обновляет состояние кнопки "Apply Changes". + * @param options Параметры состояния. + */ + public setApplyState(options: ApplyButtonStateOptions): void { + if (!this.applyButton) return; + + this.applyButton.setDisabled(options.disabled); + + if (options.text !== undefined) { + this.applyButton.setButtonText(options.text); + } else { + // Возвращаем текст по умолчанию, если новый не задан + this.applyButton.setButtonText(this.defaultApplyText); + } + + if (options.cta === true && !options.disabled) { + this.applyButton.setCta(); + } else { + this.applyButton.removeCta(); + } + } + + /** + * Обновляет состояние кнопки "Reset to defaults". + * @param options Параметры состояния. + */ + public setResetDefaultsState(options: ResetDefaultsButtonStateOptions): void { + if (!this.resetDefaultsButton) return; + + this.resetDefaultsButton.setDisabled(options.disabled); + + if (options.text !== undefined) { + this.resetDefaultsButton.setButtonText(options.text); + } else { + // Возвращаем текст по умолчанию + this.resetDefaultsButton.setButtonText(this.defaultResetDefaultsText); + } + } +} \ No newline at end of file From 89f3b56871bbf2976dbc4990f1714811bd3de407 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 08:19:59 +0300 Subject: [PATCH 13/14] refactor(settings): Improve tab UI handling and listeners --- src/ui/CheckboxSyncPluginSettingTab.ts | 40 ++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 5311d8b..df0595b 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -39,16 +39,9 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { // Создаем экземпляры наших компонентов const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); - checkedSymbolsComp.setChangeListener(() => this.settingChanged()); - const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); - uncheckedSymbolsComp.setChangeListener(() => this.settingChanged()); - const unknownPolicyComp = new UnknownPolicySettingComponent(); - unknownPolicyComp.setChangeListener(() => this.settingChanged()); - const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent(); - ignoreSymbolsComp.setChangeListener(() => this.settingChanged()); const symbolGroup = new SettingGroup( "Checkbox Symbol Configuration (Advanced: JSON)", @@ -57,13 +50,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { ); const parentToggleComp = new EnableParentSyncSettingComponent(); - parentToggleComp.setChangeListener(() => this.settingChanged()); - const childrenToggleComp = new EnableChildSyncSettingComponent(); - childrenToggleComp.setChangeListener(() => this.settingChanged()); - const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent(); - automaticFileSyncToggleComp.setChangeListener(() => this.settingChanged()); const behaviorGroup = new SettingGroup( "Synchronization Behavior", @@ -75,6 +63,14 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { symbolGroup, behaviorGroup ]; + + const changeListener = () => this.settingChanged(); + for (const group of this.settingGroups) { + for (const component of group.components) { + // Устанавливаем общий listener для всех компонентов + component.setChangeListener(changeListener); + } + } } private settingChanged() { @@ -143,7 +139,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { const message = error.message || "Unknown error"; this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); } finally { - this.settingsControls.setApplyState({ disabled: false, cta: true }) + this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }) } }); } @@ -212,18 +208,26 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { // Колбэк для кнопки "Save" const saveCallback = async () => { try { - await this.validateAndSaveSettings(); - this.isDirty = false; // Сбрасываем флаг - new Notice("Settings saved.", 2000); + 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:", error); + // Ловим НЕОЖИДАННЫЕ ошибки (например, ошибка сохранения) + 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(); } }; From 6756a4a8a71182de18d6d1b65e9a61b07008475c Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Sun, 27 Apr 2025 08:27:25 +0300 Subject: [PATCH 14/14] moving files --- src/ui/CheckboxSyncPluginSettingTab.ts | 18 +++++++++--------- .../BaseSettingComponent.ts | 2 +- .../BaseTextArraySettingComponent.ts | 0 .../CheckedSymbolsSettingComponent.ts | 6 +++--- .../IgnoreSymbolsSettingComponent.ts | 2 +- .../UncheckedSymbolsSettingComponent.ts | 6 +++--- .../UnknownPolicySettingComponent.ts | 6 +++--- .../EnableChildSyncSettingComponent.ts | 6 +++--- .../EnableFileSyncSettingComponent.ts | 8 ++++---- .../EnableParentSyncSettingComponent.ts | 8 ++++---- 10 files changed, 31 insertions(+), 31 deletions(-) rename src/ui/{components => basedClasses}/BaseSettingComponent.ts (94%) rename src/ui/{components => basedClasses}/BaseTextArraySettingComponent.ts (100%) rename src/ui/components/{ => checkboxSymbolConfiguration}/CheckedSymbolsSettingComponent.ts (93%) rename src/ui/components/{ => checkboxSymbolConfiguration}/IgnoreSymbolsSettingComponent.ts (93%) rename src/ui/components/{ => checkboxSymbolConfiguration}/UncheckedSymbolsSettingComponent.ts (90%) rename src/ui/components/{ => checkboxSymbolConfiguration}/UnknownPolicySettingComponent.ts (91%) rename src/ui/components/{ => synchronizationBehavior}/EnableChildSyncSettingComponent.ts (89%) rename src/ui/components/{ => synchronizationBehavior}/EnableFileSyncSettingComponent.ts (89%) rename src/ui/components/{ => synchronizationBehavior}/EnableParentSyncSettingComponent.ts (90%) diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index df0595b..b836b59 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -1,20 +1,20 @@ import { Mutex } from "async-mutex"; -import { App, ButtonComponent, Notice, PluginSettingTab, Setting } from "obsidian"; +import { App, Notice, PluginSettingTab } from "obsidian"; import CheckboxSyncPlugin from "../main"; import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types"; import { ErrorDisplay } from "./ErrorDisplay"; import { SettingGroup } from "./SettingGroup"; -import { CheckedSymbolsSettingComponent } from "./components/CheckedSymbolsSettingComponent"; -import { EnableChildSyncSettingComponent } from "./components/EnableChildSyncSettingComponent"; -import { EnableFileSyncSettingComponent } from "./components/EnableFileSyncSettingComponent"; -import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; -import { IgnoreSymbolsSettingComponent } from "./components/IgnoreSymbolsSettingComponent"; -import { UncheckedSymbolsSettingComponent } from "./components/UncheckedSymbolsSettingComponent"; -import { UnknownPolicySettingComponent } from "./components/UnknownPolicySettingComponent"; +import { ISettingsControlActions, SettingsControls } from "./SettingsControls"; +import { CheckedSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent"; +import { IgnoreSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent"; +import { UncheckedSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent"; +import { UnknownPolicySettingComponent } from "./components/checkboxSymbolConfiguration/UnknownPolicySettingComponent"; +import { EnableChildSyncSettingComponent } from "./components/synchronizationBehavior/EnableChildSyncSettingComponent"; +import { EnableFileSyncSettingComponent } from "./components/synchronizationBehavior/EnableFileSyncSettingComponent"; +import { EnableParentSyncSettingComponent } from "./components/synchronizationBehavior/EnableParentSyncSettingComponent"; import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { SettingsValidator } from "./validation/SettingsValidator"; import { ValidationError } from "./validation/types"; -import { ISettingsControlActions, SettingsControls } from "./SettingsControls"; export class CheckboxSyncPluginSettingTab extends PluginSettingTab { plugin: CheckboxSyncPlugin; diff --git a/src/ui/components/BaseSettingComponent.ts b/src/ui/basedClasses/BaseSettingComponent.ts similarity index 94% rename from src/ui/components/BaseSettingComponent.ts rename to src/ui/basedClasses/BaseSettingComponent.ts index 85de0bf..bdeed80 100644 --- a/src/ui/components/BaseSettingComponent.ts +++ b/src/ui/basedClasses/BaseSettingComponent.ts @@ -1,5 +1,5 @@ import { Setting } from 'obsidian'; -import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../../types'; +import { CheckboxSyncPluginSettings } from '../../types'; import { ISettingComponent } from '../interfaces/ISettingComponent'; import { ValidationError } from '../validation/types'; // Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам diff --git a/src/ui/components/BaseTextArraySettingComponent.ts b/src/ui/basedClasses/BaseTextArraySettingComponent.ts similarity index 100% rename from src/ui/components/BaseTextArraySettingComponent.ts rename to src/ui/basedClasses/BaseTextArraySettingComponent.ts diff --git a/src/ui/components/CheckedSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts similarity index 93% rename from src/ui/components/CheckedSymbolsSettingComponent.ts rename to src/ui/components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts index 661e054..e4bb3db 100644 --- a/src/ui/components/CheckedSymbolsSettingComponent.ts +++ b/src/ui/components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts @@ -1,8 +1,8 @@ import { Setting } from "obsidian"; import { CheckboxSyncPluginSettings } from "src/types"; -import { ValidationError } from "../validation/types"; -import { validateNotEmptyArray } from "../validation/validators"; -import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; +import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateNotEmptyArray } from "src/ui/validation/validators"; // ИЗМЕНЕНИЕ: Наследуемся от BaseTextArraySettingComponent export class CheckedSymbolsSettingComponent extends BaseTextArraySettingComponent { diff --git a/src/ui/components/IgnoreSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts similarity index 93% rename from src/ui/components/IgnoreSymbolsSettingComponent.ts rename to src/ui/components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts index d2b6919..504efd6 100644 --- a/src/ui/components/IgnoreSymbolsSettingComponent.ts +++ b/src/ui/components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts @@ -1,6 +1,6 @@ import { Setting } from "obsidian"; import { CheckboxSyncPluginSettings } from "src/types"; -import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; +import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent"; export class IgnoreSymbolsSettingComponent extends BaseTextArraySettingComponent { diff --git a/src/ui/components/UncheckedSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts similarity index 90% rename from src/ui/components/UncheckedSymbolsSettingComponent.ts rename to src/ui/components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts index 652f5a8..ba79833 100644 --- a/src/ui/components/UncheckedSymbolsSettingComponent.ts +++ b/src/ui/components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts @@ -1,8 +1,8 @@ import { Setting } from "obsidian"; import { CheckboxSyncPluginSettings } from "src/types"; -import { ValidationError } from "../validation/types"; -import { validateNotEmptyArray } from "../validation/validators"; -import { BaseTextArraySettingComponent } from "./BaseTextArraySettingComponent"; +import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateNotEmptyArray } from "src/ui/validation/validators"; export class UncheckedSymbolsSettingComponent extends BaseTextArraySettingComponent { diff --git a/src/ui/components/UnknownPolicySettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts similarity index 91% rename from src/ui/components/UnknownPolicySettingComponent.ts rename to src/ui/components/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts index f8455cf..2c46a4e 100644 --- a/src/ui/components/UnknownPolicySettingComponent.ts +++ b/src/ui/components/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts @@ -1,8 +1,8 @@ import { DropdownComponent, Setting } from "obsidian"; import { CheckboxSyncPluginSettings, CheckboxState } from "src/types"; -import { ValidationError } from "../validation/types"; -import { validateIsCheckboxState } from "../validation/validators"; -import { BaseSettingComponent } from "./BaseSettingComponent"; +import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateIsCheckboxState } from "src/ui/validation/validators"; export class UnknownPolicySettingComponent extends BaseSettingComponent { diff --git a/src/ui/components/EnableChildSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts similarity index 89% rename from src/ui/components/EnableChildSyncSettingComponent.ts rename to src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts index cb6b75d..1559146 100644 --- a/src/ui/components/EnableChildSyncSettingComponent.ts +++ b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts @@ -1,8 +1,8 @@ import { ToggleComponent, Setting } from "obsidian"; import { CheckboxSyncPluginSettings } from "src/types"; -import { ValidationError } from "../validation/types"; -import { validateValueIsBoolean } from "../validation/validators"; -import { BaseSettingComponent } from "./BaseSettingComponent"; +import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateValueIsBoolean } from "src/ui/validation/validators"; export class EnableChildSyncSettingComponent extends BaseSettingComponent { diff --git a/src/ui/components/EnableFileSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableFileSyncSettingComponent.ts similarity index 89% rename from src/ui/components/EnableFileSyncSettingComponent.ts rename to src/ui/components/synchronizationBehavior/EnableFileSyncSettingComponent.ts index eb6f79e..5a24fc4 100644 --- a/src/ui/components/EnableFileSyncSettingComponent.ts +++ b/src/ui/components/synchronizationBehavior/EnableFileSyncSettingComponent.ts @@ -1,9 +1,9 @@ // src/settings/components/EnableFileSyncSettingComponent.ts import { Setting, ToggleComponent } from 'obsidian'; -import { CheckboxSyncPluginSettings } from '../../types'; -import { BaseSettingComponent } from './BaseSettingComponent'; -import { ValidationError } from '../validation/types'; -import { validateValueIsBoolean } from '../validation/validators'; // Используем тот же валидатор +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 EnableFileSyncSettingComponent extends BaseSettingComponent { private toggleComponent: ToggleComponent; diff --git a/src/ui/components/EnableParentSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts similarity index 90% rename from src/ui/components/EnableParentSyncSettingComponent.ts rename to src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts index 96e0139..116366f 100644 --- a/src/ui/components/EnableParentSyncSettingComponent.ts +++ b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts @@ -1,8 +1,8 @@ import { Setting, ToggleComponent } from 'obsidian'; -import { CheckboxSyncPluginSettings } from '../../types'; -import { BaseSettingComponent } from './BaseSettingComponent'; -import { ValidationError } from '../validation/types'; -import { validateValueIsBoolean } from '../validation/validators'; +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 EnableParentSyncSettingComponent extends BaseSettingComponent { private toggleComponent: ToggleComponent;