add settings tab.

This commit is contained in:
Grol Grol 2025-03-15 13:29:19 +03:00
parent 0c43e9f5e7
commit 1234817949
5 changed files with 223 additions and 132 deletions

View file

@ -1,92 +1,110 @@
import { findCheckboxesLine, syncCheckboxes } from "../src/checkboxUtils";
import { CheckboxUtils } from "../src/checkboxUtils";
describe("findCheckboxesLine", () => {
test("распознаёт корректные строки с чекбоксами", () => {
expect(findCheckboxesLine("- [ ] Task")).not.toBeNull();
expect(findCheckboxesLine(" - [ ] Task")).not.toBeNull();
expect(findCheckboxesLine("- [x] Task")).not.toBeNull();
expect(findCheckboxesLine("* [ ] Another task")).not.toBeNull();
expect(findCheckboxesLine("+ [x] Task")).not.toBeNull();
expect(findCheckboxesLine("1. [ ] Numbered task")).not.toBeNull();
expect(findCheckboxesLine("1. [x] Numbered task")).not.toBeNull();
describe("CheckboxUtils", () => {
let checkboxUtilsXOnly: CheckboxUtils;
let checkboxUtilsSpaceOnly: CheckboxUtils;
beforeEach(() => {
checkboxUtilsXOnly = new CheckboxUtils({ xOnlyMode: true });
checkboxUtilsSpaceOnly = new CheckboxUtils({ xOnlyMode: false });
});
test("корректно обрабатывает разные состояния чекбокса", () => {
const states = [" ", "x", "-", "?", "!", "*", "#", "@", "[", "]", "y", "X", "Y"];
for (const state of states) {
const match = findCheckboxesLine(`- [${state}] Task`);
expect(match).not.toBeNull();
expect(match![3]).toBe(state);
}
describe("findCheckboxesLine", () => {
test("распознаёт корректные строки с чекбоксами", () => {
expect(checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("- [x] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("* [ ] Another task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("+ [x] Task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("1. [ ] Numbered task")).not.toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("1. [x] Numbered task")).not.toBeNull();
});
test("корректно обрабатывает разные состояния чекбокса", () => {
const states = [" ", "x", "-", "?", "!", "*", "#", "@", "[", "]", "y", "X", "Y"];
for (const state of states) {
const match = checkboxUtilsXOnly.findCheckboxesLine(`- [${state}] Task`);
expect(match).not.toBeNull();
expect(match![3]).toBe(state);
}
});
test("корректно обрабатывает отступы", () => {
const noIndentMatch = checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task");
expect(noIndentMatch).not.toBeNull();
expect(noIndentMatch![1]).toBe("");
const singleSpaceMatch = checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task");
expect(singleSpaceMatch).not.toBeNull();
expect(singleSpaceMatch![1]).toBe(" ");
const multiSpaceMatch = checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task");
expect(multiSpaceMatch).not.toBeNull();
expect(multiSpaceMatch![1]).toBe(" ");
const tabMatch = checkboxUtilsXOnly.findCheckboxesLine("\t- [ ] Task");
expect(tabMatch).not.toBeNull();
expect(tabMatch![1]).toBe("\t");
});
test("игнорирует некорректные строки", () => {
expect(checkboxUtilsXOnly.findCheckboxesLine("-[ ] Task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("*[x] Task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("1.[ ] Numbered task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("- [] Task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine(" - Task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("Just some text")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("1. Some numbered task")).toBeNull();
expect(checkboxUtilsXOnly.findCheckboxesLine("")).toBeNull();
});
});
test("корректно обрабатывает отступы", () => {
const noIndentMatch = findCheckboxesLine("- [ ] Task");
expect(noIndentMatch).not.toBeNull();
expect(noIndentMatch![1]).toBe("");
describe("isCheckedSymbol", () => {
test("в режиме xOnlyMode только 'x' считается отмеченным", () => {
expect(checkboxUtilsXOnly.isCheckedSymbol("x")).toBe(true);
expect(checkboxUtilsXOnly.isCheckedSymbol(" ")).toBe(false);
expect(checkboxUtilsXOnly.isCheckedSymbol("-")).toBe(false);
expect(checkboxUtilsXOnly.isCheckedSymbol("?")).toBe(false);
});
const singleSpaceMatch = findCheckboxesLine(" - [ ] Task");
expect(singleSpaceMatch).not.toBeNull();
expect(singleSpaceMatch![1]).toBe(" ");
const multiSpaceMatch = findCheckboxesLine(" - [ ] Task");
expect(multiSpaceMatch).not.toBeNull();
expect(multiSpaceMatch![1]).toBe(" ");
const tabMatch = findCheckboxesLine("\t- [ ] Task");
expect(tabMatch).not.toBeNull();
expect(tabMatch![1]).toBe("\t");
test("в обычном режиме все символы кроме пробела считаются отмеченными", () => {
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("x")).toBe(true);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol(" ")).toBe(false);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("-")).toBe(true);
expect(checkboxUtilsSpaceOnly.isCheckedSymbol("?")).toBe(true);
});
});
test("игнорирует некорректные строки", () => {
// Отсутствует пробел после маркера списка
expect(findCheckboxesLine("-[ ] Task")).toBeNull();
expect(findCheckboxesLine("*[x] Task")).toBeNull();
expect(findCheckboxesLine("1.[ ] Numbered task")).toBeNull();
// Пустые скобки недопустимы
expect(findCheckboxesLine("- [] Task")).toBeNull();
// Не соответствует шаблону чекбокса
expect(findCheckboxesLine(" - Task")).toBeNull();
expect(findCheckboxesLine("Just some text")).toBeNull();
expect(findCheckboxesLine("1. Some numbered task")).toBeNull();
expect(findCheckboxesLine("")).toBeNull();
});
});
describe("syncCheckboxes", () => {
test("обновляет родительский чекбокс, если все дочерние отмечены", () => {
const text = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2";
// Для строки "- [ ] Parent":
// match[1] = "" (нулевой отступ), match[2] = "-" (1 символ)
// => позиция чекбокса = 0 + 1 + 2 = 3
expect(syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]);
});
test("снимает отметку у родителя, если не все дочерние отмечены", () => {
const text = "- [x] Parent\n - [x] Child 1\n - [ ] Child 2";
// Родительский чекбокс должен стать незамеченным, то есть "x" -> " "
expect(syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: " " }]);
});
test("не изменяет родителя, если у него нет дочерних элементов", () => {
const text = "- [x] Single";
expect(syncCheckboxes(text)).toEqual([]);
});
test("тройная вложенность: обновляет только промежуточный уровень", () => {
const text = "- [ ] Parent\n - [ ] Child\n - [x] Grandchild";
// Для строки " - [ ] Child":
// match[1] = " " (2 пробела), match[2] = "-" (1 символ)
// => позиция чекбокса = 2 + 1 + 2 = 5
expect(syncCheckboxes(text)).toEqual([{ line: 1, ch: 5, value: "x" }]);
});
test("тройная вложенность: обновляет родительский чекбокс, если все вложенные отмечены", () => {
const text = "- [ ] Parent\n - [x] Child\n - [x] Grandchild";
// Здесь для строки " - [x] Child" изменений не требуется (она уже отмечена).
// Родительский элемент имеет единственного дочернего, отмеченного как [x],
// поэтому ожидается обновление строки Parent: позиция = 0 + 1 + 2 = 3.
expect(syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]);
describe("syncCheckboxes", () => {
test("обновляет родительский чекбокс, если все дочерние отмечены (xOnlyMode)", () => {
const text = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2";
expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]);
});
test("обновляет родительский чекбокс, если все дочерние отмечены (spaceOnly)", () => {
const text = "- [ ] Parent\n - [?] Child 1\n - [-] Child 2";
expect(checkboxUtilsSpaceOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]);
});
test("снимает отметку у родителя, если не все дочерние отмечены", () => {
const text = "- [x] Parent\n - [x] Child 1\n - [ ] Child 2";
expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: " " }]);
});
test("не изменяет родителя, если у него нет дочерних элементов", () => {
const text = "- [x] Single";
expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([]);
});
test("тройная вложенность: обновляет только промежуточный уровень", () => {
const text = "- [ ] Parent\n - [ ] Child\n - [x] Grandchild";
expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 1, ch: 5, value: "x" }]);
});
test("тройная вложенность: обновляет родительский чекбокс, если все вложенные отмечены", () => {
const text = "- [ ] Parent\n - [x] Child\n - [x] Grandchild";
expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]);
});
});
});

