Merge branch 'refactor/settingstab' into dev

This commit is contained in:
Grol Grol 2025-04-27 08:45:23 +03:00
commit a694453dcf
23 changed files with 1512 additions and 293 deletions

View file

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

View file

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

View file

@ -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<CheckboxSyncPluginSettings> => ({
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<CheckboxSyncPluginSettings> = { 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);
});
});

View file

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

View file

@ -36,6 +36,12 @@ const config: Config = {
// Limit the number of workers that run tests in parallel
maxWorkers: 1, // <--- Задает последовательное выполнение
moduleNameMapper: {
// Этот паттерн говорит: если импорт начинается с 'src/',
// замени 'src/' на '<rootDir>/src/' при поиске файла.
'^src/(.*)$': '<rootDir>/src/$1',
},
};
export default config;

View file

@ -13,7 +13,7 @@
},
"keywords": [],
"author": "",
"license": "MIT",
"license": "0BSD",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^16.11.6",

View file

@ -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<string>();
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<void> {
// 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<CheckboxSyncPluginSettings> = {}; // Объект для новых данных
// 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;
}
}
}

67
src/ui/ErrorDisplay.ts Normal file
View file

@ -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('');
}
}

56
src/ui/SettingGroup.ts Normal file
View file

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

122
src/ui/SettingsControls.ts Normal file
View file

@ -0,0 +1,122 @@
import { ButtonComponent, Setting } from 'obsidian';
/**
* Интерфейс для действий, которые должны выполнять кнопки управления.
*/
export interface ISettingsControlActions {
onApply: () => Promise<void> | void;
onReset: () => Promise<void> | void;
onResetDefaults: () => Promise<void> | 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);
}
}
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CheckboxSyncPluginSettings>): 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(', ');
}
}
}

View file

@ -0,0 +1,8 @@
import { CheckboxSyncPluginSettings } from "src/types";
export interface ValidationError {
/** Ключ поля настройки, с которым связана ошибка (опционально). */
field?: keyof CheckboxSyncPluginSettings;
/** Сообщение об ошибке, понятное пользователю. */
message: string;
}

View file

@ -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<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; // Все проверки пройдены
}
/**
* Проверяет, является ли предоставленный массив непустым.
* @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;
}