From 198adf253f64753477080a9f7fc37a6acb7b84c5 Mon Sep 17 00:00:00 2001 From: Grol Grol Date: Fri, 11 Apr 2025 18:07:53 +0300 Subject: [PATCH] stable? --- __tests__/checkboxUtils.test.ts | 570 ++++++++++++++++++ __tests__/findCheckboxesLine.test.ts | 116 ++-- jest.config.ts | 3 + package.json | 7 +- src/CheckboxSyncPluginSettingTab.ts | 7 +- src/FileStateHolder.ts | 39 +- src/SyncController.ts | 112 ++-- src/checkboxUtils.ts | 148 +++-- src/events/FileChangeEventHandler.ts | 34 ++ .../FileLoadEventHandler.ts} | 54 +- src/main.ts | 43 +- 11 files changed, 892 insertions(+), 241 deletions(-) create mode 100644 __tests__/checkboxUtils.test.ts create mode 100644 src/events/FileChangeEventHandler.ts rename src/{EventService.ts => events/FileLoadEventHandler.ts} (59%) diff --git a/__tests__/checkboxUtils.test.ts b/__tests__/checkboxUtils.test.ts new file mode 100644 index 0000000..ae05310 --- /dev/null +++ b/__tests__/checkboxUtils.test.ts @@ -0,0 +1,570 @@ +// checkboxUtils.test.ts +import { CheckboxUtils, CheckboxLineInfo } from '../src/checkboxUtils'; +import { CheckboxSyncPluginSettings } from '../src/types'; // Предполагаем, что типы в types.ts + +// --- Mock Settings --- +const defaultSettings: Readonly = { + xOnlyMode: false, // По умолчанию: любой не-пробел считается 'checked' +}; + +const xOnlySettings: Readonly = { + xOnlyMode: true, // Только 'x' считается 'checked' +}; + +// --- Test Suite --- +describe('CheckboxUtils', () => { + let utilsDefault: CheckboxUtils; + let utilsXOnly: CheckboxUtils; + + beforeEach(() => { + // Создаем новые экземпляры перед каждым тестом для изоляции + utilsDefault = new CheckboxUtils(defaultSettings); + utilsXOnly = new CheckboxUtils(xOnlySettings); + }); + + // --- matchCheckboxLine --- + describe('matchCheckboxLine', () => { + it.each([ + // Простые случаи + ['- [ ] Text', { indent: 0, marker: '-', checkChar: ' ', checkboxCharPosition: 3 }], + ['* [x] Task', { indent: 0, marker: '*', checkChar: 'x', checkboxCharPosition: 3 }], + ['+ [?] Query', { indent: 0, marker: '+', checkChar: '?', checkboxCharPosition: 3 }], + ['1. [!] Num', { indent: 0, marker: '1.', checkChar: '!', checkboxCharPosition: 4 }], + // С отступами + [' - [ ] Indented', { indent: 2, marker: '-', checkChar: ' ', checkboxCharPosition: 5 }], + ['\t* [x] Tab Indent', { indent: 1, marker: '*', checkChar: 'x', checkboxCharPosition: 4 }], // Зависит от размера таба, Jest может считать его 1 + [' 10. [A] Deep', { indent: 4, marker: '10.', checkChar: 'A', checkboxCharPosition: 9 }], + ])('should match valid checkbox line: %s', (line, expected) => { + const result = utilsDefault.matchCheckboxLine(line); + expect(result).toEqual(expected); + }); + + it.each([ + 'Not a list item', + '- Just text', + '-[] No space', + '- [No Char]', + '- [ ]', // Нет пробела после скобки, как требует регекс \s + ' Non-checkbox line', + '-[ ] No space before marker', + '1.[ ] No space after numbered marker', + '- ] Missing opening bracket', + '* [x Missing closing bracket', + ])('should return null for invalid lines: %s', (line) => { + expect(utilsDefault.matchCheckboxLine(line)).toBeNull(); + }); + + it('should require a space after the closing bracket', () => { + expect(utilsDefault.matchCheckboxLine('- [ ]')).toBeNull(); // Missing trailing space + expect(utilsDefault.matchCheckboxLine('- [ ] ')).not.toBeNull(); // Has trailing space + }); + + it.each([' ', 'x', '-', '?', '!'])('should correctly parse the checkChar', (state) => { + const lineInfo = utilsDefault.matchCheckboxLine(`- [${state}] Task`); + expect(lineInfo).not.toBeNull(); + expect(lineInfo?.checkChar).toBe(state); + }); + }); + + // --- isCheckedSymbol --- + describe('isCheckedSymbol', () => { + // Default mode (xOnlyMode: false) + it('should check symbols correctly in default mode', () => { + expect(utilsDefault.isCheckedSymbol('x')).toBe(true); + expect(utilsDefault.isCheckedSymbol('X')).toBe(true); + expect(utilsDefault.isCheckedSymbol('/')).toBe(true); + expect(utilsDefault.isCheckedSymbol('-')).toBe(true); + expect(utilsDefault.isCheckedSymbol('?')).toBe(true); + expect(utilsDefault.isCheckedSymbol(' ')).toBe(false); // Только пробел - не отмечено + }); + + // xOnly mode (xOnlyMode: true) + it('should check symbols correctly in xOnly mode', () => { + expect(utilsXOnly.isCheckedSymbol('x')).toBe(true); // Только 'x' - отмечено + expect(utilsXOnly.isCheckedSymbol('X')).toBe(false); + expect(utilsXOnly.isCheckedSymbol('/')).toBe(false); + expect(utilsXOnly.isCheckedSymbol('-')).toBe(false); + expect(utilsXOnly.isCheckedSymbol('?')).toBe(false); + expect(utilsXOnly.isCheckedSymbol(' ')).toBe(false); + }); + }); + + // --- updateLineCheckboxStateWithInfo --- + describe('updateLineCheckboxStateWithInfo', () => { + + it('should check an unchecked box', () => { + const uncheckedLine = ' - [ ] Task'; + const uncheckedLineInfo = utilsDefault.matchCheckboxLine(uncheckedLine)!; + const updatedLine = utilsDefault.updateLineCheckboxStateWithInfo(uncheckedLine, true, uncheckedLineInfo); + expect(updatedLine).toBe(' - [x] Task'); + }); + + it('should uncheck a checked box', () => { + const checkedLine = ' - [x] Task'; + const checkedLineInfo = utilsDefault.matchCheckboxLine(checkedLine)!; + const updatedLine = utilsDefault.updateLineCheckboxStateWithInfo(checkedLine, false, checkedLineInfo); + expect(updatedLine).toBe(' - [ ] Task'); + }); + + it('should handle other initial check chars', () => { + const otherLine = ' - [?] Task'; + const otherLineInfo = utilsDefault.matchCheckboxLine(otherLine)!; + expect(utilsDefault.updateLineCheckboxStateWithInfo(otherLine, true, otherLineInfo)).toBe(' - [x] Task'); + expect(utilsDefault.updateLineCheckboxStateWithInfo(otherLine, false, otherLineInfo)).toBe(' - [ ] Task'); + }); + + it('should return original line if position is invalid (and warn)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); // Подавить вывод в консоль + const uncheckedLine = ' - [ ] Task'; + const uncheckedLineInfo = utilsDefault.matchCheckboxLine(uncheckedLine)!; + const invalidInfo = { ...uncheckedLineInfo, checkboxCharPosition: 100 }; + const result = utilsDefault.updateLineCheckboxStateWithInfo(uncheckedLine, true, invalidInfo); + expect(result).toBe(uncheckedLine); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + // --- propagateStateToChildren --- + describe('propagateStateToChildren', () => { + const text = [ + '- [ ] Parent 1', // 0 + ' - [ ] Child 1.1', // 1 + ' - [x] Child 1.2', // 2 + ' - [ ] Grandchild 1.2.1', // 3 + '- [x] Parent 2', // 4 + ' - [x] Child 2.1', // 5 + ].join('\n'); + + it('should check children when parent is checked', () => { + // Имитируем проверку Parent 1 (индекс 0) + const lines = text.split('\n'); + lines[0] = '- [x] Parent 1'; // Manually check parent for the test input + const initialTextModified = lines.join('\n'); + + const result = utilsDefault.propagateStateToChildren(initialTextModified, 0); + const expected = [ + '- [x] Parent 1', // Checked + ' - [x] Child 1.1', // Updated + ' - [x] Child 1.2', // Updated + ' - [x] Grandchild 1.2.1', // Updated + '- [x] Parent 2', // Unchanged + ' - [x] Child 2.1', // Unchanged + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should uncheck children when parent is unchecked', () => { + // Имитируем снятие отметки с Parent 2 (индекс 4) + const lines = text.split('\n'); + lines[4] = '- [ ] Parent 2'; // Manually uncheck parent for the test input + const initialTextModified = lines.join('\n'); + + const result = utilsDefault.propagateStateToChildren(initialTextModified, 4); + const expected = [ + '- [ ] Parent 1', // Unchanged + ' - [ ] Child 1.1', // Unchanged + ' - [x] Child 1.2', // Unchanged + ' - [ ] Grandchild 1.2.1', // Unchanged + '- [ ] Parent 2', // Unchecked + ' - [ ] Child 2.1', // Updated + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should do nothing if the target line is not a checkbox', () => { + const textWithNonCheckbox = [ + '# Header', + '- [ ] Item 1' + ].join('\n'); + const result = utilsDefault.propagateStateToChildren(textWithNonCheckbox, 0); + expect(result).toBe(textWithNonCheckbox); + }); + + it('should stop only when indent is less than or equal to the original parent', () => { + const textSibling = [ + '- [ ] Parent', // 0, indent 0 + ' - [ ] Child 1', // 1, indent 2 + ' - [ ] Sibling C1', // 2, indent 2 (still > 0) + '- [ ] Parent 2' // 3, indent 0 (<= 0, stop here) + ].join('\n'); + const lines = textSibling.split('\n'); + lines[0] = '- [x] Parent'; // Make parent checked + const modifiedText = lines.join('\n'); + const result = utilsDefault.propagateStateToChildren(modifiedText, 0); + const expected = [ + '- [x] Parent', + ' - [x] Child 1', // Changed (indent 2 > 0) + ' - [x] Sibling C1', // <<-- ALSO Changed (indent 2 > 0) + '- [ ] Parent 2' // Unchanged (indent 0 <= 0) + ].join('\n'); + expect(result).toBe(expected); // Проверяем обновленное ожидание + }); + }); + + // --- propagateStateFromChildren --- + describe('propagateStateFromChildren', () => { + it('should check parent if all children are checked', () => { + const text = [ + '- [ ] Parent', // Should become [x] + ' - [x] Child 1', + ' - [x] Child 2', + ' - [x] Grandchild 2.1', // Needs to be checked too + ].join('\n'); + const result = utilsDefault.propagateStateFromChildren(text); + const expected = [ + '- [x] Parent', // Updated + ' - [x] Child 1', + ' - [x] Child 2', + ' - [x] Grandchild 2.1', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should uncheck parent if any child is unchecked', () => { + const text = [ + '- [x] Parent', // Should become [ ] + ' - [x] Child 1', + ' - [ ] Child 2', // This one makes parent unchecked + ].join('\n'); + const result = utilsDefault.propagateStateFromChildren(text); + const expected = [ + '- [ ] Parent', // Updated + ' - [x] Child 1', + ' - [ ] Child 2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should handle nested propagation', () => { + const text = [ + '- [ ] Grandparent', // Should become [ ] + ' - [x] Parent 1', // Should become [ ] + ' - [x] Child 1.1', + ' - [ ] Child 1.2', // Makes Parent 1 unchecked + ' - [x] Parent 2', // Stays checked + ' - [x] Child 2.1', + ].join('\n'); + const result = utilsDefault.propagateStateFromChildren(text); + const expected = [ + '- [ ] Grandparent', // Updated (because Parent 1 is now [ ]) + ' - [ ] Parent 1', // Updated + ' - [x] Child 1.1', + ' - [ ] Child 1.2', + ' - [x] Parent 2', + ' - [x] Child 2.1', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should not change parent state if it has no children', () => { + const text = [ + '- [ ] Parent No Children', + '- [x] Parent No Children Checked', + ' Some other line', + ].join('\n'); + const result = utilsDefault.propagateStateFromChildren(text); + expect(result).toBe(text); // No changes expected + }); + + it('should work correctly with xOnlyMode', () => { + const text = [ + '- [ ] Parent', // Should become [x] in xOnly mode + ' - [x] Child 1', + ' - [x] Child 2' + ].join('\n'); + const result = utilsXOnly.propagateStateFromChildren(text); + expect(result).toContain('- [x] Parent'); // Parent checked + + const text2 = [ + '- [x] Parent', // Should become [ ] because '?' is not 'x' + ' - [x] Child 1', + ' - [?] Child 2' + ].join('\n'); + const result2 = utilsXOnly.propagateStateFromChildren(text2); + expect(result2).toContain('- [ ] Parent'); // Parent unchecked + }); + + it('should handle multiple independent parent checklists', () => { + const text = [ + '- [ ] Parent 1', + ' - [x] Child 1.1', + 'Some other text', + '* [ ] Parent 2', + ' * [x] Child 2.1', + ' * [x] Child 2.2', + ].join('\n'); + const result = utilsDefault.propagateStateFromChildren(text); + const expected = [ + '- [x] Parent 1', // Updated + ' - [x] Child 1.1', + 'Some other text', + '* [x] Parent 2', // Updated + ' * [x] Child 2.1', + ' * [x] Child 2.2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should stop searching for children for a parent when a line with equal or lesser indent is encountered', () => { + const text = [ + '- [ ] Parent 1', // indent 0 + 'Some regular text', // indent 0. Stops search for Parent 1 children HERE. + ' - [x] Child 1.1', // indent 2. Ignored for Parent 1 because search stopped. + '- [ ] Parent 2', // indent 0. Processed independently later. + ' - [x] Child 2.1', // indent 2. Child of Parent 2. + ].join('\n'); + + const result = utilsDefault.propagateStateFromChildren(text); + + // Ожидания: + // - Parent 1 не найдет детей (из-за "Some regular text") и останется [ ]. + // - Parent 2 найдет Child 2.1 ([x]) и станет [x]. + const expected = [ + '- [ ] Parent 1', // Unchanged + 'Some regular text', // Unchanged + ' - [x] Child 1.1', // Unchanged + '- [x] Parent 2', // Updated because Child 2.1 is checked + ' - [x] Child 2.1', // Unchanged + ].join('\n'); + + expect(result).toBe(expected); + }); + + it('should stop searching for children when a sibling checkbox is encountered', () => { + const text = [ + '- [ ] Parent 1', // indent 0 + '- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children HERE. + ' - [x] Child 1.1', // indent 2. Belongs to Sibling Parent if processed. + ].join('\n'); + + const result = utilsDefault.propagateStateFromChildren(text); + + // Ожидания: + // - Parent 1 не найдет детей (из-за "Sibling Parent") и останется [ ]. + // - Sibling Parent найдет Child 1.1 ([x]) и станет [x]. + const expected = [ + '- [ ] Parent 1', // Unchanged + '- [x] Sibling Parent', // Updated because Child 1.1 is checked + ' - [x] Child 1.1', // Unchanged + ].join('\n'); + + expect(result).toBe(expected); + }); + + }); + + // --- propagateStateToChildrenFromSingleDiff --- + describe('propagateStateToChildrenFromSingleDiff', () => { + const textBefore = [ + '- [ ] Parent', // 0 + ' - [ ] Child', // 1 + ].join('\n'); + + it('should propagate down if single line changed and it was a checkbox toggle', () => { + const textAfter = [ + '- [x] Parent', // Changed state + ' - [ ] Child', + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, textBefore); + const expected = [ // Expect children to be updated + '- [x] Parent', + ' - [x] Child', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should NOT propagate down if single line changed but it was NOT checkbox state', () => { + const textAfter = [ + '- [ ] Parent Edited Text', // Text changed, not checkbox + ' - [ ] Child', + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, textBefore); + // Expect original textAfter, no downward propagation + expect(result).toBe(textAfter); + }); + + it('should NOT propagate down if single line changed into non-checkbox', () => { + const textAfter = [ + 'Parent removed checkbox', // Structure changed + ' - [ ] Child', + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, textBefore); + expect(result).toBe(textAfter); + }); + + + it('should NOT propagate down if multiple lines changed', () => { + const textAfter = [ + '- [x] Parent', // Changed + ' - [x] Child', // Changed + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, textBefore); + expect(result).toBe(textAfter); // Expect original textAfter + }); + + it('should NOT propagate down if line counts differ', () => { + const textAfter = [ + '- [x] Parent', + ' - [ ] Child', + ' - [ ] New Child', + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, textBefore); + expect(result).toBe(textAfter); // Expect original textAfter + }); + + it('should NOT propagate down if textBefore is undefined', () => { + const textAfter = [ + '- [x] Parent', + ' - [ ] Child', + ].join('\n'); + const result = utilsDefault.propagateStateToChildrenFromSingleDiff(textAfter, undefined); + expect(result).toBe(textAfter); // Expect original textAfter + }); + }); + + // --- findDifferentLineIndexes --- + describe('findDifferentLineIndexes', () => { + const lines1 = ['a', 'b', 'c']; + + it('should return empty array for identical lines', () => { + const lines2 = ['a', 'b', 'c']; + expect(utilsDefault.findDifferentLineIndexes(lines1, lines2)).toEqual([]); + }); + + it('should return index of the single different line', () => { + const lines2 = ['a', 'B', 'c']; + expect(utilsDefault.findDifferentLineIndexes(lines1, lines2)).toEqual([1]); + }); + + it('should return indexes of multiple different lines', () => { + const lines2 = ['A', 'b', 'C']; + expect(utilsDefault.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]); + }); + + it('should throw error if line lengths are different', () => { + const lines2 = ['a', 'b']; + expect(() => utilsDefault.findDifferentLineIndexes(lines1, lines2)).toThrow( + 'the length of the lines must be equal' + ); + }); + }); + + + // --- syncText (Integration) --- + describe('syncText', () => { + it('should handle user checking parent (down -> up)', () => { + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child 1', + ' - [ ] Child 2', + ].join('\n'); + const textAfterUserCheck = [ + '- [x] Parent', // User checked this + ' - [ ] Child 1', + ' - [ ] Child 2', + ].join('\n'); + + const result = utilsDefault.syncText(textAfterUserCheck, textBefore); + const expected = [ // Down propagation first, then up (which changes nothing here) + '- [x] Parent', + ' - [x] Child 1', + ' - [x] Child 2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should handle user unchecking parent (down -> up)', () => { + const textBefore = [ + '- [x] Parent', + ' - [x] Child 1', + ' - [x] Child 2', + ].join('\n'); + const textAfterUserUncheck = [ + '- [ ] Parent', // User unchecked this + ' - [x] Child 1', + ' - [x] Child 2', + ].join('\n'); + + const result = utilsDefault.syncText(textAfterUserUncheck, textBefore); + const expected = [ // Down propagation first, then up + '- [ ] Parent', + ' - [ ] Child 1', + ' - [ ] Child 2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should handle user checking the last child, making parent checked (no down -> up)', () => { + const textBefore = [ + '- [ ] Parent', + ' - [x] Child 1', + ' - [ ] Child 2', // User will check this + ].join('\n'); + const textAfterUserCheck = [ + '- [ ] Parent', + ' - [x] Child 1', + ' - [x] Child 2', // User checked this + ].join('\n'); + + const result = utilsDefault.syncText(textAfterUserCheck, textBefore); + // SingleDiff won't trigger down prop. FromChildren runs. + const expected = [ + '- [x] Parent', // Updated by FromChildren + ' - [x] Child 1', + ' - [x] Child 2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should handle user unchecking one child, making parent unchecked (no down -> up)', () => { + const textBefore = [ + '- [x] Parent', + ' - [x] Child 1', // User will uncheck this + ' - [x] Child 2', + ].join('\n'); + const textAfterUserUncheck = [ + '- [x] Parent', + ' - [ ] Child 1', // User unchecked this + ' - [x] Child 2', + ].join('\n'); + + const result = utilsDefault.syncText(textAfterUserUncheck, textBefore); + // SingleDiff won't trigger down prop. FromChildren runs. + const expected = [ + '- [ ] Parent', // Updated by FromChildren + ' - [ ] Child 1', + ' - [x] Child 2', + ].join('\n'); + expect(result).toBe(expected); + }); + + it('should handle multiple changes correctly (no down -> up)', () => { + const textBefore = [ + '- [ ] Parent', + ' - [ ] Child 1', + ' - [ ] Child 2', + ' - [ ] Child 3', + ].join('\n'); + // User checks Parent AND Child 1, but leaves Child 2 unchecked + const textAfterMultipleChanges = [ + '- [x] Parent', // Changed by user + ' - [x] Child 1', // Changed by user + ' - [ ] Child 2', // Left unchecked + ' - [ ] Child 3', // Left unchecked + ].join('\n'); + + const result = utilsDefault.syncText(textAfterMultipleChanges, textBefore); + // SingleDiff won't run (2 changes). FromChildren runs. + // Since Child 2/3 are unchecked, Parent should become unchecked. + const expected = [ + '- [ ] Parent', // Corrected by FromChildren + ' - [x] Child 1', + ' - [ ] Child 2', + ' - [ ] Child 3', + ].join('\n'); + expect(result).toBe(expected); + }); + + }); +}); \ No newline at end of file diff --git a/__tests__/findCheckboxesLine.test.ts b/__tests__/findCheckboxesLine.test.ts index 952ce5f..cd42edb 100644 --- a/__tests__/findCheckboxesLine.test.ts +++ b/__tests__/findCheckboxesLine.test.ts @@ -1,6 +1,6 @@ import { CheckboxUtils } from "../src/checkboxUtils"; -describe("CheckboxUtils", () => { +describe("CheckboxUtils old", () => { let checkboxUtilsXOnly: CheckboxUtils; let checkboxUtilsSpaceOnly: CheckboxUtils; @@ -11,88 +11,88 @@ describe("CheckboxUtils", () => { describe("findCheckboxesLine", () => { test("recognizes valid checkbox lines", () => { - 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(); + 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 match = checkboxUtilsXOnly.findCheckboxesLine(`- [${state}] Task`); - expect(match).not.toBeNull(); - expect(match![3]).toBe(state); + const lineInfo = checkboxUtilsXOnly.matchCheckboxLine(`- [${state}] Task`); + expect(lineInfo).not.toBeNull(); + expect(lineInfo?.checkChar).toBe(state); } }); test("correctly handles indentation", () => { - const noIndentMatch = checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task"); + const noIndentMatch = checkboxUtilsXOnly.matchCheckboxLine("- [ ] Task"); expect(noIndentMatch).not.toBeNull(); - expect(noIndentMatch![1]).toBe(""); + expect(noIndentMatch!.indent).toBe("".length); - const singleSpaceMatch = checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task"); + const singleSpaceMatch = checkboxUtilsXOnly.matchCheckboxLine(" - [ ] Task"); expect(singleSpaceMatch).not.toBeNull(); - expect(singleSpaceMatch![1]).toBe(" "); + expect(singleSpaceMatch!.indent).toBe(" ".length); - const multiSpaceMatch = checkboxUtilsXOnly.findCheckboxesLine(" - [ ] Task"); + const multiSpaceMatch = checkboxUtilsXOnly.matchCheckboxLine(" - [ ] Task"); expect(multiSpaceMatch).not.toBeNull(); - expect(multiSpaceMatch![1]).toBe(" "); + expect(multiSpaceMatch!.indent).toBe(" ".length); - const tabMatch = checkboxUtilsXOnly.findCheckboxesLine("\t- [ ] Task"); + const tabMatch = checkboxUtilsXOnly.matchCheckboxLine("\t- [ ] Task"); expect(tabMatch).not.toBeNull(); - expect(tabMatch![1]).toBe("\t"); + expect(tabMatch!.indent).toBe("\t".length); }); test("ignores invalid lines", () => { - 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(); + 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.findCheckboxesLine("- ] Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("* ] Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("+ ] Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("1. ] Task")).toBeNull(); + 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.findCheckboxesLine("- [x Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("* [ Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("+ [- Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("1. [? Task")).toBeNull(); + 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.findCheckboxesLine("- [ x Task")).toBeNull(); - expect(checkboxUtilsXOnly.findCheckboxesLine("- x] Task")).toBeNull(); + expect(checkboxUtilsXOnly.matchCheckboxLine("- [ x Task")).toBeNull(); + expect(checkboxUtilsXOnly.matchCheckboxLine("- x] Task")).toBeNull(); }); test("extracts list marker correctly", () => { - const dashMatch = checkboxUtilsXOnly.findCheckboxesLine("- [ ] Task"); + const dashMatch = checkboxUtilsXOnly.matchCheckboxLine("- [ ] Task"); expect(dashMatch).not.toBeNull(); - expect(dashMatch![2]).toBe("-"); + expect(dashMatch!.marker).toBe("-"); - const asteriskMatch = checkboxUtilsXOnly.findCheckboxesLine("* [ ] Task"); + const asteriskMatch = checkboxUtilsXOnly.matchCheckboxLine("* [ ] Task"); expect(asteriskMatch).not.toBeNull(); - expect(asteriskMatch![2]).toBe("*"); + expect(asteriskMatch!.marker).toBe("*"); - const plusMatch = checkboxUtilsXOnly.findCheckboxesLine("+ [ ] Task"); + const plusMatch = checkboxUtilsXOnly.matchCheckboxLine("+ [ ] Task"); expect(plusMatch).not.toBeNull(); - expect(plusMatch![2]).toBe("+"); + expect(plusMatch!.marker).toBe("+"); - const numberedMatch = checkboxUtilsXOnly.findCheckboxesLine("1. [ ] Task"); + const numberedMatch = checkboxUtilsXOnly.matchCheckboxLine("1. [ ] Task"); expect(numberedMatch).not.toBeNull(); - expect(numberedMatch![2]).toBe("1."); + expect(numberedMatch!.marker).toBe("1."); }); }); @@ -119,13 +119,15 @@ describe("CheckboxUtils", () => { describe("updateSettings", () => { test("updates xOnlyMode setting correctly", () => { - const utils = new CheckboxUtils({ xOnlyMode: true }); + + const settings = { xOnlyMode: true }; + const utils = new CheckboxUtils(settings); expect(utils.isCheckedSymbol("-")).toBe(false); - utils.updateSettings({ xOnlyMode: false }); + settings.xOnlyMode = false; expect(utils.isCheckedSymbol("-")).toBe(true); - utils.updateSettings({ xOnlyMode: true }); + settings.xOnlyMode = true; expect(utils.isCheckedSymbol("-")).toBe(false); }); }); @@ -134,54 +136,54 @@ describe("CheckboxUtils", () => { 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected); }); test("doesn't change parent if it has no children", () => { const text = "- [x] Single"; - expect(checkboxUtilsXOnly.syncCheckboxes(text)).toEqual(text); + 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + 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.syncCheckboxes(text)).toEqual(expected); + expect(checkboxUtilsXOnly.propagateStateFromChildren(text)).toEqual(expected); }); }); }); diff --git a/jest.config.ts b/jest.config.ts index bc89638..048dde7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -33,6 +33,9 @@ const config: Config = { testEnvironment: "node", + // Limit the number of workers that run tests in parallel + maxWorkers: 1, // <--- Задает последовательное выполнение + }; export default config; diff --git a/package.json b/package.json index e51a30c..6672a35 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "prebuild": "npm test", + "prebuild": "npm run test", "version": "node version-bump.mjs && git add manifest.json versions.json", - "test": "jest" + "test": "jest", + "test:fast": "jest --no-coverage" }, "keywords": [], "author": "", @@ -30,4 +31,4 @@ "dependencies": { "async-mutex": "^0.5.0" } -} +} \ No newline at end of file diff --git a/src/CheckboxSyncPluginSettingTab.ts b/src/CheckboxSyncPluginSettingTab.ts index b4c8ad3..a555069 100644 --- a/src/CheckboxSyncPluginSettingTab.ts +++ b/src/CheckboxSyncPluginSettingTab.ts @@ -12,7 +12,7 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { 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") @@ -20,8 +20,9 @@ export class CheckboxSyncPluginSettingTab extends PluginSettingTab { toggle .setValue(this.plugin.settings.xOnlyMode) .onChange(async (value) => { - this.plugin.settings.xOnlyMode = value; - await this.plugin.saveSettings(); + await this.plugin.updateSettings((settings) => { + settings.xOnlyMode = value; + }); }) ); } diff --git a/src/FileStateHolder.ts b/src/FileStateHolder.ts index d5abeb2..a488d11 100644 --- a/src/FileStateHolder.ts +++ b/src/FileStateHolder.ts @@ -13,27 +13,26 @@ export default class FileStateHolder { this.mutex = new Mutex(); } - async update(file: TFile) { - await this.mutex.runExclusive(async () => { - const text = await this.vault.read(file); - this.set(file, text); - }); - } - - // возвращает понадобилась ли загрузка - async updateIfNeeded(file: TFile, text?: string): Promise { + /** + * Initializes the file's data if it hasn't been initialized yet. + * + * @param file - The file object to initialize. + * @param text - Optional text content to associate with the file. If not provided, it will be read from the vault. + * @returns A promise that resolves to `true` if the file was initialized, or `false` if it was already initialized. + */ + async initIfNeeded(file: TFile, text?: string): Promise { if (this.has(file)) { - return false; + return false; } let res = await this.mutex.runExclusive(async () => { if (this.has(file)) { return false; } - console.log(`updateIfNeeded "${file.name}" start`); - if (!text){ + // console.log(`updateIfNeeded "${file.name}" start`); + if (!text) { text = await this.vault.read(file); - } - this.set(file, text); + } + this.set(file, text); return true; }); return res; @@ -44,11 +43,19 @@ export default class FileStateHolder { } set(file: TFile, text: string) { - console.log(`File "${file.name}" load to holder`); - this.map.set(file, text); + if (text !== this.map.get(file)) { + console.log(`File "${file.name}" load to holder`); + this.map.set(file, text); + } } get(file: TFile) { return this.map.get(file); } + + getAllFiles(): TFile[] { + const keysIterator = this.map.keys(); + const keysArray = Array.from(keysIterator); + return keysArray; + } } \ No newline at end of file diff --git a/src/SyncController.ts b/src/SyncController.ts index 95bfe22..0285ce8 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -1,18 +1,19 @@ import { Mutex } from "async-mutex"; -import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile } from "obsidian"; -import CheckboxSyncPlugin from "./main"; +import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian"; import { CheckboxUtils } from "./checkboxUtils"; import FileStateHolder from "./FileStateHolder"; export default class SyncController { - private plugin: CheckboxSyncPlugin;//delete + // private plugin: CheckboxSyncPlugin;//delete + private vault: Vault; private checkboxUtils: CheckboxUtils; private fileStateHolder: FileStateHolder; private mutex: Mutex; - constructor(plugin: CheckboxSyncPlugin, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder) { - this.plugin = plugin;//delete + constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder) { + // this.plugin = plugin;//delete + this.vault = vault; this.checkboxUtils = checkboxUtils; this.fileStateHolder = fileStateHolder; this.mutex = new Mutex(); @@ -28,15 +29,15 @@ export default class SyncController { const text = editor.getValue(); const textBefore = this.fileStateHolder.get(file); - let newText = this.syncText(text, textBefore); + let newText = this.checkboxUtils.syncText(text, textBefore); if (newText === text) { console.log(`sync editor "${file.basename}" stop. new text equals old text.`); return; } - this.editEditor(editor, text, newText); this.fileStateHolder.set(file, newText); - + this.editEditor(editor, text, newText); + console.log(`syncEditor "${file.basename}" stop.`); }); } @@ -47,87 +48,46 @@ export default class SyncController { } await this.mutex.runExclusive(async () => { console.log(`sync file "${file.basename}" start.`); - let text = await this.plugin.app.vault.read(file); - let textBefore = this.fileStateHolder.get(file); - - let newText = this.syncText(text, textBefore); - - if (newText === text) { - console.log(`sync file "${file.basename}" stop. new text equals old text.`); - return; - } + const newText = await this.vault.process(file, (text) => { + let textBefore = this.fileStateHolder.get(file); + let newText = this.checkboxUtils.syncText(text, textBefore); + return newText; + }); this.fileStateHolder.set(file, newText); - await this.plugin.app.vault.modify(file, newText); console.log(`sync file "${file.basename}" stop.`); }); } - syncText(text: string, textBefore: string | undefined): string { - let newText; - newText = this.syncTextBefore(text, textBefore); - newText = this.checkboxUtils.syncCheckboxes(newText); - return newText; - } - - syncTextBefore(text: string, textBefore: string | undefined): string { - if (!textBefore) return text; - const beforeLines = textBefore.split('\n'); - const textLines = text.split('\n'); - - if (beforeLines.length !== textLines.length) return text; - - const diffs = this.findDifferentLineIndexes(beforeLines, textLines); - if (diffs.length !== 1) { - return text; - } - return this.checkboxUtils.syncCheckboxesAfterDifferentLine(text, diffs[0]); - } - - findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { - if (lines1.length !== lines2.length) { - throw new Error("the length of the lines must be equal"); - } - - const length = lines1.length; - const result: number[] = []; - for (let i = 0; i < length; i++) { - if (lines1[i] !== lines2[i]) { - result.push(i); - } - } - return result; - } - - editEditor(editor: Editor, oldText: string, newText: string){ + private editEditor(editor: Editor, oldText: string, newText: string) { const cursor = editor.getCursor(); - const newLines = newText.split("\n"); - const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const oldLines = oldText.split("\n"); - const diffIndexes = this.findDifferentLineIndexes(oldLines, newLines); + const diffIndexes = this.checkboxUtils.findDifferentLineIndexes(oldLines, newLines); - const changes: EditorChange[] = []; + const changes: EditorChange[] = []; - for (let ind of diffIndexes) { - changes.push({ - from: { line: ind, ch: 0 }, - to: { line: ind, ch: oldLines[ind].length }, - text: newLines[ind] - }); - } - editor.transaction({ - changes: changes + for (let ind of diffIndexes) { + changes.push({ + from: { line: ind, ch: 0 }, + to: { line: ind, ch: oldLines[ind].length }, + text: newLines[ind] }); + } + editor.transaction({ + changes: changes + }); - editor.setCursor(cursor); + editor.setCursor(cursor); - const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1; - if (lastDifferentLineIndex != -1) { - editor.scrollIntoView({ - from: { line: lastDifferentLineIndex, ch: 0 }, - to: { line: lastDifferentLineIndex, ch: 0 } - }); - } + const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1; + if (lastDifferentLineIndex != -1) { + editor.scrollIntoView({ + from: { line: lastDifferentLineIndex, ch: 0 }, + to: { line: lastDifferentLineIndex, ch: 0 } + }); + } } } \ No newline at end of file diff --git a/src/checkboxUtils.ts b/src/checkboxUtils.ts index f785ac1..159435a 100644 --- a/src/checkboxUtils.ts +++ b/src/checkboxUtils.ts @@ -1,67 +1,90 @@ import { CheckboxSyncPluginSettings } from "./types"; -export class CheckboxUtils { - constructor(private settings: CheckboxSyncPluginSettings) { } +export interface CheckboxLineInfo { + indent: number; + marker: string; + checkChar: string; + checkboxCharPosition: number; +} - updateSettings(settings: CheckboxSyncPluginSettings) { +export class CheckboxUtils { + private settings: Readonly; + constructor(settings: Readonly) { this.settings = settings; } - findCheckboxesLine(line: string): RegExpMatchArray | null { - return line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/); + matchCheckboxLine(line: string): CheckboxLineInfo | null { + const match = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/); + if (!match) { + return null; + } + const indent = match[1].length; + const marker = match[2]; + const checkChar = match[3]; + const checkboxCharPosition = indent + marker.length + 2; + + return { + indent, + marker, + checkChar, + checkboxCharPosition + }; } isCheckedSymbol(text: string): boolean { return this.settings.xOnlyMode ? text === "x" : text !== " "; } - syncCheckboxesAfterDifferentLine(text: string, line: number): string { - console.log("syncCheckboxesAfterDifferentLine"); + updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string { + const newCheckChar = shouldBeChecked ? "x" : " "; + const pos = lineInfo.checkboxCharPosition; + if (pos >= 0 && pos < line.length) { + return line.substring(0, pos) + newCheckChar + line.substring(pos + 1); + } + console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line); + return line; + } + + propagateStateToChildren(text: string, parentLine: number): string { + // console.log("propagateStateToChildren"); const lines = text.split("\n"); - const match = this.findCheckboxesLine(lines[line]); - if (!match) { - console.log(`checkbox not found in line ${line}`) + const parentLineInfo = this.matchCheckboxLine(lines[parentLine]); + if (!parentLineInfo) { + console.log(`checkbox not found in line ${parentLine}`) return text; } - const indent = match[1].length; - const isChecked = this.isCheckedSymbol(match[3]); - let j = line + 1; + const isChecked = this.isCheckedSymbol(parentLineInfo.checkChar); + let j = parentLine + 1; while (j < lines.length) { - const childMatch = this.findCheckboxesLine(lines[j]); - if (!childMatch || childMatch[1].length <= indent) break; - const checkboxPos = childMatch[1].length + childMatch[2].length + 2; - if (isChecked) { - lines[j] = lines[j].substring(0, checkboxPos) + "x" + lines[j].substring(checkboxPos + 1); - } else { - lines[j] = lines[j].substring(0, checkboxPos) + " " + lines[j].substring(checkboxPos + 1); - } + const childLineInfo = this.matchCheckboxLine(lines[j]); + if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break; + lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], isChecked, childLineInfo); j++; } return lines.join("\n"); } - syncCheckboxes(text: string): string { + propagateStateFromChildren(text: string): string { const lines = text.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { - const match = this.findCheckboxesLine(lines[i]); - if (!match) continue; + const parentLineInfo = this.matchCheckboxLine(lines[i]); + if (!parentLineInfo) continue; - const indent = match[1].length; - const isChecked = this.isCheckedSymbol(match[3]); + const isChecked = this.isCheckedSymbol(parentLineInfo.checkChar); 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; + const childLineInfo = this.matchCheckboxLine(lines[j]); + if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break; hasChildren = true; - const childrenIsChecked = this.isCheckedSymbol(childMatch[3]); + const childrenIsChecked = this.isCheckedSymbol(childLineInfo.checkChar); if (!childrenIsChecked) { allChildrenChecked = false; break; @@ -69,15 +92,68 @@ export class CheckboxUtils { j++; } - if (hasChildren) { - const checkboxPos = match[1].length + match[2].length + 2; - if (allChildrenChecked && !isChecked) { - lines[i] = lines[i].substring(0, checkboxPos) + "x" + lines[i].substring(checkboxPos + 1); - } else if (!allChildrenChecked && isChecked) { - lines[i] = lines[i].substring(0, checkboxPos) + " " + lines[i].substring(checkboxPos + 1); - } + if (hasChildren && isChecked !== allChildrenChecked) { + lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allChildrenChecked, parentLineInfo); } } return lines.join("\n"); } + + syncText(text: string, textBefore: string | undefined): string { + let newText; + newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore); + newText = this.propagateStateFromChildren(newText); + return newText; + } + + propagateStateToChildrenFromSingleDiff(text: string, textBefore: string | undefined): string { + if (!textBefore) return text; + const textBeforeLines = textBefore.split('\n'); + const textLines = text.split('\n'); + + if (textBeforeLines.length !== textLines.length) return text; + + const diffIndexes = this.findDifferentLineIndexes(textBeforeLines, textLines); + if (diffIndexes.length !== 1) { + return text; + } + + const index = diffIndexes[0]; + + const lineBefore = textBeforeLines[index]; + const lineAfter = textLines[index]; + // Получаем информацию о чекбоксе ДО и ПОСЛЕ изменения + const lineInfoBefore = this.matchCheckboxLine(lineBefore); + const lineInfoAfter = this.matchCheckboxLine(lineAfter); + // Проверяем, что: + // 1. Обе строки (до и после) являются валидными строками с чекбоксом. + // 2. Отступ и маркер списка не изменились (т.е. структура строки та же). + // 3. Изменился именно символ внутри скобок [ ]. + if ( + lineInfoBefore && lineInfoAfter && + lineInfoBefore.indent === lineInfoAfter.indent && + lineInfoBefore.marker === lineInfoAfter.marker && + lineInfoBefore.checkChar !== lineInfoAfter.checkChar + ) { + return this.propagateStateToChildren(text, diffIndexes[0]); + } + return text; + + } + + findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] { + if (lines1.length !== lines2.length) { + throw new Error("the length of the lines must be equal"); + } + + const length = lines1.length; + const result: number[] = []; + for (let i = 0; i < length; i++) { + if (lines1[i] !== lines2[i]) { + result.push(i); + } + } + return result; + } + } \ No newline at end of file diff --git a/src/events/FileChangeEventHandler.ts b/src/events/FileChangeEventHandler.ts new file mode 100644 index 0000000..65622f7 --- /dev/null +++ b/src/events/FileChangeEventHandler.ts @@ -0,0 +1,34 @@ +import { App, TFile } from "obsidian"; +import CheckboxSyncPlugin from "src/main"; +import SyncController from "src/SyncController"; + +export class FileChangeEventHandler { + private plugin: CheckboxSyncPlugin; + private app: App; + private syncController: SyncController; + + constructor(plugin: CheckboxSyncPlugin, app: App, syncController: SyncController) { + this.plugin = plugin; + this.app = app; + this.syncController = syncController; + } + + registerEvents() { + //запуск плагина при модификации файла(для обработки в режиме просмотра) + this.plugin.registerEvent( + this.app.vault.on("modify", async (file) => { + if (!(file instanceof TFile)) return; + if (file.extension !== "md") return; + console.log(`modify file ${file.path}`); + await this.syncController.syncFile(file); + }) + ); + //запуск плагина при изменении в режиме редактора + this.plugin.registerEvent( + this.app.workspace.on("editor-change", async (editor, info) => { + console.log(`editor-change file ${info.file?.path}`); + await this.syncController.syncEditor(editor, info); + }) + ); + } +} \ No newline at end of file diff --git a/src/EventService.ts b/src/events/FileLoadEventHandler.ts similarity index 59% rename from src/EventService.ts rename to src/events/FileLoadEventHandler.ts index e7fe7dd..947cf24 100644 --- a/src/EventService.ts +++ b/src/events/FileLoadEventHandler.ts @@ -1,22 +1,23 @@ +import { Mutex } from "async-mutex"; import { App, MarkdownPostProcessorContext, MarkdownRenderer, MarkdownView, TFile, WorkspaceLeaf } from "obsidian"; -import CheckboxSyncPlugin from "./main"; -import SyncController from "./SyncController"; -import FileStateHolder from "./FileStateHolder"; -import { text } from "stream/consumers"; +import FileStateHolder from "src/FileStateHolder"; +import CheckboxSyncPlugin from "src/main"; +import SyncController from "src/SyncController"; -export default class EventService { +export class FileLoadEventHandler { private plugin: CheckboxSyncPlugin; private app: App; private syncController: SyncController; private fileStateHolder: FileStateHolder; + private markdownPostProcessorMutex: Mutex;//для более последовательной обработки файлов в постпроцессоре. но вообще это лишь замедляет плагин constructor(plugin: CheckboxSyncPlugin, app: App, syncController: SyncController, fileStateHolder: FileStateHolder) { this.plugin = plugin; this.app = app; this.syncController = syncController; this.fileStateHolder = fileStateHolder; - + this.markdownPostProcessorMutex = new Mutex(); } registerEvents() { @@ -25,44 +26,30 @@ export default class EventService { this.app.workspace.on("file-open", async (file) => { if (file) { console.log(`file-open ${file.path}`); - const isUpdate = await this.fileStateHolder.updateIfNeeded(file); + const isUpdate = await this.fileStateHolder.initIfNeeded(file); if (isUpdate) { await this.syncController.syncFile(file); } } }) ); - //запуск плагина при модификации файла(для обработки в режиме просмотра) - this.plugin.registerEvent( - this.app.vault.on("modify", async (file) => { - if (!(file instanceof TFile)) return; - if (file.extension !== "md") return; - console.log(`modify file ${file.path}`); - await this.syncController.syncFile(file); - }) - ); - //запуск плагина при изменении в режиме редактора - this.plugin.registerEvent( - this.app.workspace.on("editor-change", async (editor, info) => { - console.log(`editor-change file ${info.file?.path}`); - await this.syncController.syncEditor(editor, info); - }) - ); this.plugin.registerMarkdownPostProcessor(async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => { - console.log(`MarkdownPostProcessor "${ctx.sourcePath}"`); - const file = this.app.vault.getAbstractFileByPath(ctx.sourcePath); - if (file instanceof TFile) { - const isUpdate = await this.fileStateHolder.updateIfNeeded(file); - if (isUpdate) { - await this.syncController.syncFile(file); + // console.log(`MarkdownPostProcessor "${ctx.sourcePath}"`); + await this.markdownPostProcessorMutex.runExclusive(async () => { + const file = this.app.vault.getAbstractFileByPath(ctx.sourcePath); + if (file instanceof TFile) { + const isUpdate = await this.fileStateHolder.initIfNeeded(file); + if (isUpdate) { + await this.syncController.syncFile(file); + } } - } + }); }); this.plugin.registerEvent( this.app.workspace.on('quick-preview', async (file: TFile, data: string) => { - const isUpdate = await this.fileStateHolder.updateIfNeeded(file, data); + const isUpdate = await this.fileStateHolder.initIfNeeded(file, data); if (isUpdate) { await this.syncController.syncFile(file); } @@ -77,15 +64,12 @@ export default class EventService { //загрузка в кеш и синхронизация открытых файлов async loadAndSyncActiveFiles() { const markdownLeaves = this.app.workspace.getLeavesOfType('markdown'); - markdownLeaves.forEach(async (leaf: WorkspaceLeaf) => { - await this.loadAndSyncActiveFile(leaf); - }); + await Promise.all(markdownLeaves.map(async (leaf) => await this.loadAndSyncActiveFile(leaf))); } private async loadAndSyncActiveFile(leaf: WorkspaceLeaf) { const view = leaf.view as MarkdownView; if (!view.file) return; - //вызов ререндера, для кеширования через MarkdownPostProcessor await this.rerenderView(view); } diff --git a/src/main.ts b/src/main.ts index ddcc82a..1f706ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,43 +1,56 @@ -import { Plugin } from "obsidian"; +import { Plugin, TFile } from "obsidian"; import { CheckboxSyncPluginSettingTab } from "./CheckboxSyncPluginSettingTab"; import { CheckboxUtils } from "./checkboxUtils"; -import EventService from "./EventService"; 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, }; export default class CheckboxSyncPlugin extends Plugin { - settings: CheckboxSyncPluginSettings; + private _settings: CheckboxSyncPluginSettings; - syncController: SyncController; - checkboxUtils: CheckboxUtils; - fileStateHolder: FileStateHolder; - eventService: EventService; + private syncController: SyncController; + private checkboxUtils: CheckboxUtils; + private fileStateHolder: FileStateHolder; + private fileLoadEventHandler: FileLoadEventHandler; + private fileChangeEventHandler: FileChangeEventHandler; async onload() { await this.loadSettings(); this.fileStateHolder = new FileStateHolder(this.app.vault); this.checkboxUtils = new CheckboxUtils(this.settings); - this.syncController = new SyncController(this, this.checkboxUtils, this.fileStateHolder); - this.eventService = new EventService(this, this.app, this.syncController, this.fileStateHolder); + this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.fileStateHolder); + this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder); + this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController); this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this)); - this.eventService.registerEvents(); - + this.fileLoadEventHandler.registerEvents(); + this.fileChangeEventHandler.registerEvents(); } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } - async saveSettings() { + get settings(): Readonly { + return this._settings; + } + + async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise) { + await callback(this._settings); await this.saveData(this.settings); - this.checkboxUtils.updateSettings(this.settings); - await this.eventService.loadAndSyncActiveFiles(); + //надо пересинхронизировать все файлы в кеше + const allFile = this.fileStateHolder.getAllFiles(); + await Promise.all( + allFile.map(async (file: TFile) => { + await this.syncController.syncFile(file); + }) + ); } }