Merge branch 'feature/flexible-checkbox-symbols' into dev

This commit is contained in:
Grol Grol 2025-04-24 05:00:41 +03:00
commit b4a789f04b
8 changed files with 1247 additions and 790 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,189 +0,0 @@
import { CheckboxUtils } from "../src/checkboxUtils";
describe("CheckboxUtils old", () => {
let checkboxUtilsXOnly: CheckboxUtils;
let checkboxUtilsSpaceOnly: CheckboxUtils;
beforeEach(() => {
checkboxUtilsXOnly = new CheckboxUtils({ xOnlyMode: true, enableAutomaticParentState: true, enableAutomaticChildState: true, });
checkboxUtilsSpaceOnly = new CheckboxUtils({ xOnlyMode: false, enableAutomaticParentState: true, enableAutomaticChildState: true,});
});
describe("findCheckboxesLine", () => {
test("recognizes valid checkbox lines", () => {
expect(checkboxUtilsXOnly.matchCheckboxLine("- [ ] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine(" - [ ] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("- [x] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("* [ ] Another task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("+ [x] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1. [ ] Numbered task")).not.toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1. [x] Numbered task")).not.toBeNull();
});
test("correctly handles different checkbox states", () => {
const states = [" ", "x", "-", "?", "!", "*", "#", "@", "[", "]", "y", "X", "Y"];
for (const state of states) {
const lineInfo = checkboxUtilsXOnly.matchCheckboxLine(`- [${state}] Task`);
expect(lineInfo).not.toBeNull();
expect(lineInfo?.checkChar).toBe(state);
}
});
test("correctly handles indentation", () => {
const noIndentMatch = checkboxUtilsXOnly.matchCheckboxLine("- [ ] Task");
expect(noIndentMatch).not.toBeNull();
expect(noIndentMatch!.indent).toBe("".length);
const singleSpaceMatch = checkboxUtilsXOnly.matchCheckboxLine(" - [ ] Task");
expect(singleSpaceMatch).not.toBeNull();
expect(singleSpaceMatch!.indent).toBe(" ".length);
const multiSpaceMatch = checkboxUtilsXOnly.matchCheckboxLine(" - [ ] Task");
expect(multiSpaceMatch).not.toBeNull();
expect(multiSpaceMatch!.indent).toBe(" ".length);
const tabMatch = checkboxUtilsXOnly.matchCheckboxLine("\t- [ ] Task");
expect(tabMatch).not.toBeNull();
expect(tabMatch!.indent).toBe("\t".length);
});
test("ignores invalid lines", () => {
expect(checkboxUtilsXOnly.matchCheckboxLine("-[ ] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("*[x] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1.[ ] Numbered task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("- [] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine(" - Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("Just some text")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1. Some numbered task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("")).toBeNull();
});
test("ignores lines with missing opening or closing brackets", () => {
// Missing opening bracket [
expect(checkboxUtilsXOnly.matchCheckboxLine("- ] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("* ] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("+ ] Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1. ] Task")).toBeNull();
// Missing closing bracket ]
expect(checkboxUtilsXOnly.matchCheckboxLine("- [x Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("* [ Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("+ [- Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("1. [? Task")).toBeNull();
// Malformed with spaces
expect(checkboxUtilsXOnly.matchCheckboxLine("- [ x Task")).toBeNull();
expect(checkboxUtilsXOnly.matchCheckboxLine("- x] Task")).toBeNull();
});
test("extracts list marker correctly", () => {
const dashMatch = checkboxUtilsXOnly.matchCheckboxLine("- [ ] Task");
expect(dashMatch).not.toBeNull();
expect(dashMatch!.marker).toBe("-");
const asteriskMatch = checkboxUtilsXOnly.matchCheckboxLine("* [ ] Task");
expect(asteriskMatch).not.toBeNull();
expect(asteriskMatch!.marker).toBe("*");
const plusMatch = checkboxUtilsXOnly.matchCheckboxLine("+ [ ] Task");
expect(plusMatch).not.toBeNull();
expect(plusMatch!.marker).toBe("+");
const numberedMatch = checkboxUtilsXOnly.matchCheckboxLine("1. [ ] Task");
expect(numberedMatch).not.toBeNull();
expect(numberedMatch!.marker).toBe("1.");
});
});
describe("isCheckedSymbol", () => {
test("in xOnlyMode only 'x' is considered checked", () => {
expect(checkboxUtilsXOnly.isCheckedSymbol("x")).toBe(true);
expect(checkboxUtilsXOnly.isCheckedSymbol(" ")).toBe(false);
expect(checkboxUtilsXOnly.isCheckedSymbol("-")).toBe(false);
expect(checkboxUtilsXOnly.isCheckedSymbol("?")).toBe(false);
});
test("in normal mode all symbols except space are considered checked", () => {
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("x")).toBe(true);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol(" ")).toBe(false);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("-")).toBe(true);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("?")).toBe(true);
});
test("handles uppercase 'X' correctly", () => {
expect(checkboxUtilsXOnly.isCheckedSymbol("X")).toBe(false);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("X")).toBe(true);
});
});
describe("updateSettings", () => {
test("updates xOnlyMode setting correctly", () => {
const settings = { xOnlyMode: true, enableAutomaticParentState: true, enableAutomaticChildState: true,};
const utils = new CheckboxUtils(settings);
expect(utils.isCheckedSymbol("-")).toBe(false);
settings.xOnlyMode = false;
expect(utils.isCheckedSymbol("-")).toBe(true);
settings.xOnlyMode = true;
expect(utils.isCheckedSymbol("-")).toBe(false);
});
});
describe("syncCheckboxes", () => {
test("updates parent checkbox if all children are checked (xOnlyMode)", () => {
const text = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2";
const expected = "- [x] Parent\n - [x] Child 1\n - [x] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("updates parent checkbox if all children are checked (spaceOnly)", () => {
const text = "- [ ] Parent\n - [?] Child 1\n - [-] Child 2";
const expected = "- [x] Parent\n - [?] Child 1\n - [-] Child 2";
expect(checkboxUtilsSpaceOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("unchecks parent if not all children are checked", () => {
const text = "- [x] Parent\n - [x] Child 1\n - [ ] Child 2";
const expected = "- [ ] Parent\n - [x] Child 1\n - [ ] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("doesn't change parent if it has no children", () => {
const text = "- [x] Single";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(text);
});
test("handles multiple levels of nesting correctly", () => {
const text = "- [ ] Parent\n - [ ] Child\n - [x] Grandchild";
const expected = "- [x] Parent\n - [x] Child\n - [x] Grandchild";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("handles mixed indentation levels correctly", () => {
const text = "- [ ] Parent\n - [x] Child 1\n - [x] Grandchild 1\n - [x] Child 2";
const expected = "- [x] Parent\n - [x] Child 1\n - [x] Grandchild 1\n - [x] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("handles siblings with different indentation correctly", () => {
const text = "- [ ] Parent\n - [x] Child 1\n - [x] Grandchild 1\n - [ ] Child 2";
const expected = "- [ ] Parent\n - [x] Child 1\n - [x] Grandchild 1\n - [ ] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("handles multiple parents correctly", () => {
const text = "- [ ] Parent 1\n - [x] Child 1\n- [ ] Parent 2\n - [x] Child 2";
const expected = "- [x] Parent 1\n - [x] Child 1\n- [x] Parent 2\n - [x] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
test("handles mixed content with non-checkbox lines correctly", () => {
const text = "- [ ] Parent\nSome regular text\n - [x] Child 1\nMore text\n - [x] Child 2";
const expected = "- [ ] Parent\nSome regular text\n - [x] Child 1\nMore text\n - [x] Child 2";
expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected);
});
});
});

View file

@ -1,51 +0,0 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import CheckboxSyncPlugin from "./main";
export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
plugin: CheckboxSyncPlugin;
constructor(app: App, plugin: CheckboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("X-Only Mode")
.setDesc("When enabled, only 'x' marks a task as complete. When disabled, any character except space marks a task as complete")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.xOnlyMode)
.onChange(async (value) => {
await this.plugin.updateSettings(settings => {
settings.xOnlyMode = value;
});
})
);
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 => toggle
.setValue(this.plugin.settings.enableAutomaticParentState)
.onChange(async (value) => {
await this.plugin.updateSettings(settings => {
settings.enableAutomaticParentState = value;
});
}));
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 => toggle
.setValue(this.plugin.settings.enableAutomaticChildState)
.onChange(async (value) => {
await this.plugin.updateSettings(settings => {
settings.enableAutomaticChildState = value;
});
}));
}
}

View file

@ -1,10 +1,12 @@
import { CheckboxSyncPluginSettings } from "./types";
import { CheckboxState, CheckboxSyncPluginSettings } from "./types";
export interface CheckboxLineInfo {
indent: number;
marker: string;
checkChar: string;
checkboxCharPosition: number;
checkboxState: CheckboxState;
isChecked: boolean | undefined;
}
export class CheckboxUtils {
@ -22,21 +24,37 @@ export class CheckboxUtils {
const marker = match[2];
const checkChar = match[3];
const checkboxCharPosition = indent + marker.length + 2;
const checkboxState = this.getCheckboxState(checkChar);
let isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked;
return {
indent,
marker,
checkChar,
checkboxCharPosition
checkboxCharPosition,
checkboxState,
isChecked
};
}
isCheckedSymbol(text: string): boolean {
return this.settings.xOnlyMode ? text === "x" : text !== " ";
getCheckboxState(text: string): CheckboxState {
if (this.settings.checkedSymbols.includes(text)) {
return CheckboxState.Checked;
}
if (this.settings.uncheckedSymbols.includes(text)) {
return CheckboxState.Unchecked;
}
if (this.settings.ignoreSymbols.includes(text)) {
return CheckboxState.Ignore;
}
return this.settings.unknownSymbolPolicy;
}
updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string {
const newCheckChar = shouldBeChecked ? "x" : " ";
const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт
const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт
const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar;
const pos = lineInfo.checkboxCharPosition;
if (pos >= 0 && pos < line.length) {
return line.substring(0, pos) + newCheckChar + line.substring(pos + 1);
@ -52,18 +70,36 @@ export class CheckboxUtils {
const parentLineInfo = this.matchCheckboxLine(lines[parentLine]);
if (!parentLineInfo) {
console.log(`checkbox not found in line ${parentLine}`)
console.warn(`checkbox not found in line ${parentLine}`)
return text;
}
const isChecked = this.isCheckedSymbol(parentLineInfo.checkChar);
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
return text;
}
let parentIsChecked = parentLineInfo.isChecked!;
let j = parentLine + 1;
while (j < lines.length) {
const childLineInfo = this.matchCheckboxLine(lines[j]);
const childText = lines[j];
const childLineInfo = this.matchCheckboxLine(childText);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], isChecked, childLineInfo);
j++;
if (childLineInfo.checkboxState !== CheckboxState.Ignore) {
lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo);
j++;
} else {
//skip children ignore node
j++;
while (j < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
}
}
return lines.join("\n");
}
@ -75,24 +111,37 @@ export class CheckboxUtils {
const parentLineInfo = this.matchCheckboxLine(lines[i]);
if (!parentLineInfo) continue;
const isChecked = this.isCheckedSymbol(parentLineInfo.checkChar);
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
continue;
}
const parentIsChecked = parentLineInfo.isChecked!;
let allChildrenChecked = true;
let hasChildren = false;
let j = i + 1;
while (j < lines.length) {
for (let j = i + 1; j < lines.length; j++) {
const childLineInfo = this.matchCheckboxLine(lines[j]);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
if (childLineInfo.checkboxState === CheckboxState.Ignore) {
while (j + 1 < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j + 1]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
continue;
}
hasChildren = true;
const childrenIsChecked = this.isCheckedSymbol(childLineInfo.checkChar);
const childrenIsChecked = childLineInfo.isChecked!;
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
j++;
}
if (hasChildren && isChecked !== allChildrenChecked) {
if (hasChildren && parentIsChecked !== allChildrenChecked) {
lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allChildrenChecked, parentLineInfo);
}
}
@ -137,7 +186,7 @@ export class CheckboxUtils {
lineInfoBefore && lineInfoAfter &&
lineInfoBefore.indent === lineInfoAfter.indent &&
lineInfoBefore.marker === lineInfoAfter.marker &&
lineInfoBefore.checkChar !== lineInfoAfter.checkChar
lineInfoBefore.checkboxState !== lineInfoAfter.checkboxState
) {
return this.propagateStateToChildren(text, diffIndexes[0]);
}

View file

@ -1,17 +1,11 @@
import { Plugin, TFile } from "obsidian";
import { CheckboxSyncPluginSettingTab } from "./CheckboxSyncPluginSettingTab";
import { CheckboxUtils } from "./checkboxUtils";
import FileStateHolder from "./FileStateHolder";
import SyncController from "./SyncController";
import { CheckboxSyncPluginSettings } from "./types";
import { FileLoadEventHandler } from "./events/FileLoadEventHandler";
import { FileChangeEventHandler } from "./events/FileChangeEventHandler";
const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
xOnlyMode: true,
enableAutomaticParentState: true,
enableAutomaticChildState: true,
};
import { FileLoadEventHandler } from "./events/FileLoadEventHandler";
import FileStateHolder from "./FileStateHolder";
import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab";
import SyncController from "./SyncController";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types";
export default class CheckboxSyncPlugin extends Plugin {
private _settings: CheckboxSyncPluginSettings;

View file

@ -1,5 +1,23 @@
export interface CheckboxSyncPluginSettings {
xOnlyMode: boolean;
enableAutomaticParentState: boolean;
enableAutomaticChildState: boolean;
}
checkedSymbols: string[];
uncheckedSymbols: string[];
ignoreSymbols: string[];
unknownSymbolPolicy: CheckboxState;
}
export enum CheckboxState {
Checked = 'checked',
Unchecked = 'unchecked',
Ignore = 'ignore'
}
export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
enableAutomaticParentState: true,
enableAutomaticChildState: true,
checkedSymbols: ['x'],
uncheckedSymbols: [' '],
ignoreSymbols: [],
unknownSymbolPolicy: CheckboxState.Checked,
};

View file

@ -0,0 +1,409 @@
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";
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 applyButton: ButtonComponent;
private resetToDefaultButton: ButtonComponent;
private errorDisplayEl: HTMLElement;
private isDirty: boolean = false;
private actionMutex = new Mutex();
constructor(app: App, plugin: CheckboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
}
/**
* 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: [] };
}
let parsed: any;
try {
parsed = JSON.parse(trimmedValue);
} catch (e: any) {
return { result: [], error: `Invalid JSON format: ${e.message}` };
}
if (!Array.isArray(parsed)) {
return { result: [], error: "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 { result: [], error: `Invalid content: Array element at index ${i} is not a string.` };
}
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();
}
private settingSaved() {
this.isDirty = false;
this.applyButton.setDisabled(true).removeCta();
}
display(): void {
this.isDirty = false;
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())
});
// --- Область для вывода ошибок ---
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'
// Используем Setting для группировки кнопок
const buttonGroup = new Setting(containerEl)
.setClass('checkbox-sync-button-group'); // Добавим класс для возможной стилизации
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())
});
}
private async applyChanges() {
await this.actionMutex.runExclusive(async () => {
const originalButtonText = "Apply Changes";
this.applyButton.setDisabled(true).setButtonText("Applying...").removeCta();
try {
await this.validateAndSaveSettings();
this.checkedSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.checkedSymbols));
this.uncheckedSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.uncheckedSymbols));
this.ignoreSymbolsInput.setValue(this.arrayToJsonString(this.plugin.settings.ignoreSymbols));
this.settingSaved();
this.errorDisplayEl.setText('');
new Notice("Checkbox Sync settings applied!", 3000);
} catch (error: any) {
// --- Ошибка ---
console.error("Error applying checkbox sync settings:", error);
// Выводим ошибку в специальный div
this.errorDisplayEl.setText(`${error.message || "An unknown error occurred."}`);
} finally {
this.applyButton.setButtonText(originalButtonText); // Восстанавливаем текст
}
});
}
/**
* Resets the settings UI elements to reflect the currently saved plugin settings.
*/
private resetInputsToSavedSettings() {
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.errorDisplayEl?.setText('');
this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply
}
/**
* Applies the default settings immediately after confirmation.
*/
private async applyDefaultSettings() {
await this.actionMutex.runExclusive(async () => {
this.resetToDefaultButton.setDisabled(true).setButtonText("Resetting...");
this.errorDisplayEl.setText(''); // Очищаем предыдущие ошибки
try {
const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией
await this.plugin.updateSettings(settings => {
// Полностью перезаписываем текущие настройки дефолтными
Object.assign(settings, defaultsCopy);
});
// После успешного сохранения обновляем UI и состояние
this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply
new Notice("Settings reset to defaults and applied.", 3000);
} catch (error: any) {
console.error("Error resetting settings to default:", error);
this.errorDisplayEl.setText(`❌ Error resetting to defaults: ${error.message}`);
} finally {
// Восстанавливаем кнопку Reset
this.resetToDefaultButton.setDisabled(false).setButtonText("Reset to defaults");
}
});
}
/**
* Overridden hide method to handle unsaved changes.
*/
async hide() {
if (this.isDirty) {
// Колбэк для кнопки "Save"
const saveCallback = async () => {
try {
await this.validateAndSaveSettings();
this.isDirty = false; // Сбрасываем флаг
new Notice("Settings saved.", 2000);
} catch (error: any) {
console.error("Error saving settings on hide:", 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();
}
};
// Колбэк для кнопки "Discard"
const discardCallback = () => {
this.isDirty = false; // Считаем изменения отмененными
};
// Открываем модалку Save/Discard и ЖДЕМ ее закрытия
const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback);
saveModal.open();
}
// Продолжаем стандартное закрытие
super.hide();
}
/**
* Reads UI values, validates them, and calls plugin.updateSettings.
* 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();
// 2. Парсим JSON
const parsedChecked = this.parseJsonStringArray(checkedValue);
const parsedUnchecked = this.parseJsonStringArray(uncheckedValue);
const parsedIgnore = this.parseJsonStringArray(ignoreValue);
// 3. Проверяем ошибки парсинга -> throw
if (parsedChecked.error) {
throw new Error(`Checked Symbols Error: ${parsedChecked.error}`);
}
if (parsedUnchecked.error) {
throw new Error(`Unchecked Symbols Error: ${parsedUnchecked.error}`);
}
if (parsedIgnore.error) {
throw new Error(`Ignore Symbols Error: ${parsedIgnore.error}`);
}
const checkedSymbolsArray = parsedChecked.result;
const uncheckedSymbolsArray = parsedUnchecked.result;
const ignoreSymbolsArray = parsedIgnore.result;
// 4. Валидируем пересечение -> throw
const intersectionValidation1 = this.validateSettingsArraysIntersection(checkedSymbolsArray, uncheckedSymbolsArray);
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!}`);
}
// 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.");
}
// 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;
});
}
}

126
src/ui/modals.ts Normal file
View file

@ -0,0 +1,126 @@
// Добавляем Modal в импорты из 'obsidian'
import { App, Modal, Setting } from "obsidian";
// --- НОВЫЙ КОД: Класс ConfirmModal ---
export class ConfirmModal extends Modal {
message: string;
onConfirm: () => void; // Синхронный колбэк
constructor(app: App, message: string, onConfirm: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
}
onOpen() {
const { contentEl } = this;
contentEl.empty(); // Очищаем на всякий случай
contentEl.createEl('p').setText(this.message);
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText('Cancel')
.onClick(() => {
this.close();
}))
.addButton((btn) =>
btn
.setButtonText('OK') // Или "Confirm", "Yes"
.setCta()
.onClick(() => {
this.close();
this.onConfirm(); // Вызываем колбэк
}));
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
// --- КОНЕЦ ConfirmModal ---
// --- НОВЫЙ КОД: Класс InfoModal ---
export class InfoModal extends Modal {
message: string;
constructor(app: App, message: string) {
super(app);
this.message = message;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
// Используем <pre> для сохранения форматирования ошибки
const pre = contentEl.createEl('pre');
pre.setText(this.message);
pre.style.whiteSpace = 'pre-wrap'; // Для переноса строк
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText('OK')
.setCta()
.onClick(() => {
this.close();
}));
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
// --- КОНЕЦ InfoModal ---
// --- НОВЫЙ КОД: Класс SaveConfirmModal ---
export class SaveConfirmModal extends Modal {
onSave: () => Promise<void>; // Асинхронный колбэк для сохранения
onDiscard: () => void;
constructor(app: App, onSave: () => Promise<void>, onDiscard: () => void) {
super(app);
this.onSave = onSave;
this.onDiscard = onDiscard;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.setText('You have unsaved changes. Save them before closing?');
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText('Discard')
.onClick(() => {
this.close();
this.onDiscard();
}))
.addButton((btn) =>
btn
.setButtonText('Save')
.setCta()
.onClick(async () => {
btn.setDisabled(true).setButtonText('Saving...');
try {
await this.onSave();
this.close();
} catch (e) {
console.error("Unexpected error during save callback:", e);
new InfoModal(this.app, "An unexpected error occurred during save.").open();
this.close(); // Закрываем в любом случае после ошибки
} finally {
// Восстанавливаем кнопку на случай если модалка не закроется (не должно быть)
btn.setDisabled(false).setButtonText('Save');
}
}));
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}