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