refactor(settings): Migrate all remaining settings to component architecture

This commit is contained in:
Grol Grol 2025-04-27 05:16:18 +03:00
parent 400ce20193
commit cdcf3a9db3
10 changed files with 738 additions and 201 deletions

View file

@ -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', () => { 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', () => { 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);
});
}); });
*/
}); });

View file

@ -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 { 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 { SettingGroup } from "./SettingGroup";
import { EnableChildSyncSettingComponent } from "./components/EnableChildSyncSettingComponent";
import { EnableFileSyncSettingComponent } from "./components/EnableFileSyncSettingComponent";
import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent"; import { EnableParentSyncSettingComponent } from "./components/EnableParentSyncSettingComponent";
import { UnknownPolicySettingComponent } from "./components/UnknownPolicySettingComponent";
import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals";
import { ValidationError } from "./validation/types"; 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 { export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
plugin: CheckboxSyncPlugin; 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[] = [] private settingGroups: SettingGroup[] = []
@ -35,22 +34,45 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
// Метод для создания и конфигурации групп и компонентов // Метод для создания и конфигурации групп и компонентов
private initializeSettingGroups(): void { private initializeSettingGroups(): void {
// 1. Создаем экземпляр нашего компонента // 1. Создаем экземпляры наших компонентов
const parentToggleComp = new EnableParentSyncSettingComponent();
// 2. Устанавливаем ему обработчик изменений, чтобы он мог обновить флаг isDirty const checkedSymbolsComp = new CheckedSymbolsSettingComponent();
// Мы передаем лямбда-функцию, которая вызовет наш метод settingChanged 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()); 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( const behaviorGroup = new SettingGroup(
"Synchronization Behavior", // Заголовок группы "Synchronization Behavior", // Заголовок группы
[parentToggleComp] // Массив компонентов для этой группы (пока один) [parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp] // Массив компонентов для этой группы
// Можно добавить описание группы третьим аргументом, если нужно // Можно добавить описание группы третьим аргументом, если нужно
); );
// 4. Сохраняем созданную группу (или группы) в поле класса // 3. Сохраняем созданную группу (или группы) в поле класса
this.settingGroups = [ this.settingGroups = [
symbolGroup,
behaviorGroup behaviorGroup
// Сюда позже добавятся другие группы (например, для символов) // Сюда позже добавятся другие группы (например, для символов)
]; ];
@ -133,100 +155,10 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
const containerEl = this.containerEl; const containerEl = this.containerEl;
containerEl.empty(); containerEl.empty();
containerEl.createEl('h2', { text: 'Checkbox Sync Settings' }); 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) { for (const group of this.settingGroups) {
// Вызываем метод render группы, передавая контейнер и текущие настройки
group.render(containerEl, this.plugin.settings); 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' }); this.errorDisplayEl = containerEl.createDiv({ cls: 'checkbox-sync-settings-error' });
@ -283,16 +215,10 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
try { try {
await this.validateAndSaveSettings(); 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.settingSaved();
this.errorDisplayEl.setText(''); this.errorDisplayEl.setText('');
new Notice("Checkbox Sync settings applied!", 3000); new Notice("Checkbox Sync settings applied!", 3000);
} catch (error: any) { } catch (error: any) {
// --- Ошибка ---
console.error("Error applying checkbox sync settings:", error); console.error("Error applying checkbox sync settings:", error);
// Выводим ошибку в специальный div // Выводим ошибку в специальный div
this.errorDisplayEl.setText(`${error.message || "An unknown error occurred."}`); 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.errorDisplayEl?.setText('');
this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply
@ -434,64 +351,25 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
} }
} }
// 1. Считываем значения из UI const checkedSymbolsFromComponent = newSettingsData.checkedSymbols || [];
const checkedValue = this.checkedSymbolsInput.getValue(); const uncheckedSymbolsFromComp = newSettingsData.uncheckedSymbols || [];
const uncheckedValue = this.uncheckedSymbolsInput.getValue(); const ignoreSymbolsFromComp = newSettingsData.ignoreSymbols || [];
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();
// 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 // 4. Валидируем пересечение -> throw
const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsArray, uncheckedSymbolsArray); const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, uncheckedSymbolsFromComp);
if (!intersectionValidation1.isValid) { if (!intersectionValidation1.isValid) {
throw new Error(`Lists: checked and unchecked. ${intersectionValidation1.error!}`); throw new Error(`Lists: checked and unchecked. ${intersectionValidation1.error!}`);
} }
const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsArray, ignoreSymbolsArray); const intersectionValidation2 = this.validateSettingsArraysIntersection(checkedSymbolsFromComponent, ignoreSymbolsFromComp);
if (!intersectionValidation2.isValid) { if (!intersectionValidation2.isValid) {
throw new Error(`Lists: checked and ignore. ${intersectionValidation2.error!}`); throw new Error(`Lists: checked and ignore. ${intersectionValidation2.error!}`);
} }
const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsArray, ignoreSymbolsArray); const intersectionValidation3 = this.validateSettingsArraysIntersection(uncheckedSymbolsFromComp, ignoreSymbolsFromComp);
if (!intersectionValidation3.isValid) { if (!intersectionValidation3.isValid) {
throw new Error(`Lists: unchecked and ignore. ${intersectionValidation3.error!}`); 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) { if (errors.length > 0) {
// Формируем сообщение об ошибке // Формируем сообщение об ошибке
const errorMessage = errors.map(e => `${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n'); const errorMessage = errors.map(e => `${e.field ? `[${e.field}]: ` : ''}${e.message}`).join('\n');

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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 унаследованы
}

View file

@ -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 унаследованы
}

View file

@ -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;
}
}

View file

@ -1,5 +1,7 @@
// src/settings/validation/validators.ts // src/settings/validation/validators.ts
import { CheckboxState } from "src/types";
/** /**
* Проверяет, является ли предоставленное значение булевым типом (boolean). * Проверяет, является ли предоставленное значение булевым типом (boolean).
* Эта функция не зависит от контекста Obsidian или конкретного компонента. * Эта функция не зависит от контекста Obsidian или конкретного компонента.
@ -9,41 +11,85 @@
*/ */
export function validateValueIsBoolean(value: any): { message: string } | null { export function validateValueIsBoolean(value: any): { message: string } | null {
if (typeof value !== 'boolean') { if (typeof value !== 'boolean') {
return { message: 'Value must be a boolean.' }; return { message: 'Value must be a boolean.' };
} }
return null; 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-массива строк * Проверяет, является ли строка валидным JSON-массивом строк,
export function validateJsonStringArray(rawJson: string): { message: string } | null { * где каждая строка состоит ровно из одного символа.
* Также проверяет на уникальность символов.
* @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 { try {
const parsed = JSON.parse(rawJson); parsed = JSON.parse(trimmedValue);
if (!Array.isArray(parsed)) {
return { message: 'Input must be a valid JSON array (e.g., ["x", " "]).' };
}
for (let i = 0; i < parsed.length; i++) {
const element = parsed[i];
if (typeof element !== 'string') {
return { message: `Array element at index ${i} is not a string.` };
}
if (element.length !== 1) {
return { message: `Element "${element}" at index ${i} must be a single character.` };
}
}
return null; // Все проверки пройдены
} catch (e: any) { } 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<string>();
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; return null;
} }
*/