mirror of
https://github.com/groldsf/obsidian_check_plugin.git
synced 2026-07-22 05:37:48 +00:00
Merge branch 'refactor/settingstab' into dev
This commit is contained in:
commit
a694453dcf
23 changed files with 1512 additions and 293 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
78
__tests__/ui/components/validation/SettingsValidator.test.ts
Normal file
78
__tests__/ui/components/validation/SettingsValidator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
__tests__/ui/components/validation/validators.test.ts
Normal file
173
__tests__/ui/components/validation/validators.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"license": "0BSD",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^16.11.6",
|
||||
|
|
|
|||
|
|
@ -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
67
src/ui/ErrorDisplay.ts
Normal 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
56
src/ui/SettingGroup.ts
Normal 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
122
src/ui/SettingsControls.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/ui/basedClasses/BaseSettingComponent.ts
Normal file
28
src/ui/basedClasses/BaseSettingComponent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
130
src/ui/basedClasses/BaseTextArraySettingComponent.ts
Normal file
130
src/ui/basedClasses/BaseTextArraySettingComponent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 унаследованы
|
||||
}
|
||||
|
|
@ -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 унаследованы
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
52
src/ui/interfaces/ISettingComponent.ts
Normal file
52
src/ui/interfaces/ISettingComponent.ts
Normal 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;
|
||||
}
|
||||
78
src/ui/validation/SettingsValidator.ts
Normal file
78
src/ui/validation/SettingsValidator.ts
Normal 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(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ui/validation/types.ts
Normal file
8
src/ui/validation/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { CheckboxSyncPluginSettings } from "src/types";
|
||||
|
||||
export interface ValidationError {
|
||||
/** Ключ поля настройки, с которым связана ошибка (опционально). */
|
||||
field?: keyof CheckboxSyncPluginSettings;
|
||||
/** Сообщение об ошибке, понятное пользователю. */
|
||||
message: string;
|
||||
}
|
||||
95
src/ui/validation/validators.ts
Normal file
95
src/ui/validation/validators.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue