From 06b85267d87882b98e777764d701d52be22f451d Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Thu, 20 Mar 2025 00:24:35 +0300 Subject: [PATCH] fix cursor, add mutex, add tests, refactor --- __tests__/findCheckboxesLine.test.ts | 115 ++++++++++++++++++++++----- package-lock.json | 27 +++++-- package.json | 6 +- src/CheckboxManager.ts | 54 ------------- src/SyncController.ts | 38 +++++++++ src/checkboxUtils.ts | 12 ++- src/main.ts | 14 ++-- 7 files changed, 169 insertions(+), 97 deletions(-) delete mode 100644 src/CheckboxManager.ts create mode 100644 src/SyncController.ts diff --git a/__tests__/findCheckboxesLine.test.ts b/__tests__/findCheckboxesLine.test.ts index a02607a..952ce5f 100644 --- a/__tests__/findCheckboxesLine.test.ts +++ b/__tests__/findCheckboxesLine.test.ts @@ -10,7 +10,7 @@ describe("CheckboxUtils", () => { }); describe("findCheckboxesLine", () => { - test("распознаёт корректные строки с чекбоксами", () => { + test("recognizes valid checkbox lines", () => { expect(checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task")).not.toBeNull(); expect(checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task")).not.toBeNull(); expect(checkboxUtilsXOnly.findCheckboxesLine("- [x] Task")).not.toBeNull(); @@ -20,7 +20,7 @@ describe("CheckboxUtils", () => { expect(checkboxUtilsXOnly.findCheckboxesLine("1. [x] Numbered task")).not.toBeNull(); }); - test("корректно обрабатывает разные состояния чекбокса", () => { + test("correctly handles different checkbox states", () => { const states = [" ", "x", "-", "?", "!", "*", "#", "@", "[", "]", "y", "X", "Y"]; for (const state of states) { @@ -30,7 +30,7 @@ describe("CheckboxUtils", () => { } }); - test("корректно обрабатывает отступы", () => { + test("correctly handles indentation", () => { const noIndentMatch = checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task"); expect(noIndentMatch).not.toBeNull(); expect(noIndentMatch![1]).toBe(""); @@ -48,7 +48,7 @@ describe("CheckboxUtils", () => { expect(tabMatch![1]).toBe("\t"); }); - test("игнорирует некорректные строки", () => { + test("ignores invalid lines", () => { expect(checkboxUtilsXOnly.findCheckboxesLine("-[ ] Task")).toBeNull(); expect(checkboxUtilsXOnly.findCheckboxesLine("*[x] Task")).toBeNull(); expect(checkboxUtilsXOnly.findCheckboxesLine("1.[ ] Numbered task")).toBeNull(); @@ -58,53 +58,130 @@ describe("CheckboxUtils", () => { expect(checkboxUtilsXOnly.findCheckboxesLine("1. Some numbered task")).toBeNull(); expect(checkboxUtilsXOnly.findCheckboxesLine("")).toBeNull(); }); + + test("ignores lines with missing opening or closing brackets", () => { + // Missing opening bracket [ + expect(checkboxUtilsXOnly.findCheckboxesLine("- ] Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("* ] Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("+ ] Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("1. ] Task")).toBeNull(); + + // Missing closing bracket ] + expect(checkboxUtilsXOnly.findCheckboxesLine("- [x Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("* [ Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("+ [- Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("1. [? Task")).toBeNull(); + + // Malformed with spaces + expect(checkboxUtilsXOnly.findCheckboxesLine("- [ x Task")).toBeNull(); + expect(checkboxUtilsXOnly.findCheckboxesLine("- x] Task")).toBeNull(); + }); + + test("extracts list marker correctly", () => { + const dashMatch = checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task"); + expect(dashMatch).not.toBeNull(); + expect(dashMatch![2]).toBe("-"); + + const asteriskMatch = checkboxUtilsXOnly.findCheckboxesLine("* [ ] Task"); + expect(asteriskMatch).not.toBeNull(); + expect(asteriskMatch![2]).toBe("*"); + + const plusMatch = checkboxUtilsXOnly.findCheckboxesLine("+ [ ] Task"); + expect(plusMatch).not.toBeNull(); + expect(plusMatch![2]).toBe("+"); + + const numberedMatch = checkboxUtilsXOnly.findCheckboxesLine("1. [ ] Task"); + expect(numberedMatch).not.toBeNull(); + expect(numberedMatch![2]).toBe("1."); + }); }); describe("isCheckedSymbol", () => { - test("в режиме xOnlyMode только 'x' считается отмеченным", () => { + 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("в обычном режиме все символы кроме пробела считаются отмеченными", () => { + 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 utils = new CheckboxUtils({ xOnlyMode: true }); + expect(utils.isCheckedSymbol("-")).toBe(false); + + utils.updateSettings({ xOnlyMode: false }); + expect(utils.isCheckedSymbol("-")).toBe(true); + + utils.updateSettings({ xOnlyMode: true }); + expect(utils.isCheckedSymbol("-")).toBe(false); + }); }); describe("syncCheckboxes", () => { - test("обновляет родительский чекбокс, если все дочерние отмечены (xOnlyMode)", () => { + test("updates parent checkbox if all children are checked (xOnlyMode)", () => { const text = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]); + const expected = "- [x] Parent\n - [x] Child 1\n - [x] Child 2"; + expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual(expected); }); - test("обновляет родительский чекбокс, если все дочерние отмечены (spaceOnly)", () => { + test("updates parent checkbox if all children are checked (spaceOnly)", () => { const text = "- [ ] Parent\n - [?] Child 1\n - [-] Child 2"; - expect(checkboxUtilsSpaceOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]); + const expected = "- [x] Parent\n - [?] Child 1\n - [-] Child 2"; + expect(checkboxUtilsSpaceOnly.syncCheckboxes(text)).toEqual(expected); }); - test("снимает отметку у родителя, если не все дочерние отмечены", () => { + test("unchecks parent if not all children are checked", () => { const text = "- [x] Parent\n - [x] Child 1\n - [ ] Child 2"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: " " }]); + const expected = "- [ ] Parent\n - [x] Child 1\n - [ ] Child 2"; + expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual(expected); }); - test("не изменяет родителя, если у него нет дочерних элементов", () => { + test("doesn't change parent if it has no children", () => { const text = "- [x] Single"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([]); + expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual(text); }); - test("тройная вложенность: обновляет только промежуточный уровень", () => { + test("handles multiple levels of nesting correctly", () => { const text = "- [ ] Parent\n - [ ] Child\n - [x] Grandchild"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 1, ch: 5, value: "x" }]); + const expected = "- [x] Parent\n - [x] Child\n - [x] Grandchild"; + expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual(expected); }); - test("тройная вложенность: обновляет родительский чекбокс, если все вложенные отмечены", () => { - const text = "- [ ] Parent\n - [x] Child\n - [x] Grandchild"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual([{ line: 0, ch: 3, value: "x" }]); + 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.syncCheckboxes(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.syncCheckboxes(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.syncCheckboxes(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.syncCheckboxes(text)).toEqual(expected); }); }); }); diff --git a/package-lock.json b/package-lock.json index 0dc96c4..ac7b798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "checkbox-sync", "version": "1.0.0", "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0" + }, "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^16.11.6", @@ -220,14 +223,14 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" @@ -533,9 +536,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2211,6 +2214,15 @@ "dev": true, "license": "MIT" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -5623,7 +5635,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, "license": "0BSD" }, "node_modules/tsutils": { diff --git a/package.json b/package.json index a6340fa..e51a30c 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,7 @@ "name": "checkbox-sync", "version": "1.0.0", "description": "Automatically checks the parent checkbox if all child checkboxes are completed, and unchecks it otherwise", - "main": "main.js", - + "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", @@ -27,5 +26,8 @@ "ts-node": "^10.9.2", "tslib": "2.4.0", "typescript": "4.7.4" + }, + "dependencies": { + "async-mutex": "^0.5.0" } } diff --git a/src/CheckboxManager.ts b/src/CheckboxManager.ts deleted file mode 100644 index 71a9027..0000000 --- a/src/CheckboxManager.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Editor, TFile } from "obsidian"; -import CheckboxSyncPlugin from "./main"; - -export default class CheckboxManager { - private plugin: CheckboxSyncPlugin; - private isProcessingFile = false; - constructor(plugin: CheckboxSyncPlugin) { - this.plugin = plugin; - } - - async syncEditor(editor: Editor) { - await this.syncEditorCheckboxes(editor); - } - - async syncFile(file: any) { - if (this.isProcessingFile || !(file instanceof TFile) || file.extension !== "md") return; - this.isProcessingFile = true; - try { - await this.syncFileCheckboxes(file); - } finally { - this.isProcessingFile = false; - } - } - - private async syncEditorCheckboxes(editor: Editor) { - const updates = this.plugin.checkboxUtils.syncCheckboxes(editor.getValue()); - if (updates.length === 0) return; - - const firstUpdateLine = updates[0].line; - 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 }); - }); - } - - private async syncFileCheckboxes(file: TFile) { - let text = await this.plugin.app.vault.read(file); - let updates = this.plugin.checkboxUtils.syncCheckboxes(text); - if (updates.length === 0) return; - - while (true) { - const lines = text.split("\n"); - updates.forEach(({ line, ch, value }) => { - lines[line] = lines[line].substring(0, ch) + value + lines[line].substring(ch + 1); - }); - text = lines.join("\n"); - updates = this.plugin.checkboxUtils.syncCheckboxes(text); - if (updates.length === 0) break; - } - await this.plugin.app.vault.modify(file, text); - } -} \ No newline at end of file diff --git a/src/SyncController.ts b/src/SyncController.ts new file mode 100644 index 0000000..1d68479 --- /dev/null +++ b/src/SyncController.ts @@ -0,0 +1,38 @@ +import { Editor, TFile } from "obsidian"; +import CheckboxSyncPlugin from "./main"; +import { Mutex } from "async-mutex"; + +export default class SyncController { + private plugin: CheckboxSyncPlugin; + private mutex: Mutex; + constructor(plugin: CheckboxSyncPlugin) { + this.plugin = plugin; + this.mutex = new Mutex(); + } + + async syncEditor(editor: Editor) { + await this.mutex.runExclusive(() => { + const newText = this.plugin.checkboxUtils.syncCheckboxes(editor.getValue()); + if (newText === editor.getValue()) return; + + let cursor = editor.getCursor(); + editor.setValue(newText); + editor.setCursor(cursor); + }); + } + + async syncFile(file: TFile | null) { + if (!(file instanceof TFile) || file.extension !== "md") { + return; + } + await this.mutex.runExclusive(async () => { + let text = await this.plugin.app.vault.read(file); + let newText = this.plugin.checkboxUtils.syncCheckboxes(text); + if (newText === text) { + return; + } + await this.plugin.app.vault.modify(file, newText); + }); + + } +} \ No newline at end of file diff --git a/src/checkboxUtils.ts b/src/checkboxUtils.ts index 26c28a8..831c4da 100644 --- a/src/checkboxUtils.ts +++ b/src/checkboxUtils.ts @@ -1,7 +1,7 @@ import { CheckboxSyncPluginSettings } from "./types"; export class CheckboxUtils { - constructor(private settings: CheckboxSyncPluginSettings) {} + constructor(private settings: CheckboxSyncPluginSettings) { } updateSettings(settings: CheckboxSyncPluginSettings) { this.settings = settings; @@ -15,9 +15,8 @@ export class CheckboxUtils { return this.settings.xOnlyMode ? text === "x" : text !== " "; } - syncCheckboxes(text: string): { line: number; ch: number; value: string }[] { + syncCheckboxes(text: string): string{ const lines = text.split("\n"); - let updates: { line: number; ch: number; value: string }[] = []; for (let i = lines.length - 1; i >= 0; i--) { const match = this.findCheckboxesLine(lines[i]); @@ -44,13 +43,12 @@ export class CheckboxUtils { if (hasChildren) { const checkboxPos = match[1].length + match[2].length + 2; if (allChildrenChecked && !isChecked) { - updates.push({ line: i, ch: checkboxPos, value: "x" }); + lines[i] = lines[i].substring(0, checkboxPos) + "x" + lines[i].substring(checkboxPos + 1); } else if (!allChildrenChecked && isChecked) { - updates.push({ line: i, ch: checkboxPos, value: " " }); + lines[i] = lines[i].substring(0, checkboxPos) + " " + lines[i].substring(checkboxPos + 1); } } } - - return updates; + return lines.join("\n"); } } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index b56981f..10cf476 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { Plugin, TFile } from "obsidian"; import { CheckboxUtils } from "./checkboxUtils"; import { CheckboxSyncPluginSettingTab } from "./CheckboxSyncPluginSettingTab"; import { CheckboxSyncPluginSettings } from "./types"; -import CheckboxManager from "./CheckboxManager"; +import SyncController from "./SyncController"; const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { xOnlyMode: true, @@ -11,28 +11,28 @@ const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = { export default class CheckboxSyncPlugin extends Plugin { settings: CheckboxSyncPluginSettings; - checkboxManager: CheckboxManager; + syncController: SyncController; checkboxUtils: CheckboxUtils; async onload() { await this.loadSettings(); this.checkboxUtils = new CheckboxUtils(this.settings); - this.checkboxManager = new CheckboxManager(this); + this.syncController = new SyncController(this); this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this)); //запуск плагина при открытии файла this.registerEvent( - this.app.workspace.on("file-open", async (file) => await this.checkboxManager.syncFile(file)) + this.app.workspace.on("file-open", async (file) => await this.syncController.syncFile(file)) ); //запуск плагина при модификации файла(для обработки в режиме просмотра) this.registerEvent( - this.app.vault.on("modify", async (file) => await this.checkboxManager.syncFile(file)) + this.app.vault.on("modify", async (file: TFile) => await this.syncController.syncFile(file)) ); //запуск плагина при изменении в режиме редактора this.registerEvent( - this.app.workspace.on("editor-change", async (editor) => await this.checkboxManager.syncEditor(editor)) + this.app.workspace.on("editor-change", async (editor) => await this.syncController.syncEditor(editor)) ); } @@ -45,6 +45,6 @@ export default class CheckboxSyncPlugin extends Plugin { await this.saveData(this.settings); this.checkboxUtils.updateSettings(this.settings); const activeFile = this.app.workspace.getActiveFile(); - await this.checkboxManager.syncFile(activeFile); + await this.syncController.syncFile(activeFile); } } \ No newline at end of file