View file

@ -0,0 +1,28 @@
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) => {
this.plugin.settings.xOnlyMode = value;
await this.plugin.saveSettings();
})
);
}
}

View file

@ -1,51 +1,56 @@
/**
* Функция для поиска строк с чекбоксами в формате Markdown.
*/
export function findCheckboxesLine(line: string): RegExpMatchArray | null {
return line.match(/^(\s*)([*+-]|\d+\.) \[(.)\] /);
}
import { CheckboxSyncPluginSettings } from "./types";
export function isCheckedSymbol(text: string): boolean {
return text === "x";
}
/**
* Возвращает список изменений (позиции и новые значения), но не модифицирует текст напрямую.
*/
export function syncCheckboxes(text: string): { line: number; ch: number; value: string }[] {
const lines = text.split("\n");
let updates: { line: number; ch: number; value: string }[] = [];
export class CheckboxUtils {
constructor(private settings: CheckboxSyncPluginSettings) {}
for (let i = lines.length - 1; i >= 0; i--) {
const match = findCheckboxesLine(lines[i]);
if (!match) continue;
updateSettings(settings: CheckboxSyncPluginSettings) {
this.settings = settings;
}
const indent = match[1].length;
const isChecked = isCheckedSymbol(match[3]);
let allChildrenChecked = true;
let hasChildren = false;
let j = i + 1;
findCheckboxesLine(line: string): RegExpMatchArray | null {
return line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/);
}
while (j < lines.length) {
const childMatch = findCheckboxesLine(lines[j]);
if (!childMatch || childMatch[1].length <= indent) break;
hasChildren = true;
const childrenIsChecked = isCheckedSymbol(childMatch[3]);
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
j++;
}
isCheckedSymbol(text: string): boolean {
return this.settings.xOnlyMode ? text === "x" : text !== " ";
}
if (hasChildren) {
const checkboxPos = match[1].length + match[2].length + 2;
if (allChildrenChecked && !isChecked) {
updates.push({ line: i, ch: checkboxPos, value: "x" });
} else if (!allChildrenChecked && isChecked) {
updates.push({ line: i, ch: checkboxPos, value: " " });
}
}
}
syncCheckboxes(text: string): { line: number; ch: number; value: string }[] {
const lines = text.split("\n");
let updates: { line: number; ch: number; value: string }[] = [];
return updates;
for (let i = lines.length - 1; i >= 0; i--) {
const match = this.findCheckboxesLine(lines[i]);
if (!match) continue;
const indent = match[1].length;
const isChecked = this.isCheckedSymbol(match[3]);
let allChildrenChecked = true;
let hasChildren = false;
let j = i + 1;
while (j < lines.length) {
const childMatch = this.findCheckboxesLine(lines[j]);
if (!childMatch || childMatch[1].length <= indent) break;
hasChildren = true;
const childrenIsChecked = this.isCheckedSymbol(childMatch[3]);
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
j++;
}
if (hasChildren) {
const checkboxPos = match[1].length + match[2].length + 2;
if (allChildrenChecked && !isChecked) {
updates.push({ line: i, ch: checkboxPos, value: "x" });
} else if (!allChildrenChecked && isChecked) {
updates.push({ line: i, ch: checkboxPos, value: " " });
}
}
}
return updates;
}
}

