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 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/__tests__/ui/components/validation/validators.test.ts b/__tests__/ui/components/validation/validators.test.ts new file mode 100644 index 0000000..a9e1087 --- /dev/null +++ b/__tests__/ui/components/validation/validators.test.ts @@ -0,0 +1,173 @@ +import { CheckboxState } from "src/types"; +import { validateIsCheckboxState, validateJsonStringArray, validateNotEmptyArray, 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('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', () => { + // Валидные случаи + 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/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/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", diff --git a/src/ui/CheckboxSyncPluginSettingTab.ts b/src/ui/CheckboxSyncPluginSettingTab.ts index 5e4e362..b836b59 100644 --- a/src/ui/CheckboxSyncPluginSettingTab.ts +++ b/src/ui/CheckboxSyncPluginSettingTab.ts @@ -1,98 +1,86 @@ -import { App, ButtonComponent, DropdownComponent, Notice, PluginSettingTab, Setting, TextComponent, ToggleComponent } from "obsidian"; -import CheckboxSyncPlugin from "../main"; -import { CheckboxState, DEFAULT_SETTINGS } from "../types"; -import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals"; import { Mutex } from "async-mutex"; +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 { 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"; 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 applyButton: ButtonComponent; - private resetToDefaultButton: ButtonComponent; - private errorDisplayEl: HTMLElement; + + private settingGroups: SettingGroup[] = [] + + private errorDisplay: ErrorDisplay; + + private settingsControls: SettingsControls; + private isDirty: boolean = false; private actionMutex = new Mutex(); constructor(app: App, plugin: CheckboxSyncPlugin) { super(app, plugin); this.plugin = plugin; + this.initializeSettingGroups(); } - /** - * 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: [] }; - } + // Метод для создания и конфигурации групп и компонентов + private initializeSettingGroups(): void { + // Создаем экземпляры наших компонентов - let parsed: any; - try { - parsed = JSON.parse(trimmedValue); - } catch (e: any) { - return { result: [], error: `Invalid JSON format: ${e.message}` }; - } + const checkedSymbolsComp = new CheckedSymbolsSettingComponent(); + const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent(); + const unknownPolicyComp = new UnknownPolicySettingComponent(); + const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent(); - if (!Array.isArray(parsed)) { - return { result: [], error: "Invalid format: Input must be a valid JSON array (e.g., [\"x\", \" \"] )." }; - } + const symbolGroup = new SettingGroup( + "Checkbox Symbol Configuration (Advanced: JSON)", + [checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp], + "Warning: Requires valid JSON format. Use double quotes for strings." + ); - 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.` }; + const parentToggleComp = new EnableParentSyncSettingComponent(); + const childrenToggleComp = new EnableChildSyncSettingComponent(); + const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent(); + + const behaviorGroup = new SettingGroup( + "Synchronization Behavior", + [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] + ); + + // Сохраняем созданную группу (или группы) в поле класса + this.settingGroups = [ + symbolGroup, + behaviorGroup + ]; + + const changeListener = () => this.settingChanged(); + for (const group of this.settingGroups) { + for (const component of group.components) { + // Устанавливаем общий listener для всех компонентов + component.setChangeListener(changeListener); } - 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(); + 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,165 +88,58 @@ 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()) - }); - - - 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()); - }); + for (const group of this.settingGroups) { + group.render(containerEl, this.plugin.settings); + } // --- Область для вывода ошибок --- - 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) - .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 { - 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); + const result = await this.validateAndSaveSettings(); + if (result.success) { + this.settingSaved(); + new Notice("Checkbox Sync settings applied!", 3000); + } else { + console.warn("Validation errors:", result.errors); + this.errorDisplay.displayErrors(result.errors); + } } catch (error: any) { - // --- Ошибка --- - console.error("Error applying checkbox sync settings:", error); - // Выводим ошибку в специальный div - this.errorDisplayEl.setText(`❌ ${error.message || "An unknown error occurred."}`); + console.error("Unexpected error during applyChanges:", error); + const message = error.message || "Unknown error"; + this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`); } finally { - this.applyButton.setButtonText(originalButtonText); // Восстанавливаем текст + this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty }) } }); } @@ -267,19 +148,23 @@ 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; - // Обновляем значения всех полей ввода - 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); + 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.errorDisplayEl?.setText(''); + this.errorDisplay.clear(); this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply } @@ -288,9 +173,8 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { */ private async applyDefaultSettings() { await this.actionMutex.runExclusive(async () => { - this.resetToDefaultButton.setDisabled(true).setButtonText("Resetting..."); - - this.errorDisplayEl.setText(''); // Очищаем предыдущие ошибки + this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' }); + this.errorDisplay.clear(); try { const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией @@ -307,10 +191,11 @@ 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"); + this.settingsControls.setResetDefaultsState({ disabled: false }); } }); } @@ -323,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(); } }; @@ -357,67 +250,48 @@ 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 { - // 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(); + private async validateAndSaveSettings(): Promise<{ success: boolean; errors: ValidationError[] }> { - // 2. Парсим JSON - const parsedChecked = this.parseJsonStringArray(checkedValue); - const parsedUnchecked = this.parseJsonStringArray(uncheckedValue); - const parsedIgnore = this.parseJsonStringArray(ignoreValue); + let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации + let newSettingsData: Partial = {}; // Объект для новых данных - // 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}`); + 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}` }); + } + } } - const checkedSymbolsArray = parsedChecked.result; - const uncheckedSymbolsArray = parsedUnchecked.result; - const ignoreSymbolsArray = parsedIgnore.result; - - // 4. Валидируем пересечение -> throw - const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsArray, uncheckedSymbolsArray); - if (!intersectionValidation1.isValid) { - throw new Error(`Lists: checked and unchecked. ${intersectionValidation1.error!}`); - } - const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsArray, ignoreSymbolsArray); - if (!intersectionValidation2.isValid) { - throw new Error(`Lists: checked and ignore. ${intersectionValidation2.error!}`); - } - const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsArray, ignoreSymbolsArray); - if (!intersectionValidation3.isValid) { - throw new Error(`Lists: unchecked and ignore. ${intersectionValidation3.error!}`); + if (errors.length === 0) { + const settingsValidator = new SettingsValidator(); + const crossErrors = settingsValidator.validate(newSettingsData); + errors.push(...crossErrors); } - // 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."); + if (errors.length > 0) { + return { success: false, errors: errors }; } - // 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; - }); + 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 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 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/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 diff --git a/src/ui/basedClasses/BaseSettingComponent.ts b/src/ui/basedClasses/BaseSettingComponent.ts new file mode 100644 index 0000000..bdeed80 --- /dev/null +++ b/src/ui/basedClasses/BaseSettingComponent.ts @@ -0,0 +1,28 @@ +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; + // render теперь должен сам создавать Setting и сохранять ссылку в this.setting + abstract render(container: HTMLElement, currentValue: any): void; + abstract getValueFromUi(): any; + abstract setValueInUi(value: any): void; + abstract validate(): ValidationError | null; + + + + // --- Общая реализация --- + public setChangeListener(listener: () => void): void { + this.onChangeCallback = listener; + } +} \ No newline at end of file diff --git a/src/ui/basedClasses/BaseTextArraySettingComponent.ts b/src/ui/basedClasses/BaseTextArraySettingComponent.ts new file mode 100644 index 0000000..a68ea81 --- /dev/null +++ b/src/ui/basedClasses/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/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts new file mode 100644 index 0000000..e4bb3db --- /dev/null +++ b/src/ui/components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent.ts @@ -0,0 +1,76 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +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 { + + 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/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts new file mode 100644 index 0000000..504efd6 --- /dev/null +++ b/src/ui/components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent.ts @@ -0,0 +1,38 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { BaseTextArraySettingComponent } from "src/ui/basedClasses/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/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts new file mode 100644 index 0000000..ba79833 --- /dev/null +++ b/src/ui/components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent.ts @@ -0,0 +1,64 @@ +import { Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +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 { + + 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/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts b/src/ui/components/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts new file mode 100644 index 0000000..2c46a4e --- /dev/null +++ b/src/ui/components/checkboxSymbolConfiguration/UnknownPolicySettingComponent.ts @@ -0,0 +1,72 @@ +import { DropdownComponent, Setting } from "obsidian"; +import { CheckboxSyncPluginSettings, CheckboxState } from "src/types"; +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 { + 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/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts new file mode 100644 index 0000000..1559146 --- /dev/null +++ b/src/ui/components/synchronizationBehavior/EnableChildSyncSettingComponent.ts @@ -0,0 +1,58 @@ +import { ToggleComponent, Setting } from "obsidian"; +import { CheckboxSyncPluginSettings } from "src/types"; +import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent"; +import { ValidationError } from "src/ui/validation/types"; +import { validateValueIsBoolean } from "src/ui/validation/validators"; + + +export class 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/synchronizationBehavior/EnableFileSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableFileSyncSettingComponent.ts new file mode 100644 index 0000000..5a24fc4 --- /dev/null +++ b/src/ui/components/synchronizationBehavior/EnableFileSyncSettingComponent.ts @@ -0,0 +1,72 @@ +// src/settings/components/EnableFileSyncSettingComponent.ts +import { Setting, ToggleComponent } from 'obsidian'; +import { CheckboxSyncPluginSettings } from 'src/types'; +import { BaseSettingComponent } from 'src/ui/basedClasses/BaseSettingComponent'; +import { ValidationError } from 'src/ui/validation/types'; +import { validateValueIsBoolean } from 'src/ui/validation/validators'; + +export class 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/synchronizationBehavior/EnableParentSyncSettingComponent.ts b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts new file mode 100644 index 0000000..116366f --- /dev/null +++ b/src/ui/components/synchronizationBehavior/EnableParentSyncSettingComponent.ts @@ -0,0 +1,71 @@ +import { Setting, ToggleComponent } from 'obsidian'; +import { CheckboxSyncPluginSettings } from 'src/types'; +import { BaseSettingComponent } from 'src/ui/basedClasses/BaseSettingComponent'; +import { ValidationError } from 'src/ui/validation/types'; +import { validateValueIsBoolean } from 'src/ui/validation/validators'; + +export class 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(); + } + // Если 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 { + 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 new file mode 100644 index 0000000..33a63cd --- /dev/null +++ b/src/ui/interfaces/ISettingComponent.ts @@ -0,0 +1,52 @@ +import { CheckboxSyncPluginSettings } from "src/types"; +import { ValidationError } from "../validation/types"; + + +/** + * Интерфейс для компонента, отвечающего за одну настройку в UI. + */ +export interface ISettingComponent { + /** + * Получает ключ настройки, за которую отвечает этот компонент. + * @returns Ключ из CheckboxSyncPluginSettings. + */ + getSettingKey(): keyof CheckboxSyncPluginSettings; + + /** + * Рендерит 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(): 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..6c2e13c --- /dev/null +++ b/src/ui/validation/SettingsValidator.ts @@ -0,0 +1,78 @@ +// 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[] = []; + + 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)}` + }); + } + + return errors; + } + + /** + * Находит пересечение двух массивов строк. + * @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 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 diff --git a/src/ui/validation/validators.ts b/src/ui/validation/validators.ts new file mode 100644 index 0000000..3b954e1 --- /dev/null +++ b/src/ui/validation/validators.ts @@ -0,0 +1,95 @@ +// src/settings/validation/validators.ts + +import { CheckboxState } from "src/types"; + +/** + * Проверяет, является ли предоставленное значение булевым типом (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; +} + +/** + * Проверяет, является ли значение допустимым состоянием 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-массивом строк, + * где каждая строка состоит ровно из одного символа. + * Также проверяет на уникальность символов. + * @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 { + parsed = JSON.parse(trimmedValue); + } catch (e: any) { + 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; // Все проверки пройдены +} + + +/** +* Проверяет, является ли предоставленный массив непустым. +* @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