View file

@ -1,22 +1,45 @@
import { Plugin, TFile } from "obsidian";
import { syncCheckboxes } from "./checkboxUtils";
import { CheckboxUtils } from "./checkboxUtils";
import { CheckboxSyncPluginSettingTab } from "./CheckboxSyncPluginSettingTab";
import { CheckboxSyncPluginSettings } from "./types";
const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
xOnlyMode: true,
};
export default class CheckboxSyncPlugin extends Plugin {
settings: CheckboxSyncPluginSettings;
private isProcessing = false;
checkboxUtils: CheckboxUtils;
async onload() {
await this.loadSettings();
this.checkboxUtils = new CheckboxUtils(this.settings);
this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this));
this.registerEvent(
this.app.workspace.on("file-open", async (file) => {
if (this.isProcessing || !(file instanceof TFile) || file.extension !== "md") return;
this.isProcessing = true;
try {
await this.syncFileCheckboxes(file);
} finally {
this.isProcessing = false;
}
})
);
this.registerEvent(
this.app.workspace.on("editor-change", (editor) => {
const updates = syncCheckboxes(editor.getValue());
const updates = this.checkboxUtils.syncCheckboxes(editor.getValue());
if (updates.length === 0) return;
const firstUpdateLine = updates[0].line;
// Перемещаем курсор на строку первого изменения (символ 0)
editor.setCursor({ line: firstUpdateLine, ch: editor.getLine(firstUpdateLine).length });
// Делаем курсор невидимым
editor.blur();
// Выполняем обновления текста
updates.forEach(({ line, ch, value }) => {
editor.replaceRange(value, { line, ch }, { line, ch: ch + 1 });
});
@ -38,7 +61,7 @@ export default class CheckboxSyncPlugin extends Plugin {
async syncFileCheckboxes(file: TFile) {
const text = await this.app.vault.read(file);
const updates = syncCheckboxes(text);
const updates = this.checkboxUtils.syncCheckboxes(text);
if (updates.length === 0) return;
const lines = text.split("\n");
@ -49,4 +72,18 @@ export default class CheckboxSyncPlugin extends Plugin {
const updatedText = lines.join("\n");
await this.app.vault.modify(file, updatedText);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.checkboxUtils.updateSettings(this.settings);
const activeFile = this.app.workspace.getActiveFile();
if (activeFile instanceof TFile && activeFile.extension === "md") {
await this.syncFileCheckboxes(activeFile);
}
}
}

3
src/types.ts Normal file
View file

@ -0,0 +1,3 @@
export interface CheckboxSyncPluginSettings {
xOnlyMode: boolean;
}