Merge branch 'dev' 1.2.0

This commit is contained in:
Grol Grol 2025-08-08 13:17:04 +03:00
commit 32dcf4eff0
53 changed files with 5063 additions and 1602 deletions

49
.gitignore vendored
View file

@ -1,24 +1,25 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
coverage/
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
coverage/
test-report.html

View file

@ -26,6 +26,7 @@ It automatically updates parent checkboxes based on their children's state, and
* Respects list indentation for nested hierarchies.
* Flexible checkbox symbol interpretation (define checked/unchecked/ignored symbols).
* Option to disable automatic sync on file open.
* File Ignore Rules.
## Quick Links
@ -43,4 +44,4 @@ Contributions are welcome! Please see the [**Contributing Guide**](https://grold
## License
This project is licensed under the 0BSD license. See the [LICENSE](LICENSE) file for details.
This project is licensed under the 0BSD license. See the [LICENSE](LICENSE) file for details.

View file

@ -0,0 +1,189 @@
import { FileFilter } from "src/FileFilter";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types";
const createSettings = (pathGlobs: string[]): Readonly<CheckboxSyncPluginSettings> => ({
...DEFAULT_SETTINGS,
pathGlobs,
});
describe('FileFilter with "ignore" library (.gitignore-style logic)', () => {
describe('isPathAllowed should return true if file is NOT ignored, false if IGNORED', () => {
const testCases = [
// --- Базовые случаи ---
{ name: 'No globs: any/file.md', globs: [], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Empty glob string: any/file.md', globs: [''], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Comment only: any/file.md', globs: ['#comment'], path: 'any/file.md', expected: true }, // Not ignored
{ name: 'Empty and comment: any/file.md', globs: ['', '#comment'], path: 'any/file.md', expected: true }, // Not ignored
// --- Простое исключение (файл ИГНОРИРУЕТСЯ -> isPathAllowed: false) ---
{ name: 'Exclude folder logs/: logs/error.log', globs: ['logs/'], path: 'logs/error.log', expected: false },
{ name: 'Exclude folder logs/: data/file.md (not in folder)', globs: ['logs/'], path: 'data/file.md', expected: true },
{ name: 'Exclude *.tmp: file.tmp', globs: ['*.tmp'], path: 'file.tmp', expected: false },
{ name: 'Exclude *.tmp: file.md (different type)', globs: ['*.tmp'], path: 'file.md', expected: true },
{ name: 'Exclude secret.md: secret.md', globs: ['secret.md'], path: 'secret.md', expected: false },
// --- Правило: "Нельзя пере-включить файл, если его родительская директория исключена" ---
// Тесты, которые раньше падали из-за моего неверного expected: true
{
name: 'Parent Excluded 1: logs/, !logs/important.log -> logs/important.log',
globs: ['logs/', '!logs/important.log'],
path: 'logs/important.log',
expected: false // Stays ignored because 'logs/' is excluded
},
{
name: 'Parent Excluded 2: docs/, !docs/README.md -> docs/README.md',
globs: ['docs/', '!docs/README.md'],
path: 'docs/README.md',
expected: false // Stays ignored
},
{
name: 'Parent Excluded 3: *, !projects/** -> projects/my/file.md',
globs: ['*', '!projects/**'],
path: 'projects/my/file.md',
expected: false // Stays ignored because 'projects/' (or '*' matching it) is excluded
},
{
name: 'Parent Excluded 4: .obsidian/, !.obsidian/config -> .obsidian/config',
globs: ['.obsidian/', '!.obsidian/config'],
path: '.obsidian/config',
expected: false // Stays ignored
},
// --- Случаи, где правило о родительской директории НЕ применяется, и ! отменяет предыдущее ---
// Здесь мы не исключаем родительскую директорию целиком, а только файлы по паттерну
{
name: 'Negation Works 1: *.js, !src/safe.js -> src/safe.js',
globs: ['*.js', '!src/safe.js'],
path: 'src/safe.js',
expected: true // Not ignored
},
{
name: 'Negation Works 2: *.js, !src/safe.js -> lib/bad.js',
globs: ['*.js', '!src/safe.js'],
path: 'lib/bad.js',
expected: false // Ignored by *.js
},
{
name: 'Negation Works 3: specific.md, !specific.md -> specific.md',
globs: ['specific.md', '!specific.md'],
path: 'specific.md',
expected: true // Not ignored
},
// --- Проверка правила о родительской директории более явно ---
// Сначала исключаем родителя, потом пытаемся включить ребенка И родителя
{
name: 'Parent Excluded then Re-include Parent: logs/, !logs/ -> logs/important.log',
globs: ['logs/', '!logs/'], // !logs/ отменяет logs/ для самой папки logs и ее содержимого
path: 'logs/important.log',
expected: true // logs/important.log НЕ игнорируется
},
{
name: 'Parent Excluded then Re-include Parent & Child: logs/, !logs/, !logs/important.log -> logs/important.log',
globs: ['logs/', '!logs/', '!logs/important.log'], // !logs/important.log здесь избыточно, но для теста
path: 'logs/important.log',
expected: true // НЕ игнорируется
},
// Исключаем папку, потом пытаемся включить подпапку - НЕ СРАБОТАЕТ для файлов ВНУТРИ подпапки
{
name: 'Exclude parent, try un-ignore sub-folder: top/, !top/mid/ -> top/mid/file.txt',
globs: ['top/', '!top/mid/'],
path: 'top/mid/file.txt',
expected: false // 'top/' excluded, so 'top/mid/file.txt' is ignored. '!top/mid/' cannot un-ignore contents.
},
// Но если мы хотим работать с top/mid/, нам нужно отменить top/ СНАЧАЛА для top/mid/
{
name: 'Un-ignore sub-folder first, then parent: !top/mid/, top/ -> top/mid/file.txt',
globs: ['!top/mid/', 'top/'], // 'top/' will re-ignore 'top/mid/file.txt'. Last rule wins if parent rule is not violated.
path: 'top/mid/file.txt',
expected: false
},
{
name: 'Un-ignore sub-folder from wildcard: *, !top/mid/** -> top/mid/file.txt',
globs: ['*', '!top/mid/**'], // '*' excludes 'top/', then '!top/mid/**' tries to un-exclude. Parent 'top/' is excluded.
path: 'top/mid/file.txt',
expected: false // Parent 'top/' is excluded by '*', so '!top/mid/**' has no effect on contents.
},
{
name: 'Correct way for "only top/mid/**" (other file in top): *, !top/, top/*, !top/mid/** -> top/other.txt',
globs: ['*', '!top/', 'top/*', '!top/mid/**'],
path: 'top/other.txt',
expected: false // Ignored by 'top/*'
},
// --- Другие случаи с порядком (должны работать как раньше, т.к. не нарушают правило о родителях) ---
{
name: 'Order matters: !file.md, file.md -> file.md',
globs: ['!file.md', 'file.md'],
path: 'file.md',
expected: false // Ignored by 'file.md' (last rule)
},
// --- Случай "только это" (перепроверяем с учетом правила о родителях) ---
// Globs: ["*", "!projects/**"], Path: "projects/my/file.md"
// '*' excludes 'projects/'. So '!projects/**' cannot re-include contents.
// Expected: false
// (Этот тест уже был в секции Parent Excluded и остался false)
// --- Файлы с точкой (dot files/folders) ---
// Globs: [".obsidian/", "!.obsidian/config"], Path: ".obsidian/config"
// '.obsidian/' excludes parent.
// Expected: false
// (Этот тест уже был в секции Parent Excluded и остался false)
// --- "Противоречивые" случаи ---
{
name: 'Contradictory 1: !src/**, *.js -> src/subdir/file.js',
globs: ['!src/**', '*.js'], // !src/** makes src/ not ignored. Then *.js ignores file.js.
path: 'src/subdir/file.js',
expected: false
},
{
name: 'Contradictory 1: !src/**, *.js -> src/subdir/file.txt',
globs: ['!src/**', '*.js'], // !src/** makes src/ not ignored. *.js does not match.
path: 'src/subdir/file.txt',
expected: true
},
{
name: 'Contradictory 2: *.js, !src/** -> src/file.js',
globs: ['*.js', '!src/**'], // *.js ignores src/file.js. !src/** makes src/ (and its contents) not ignored. Last rule wins.
path: 'src/file.js',
expected: true
},
{
name: 'Contradictory 2: *.js, !src/** -> lib/file.js',
globs: ['*.js', '!src/**'], // *.js ignores lib/file.js. !src/** does not match.
path: 'lib/file.js',
expected: false
},
// --- Только правило на разрешение ---
{
name: 'Only allow rule !important.txt: other.txt',
globs: ['!important.txt'], // No rule ignores other.txt. !important.txt does not match.
path: 'other.txt',
expected: true // Not ignored
},
{
name: 'Only allow rule !important.txt: important.txt',
globs: ['!important.txt'], // !important.txt matches, makes it not ignored.
path: 'important.txt',
expected: true // Not ignored
},
];
testCases.forEach(tc => {
it(`should correctly process: ${tc.name} (path: "${tc.path}", globs: ${JSON.stringify(tc.globs)})`, () => {
const filter = new FileFilter(createSettings(tc.globs));
const received = filter.isPathAllowed(tc.path);
// Логирование только для упавших тестов (убрано из предыдущей версии, т.к. expect сам покажет)
// if (received !== tc.expected) { ... }
expect(received).toBe(tc.expected);
});
});
});
});

View file

@ -0,0 +1,234 @@
import { CheckboxUtils } from "src/core/checkboxUtils";
import { FileFilter } from "src/FileFilter";
import TextSyncPipeline from "src/TextSyncPipeline";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types";
import { InMemoryFilePathStateHolder } from "./fakes/InMemoryFilePathStateHolder";
describe('TextSyncPipeline E2E-like Tests', () => {
let fakeFileStateHolder: InMemoryFilePathStateHolder;
let checkboxUtils: CheckboxUtils;
let fileFilter: FileFilter;
let pipeline: TextSyncPipeline;
let settings: CheckboxSyncPluginSettings;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов,
// их можно переопределять для специфических сценариев
settings = { ...DEFAULT_SETTINGS };
fakeFileStateHolder = new InMemoryFilePathStateHolder();
checkboxUtils = new CheckboxUtils(settings);
fileFilter = new FileFilter(settings); // Инициализируем с настройками
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
});
// --- Тесты на разрешение путей и кеширование ---
describe('Path Filtering and Caching Logic', () => {
it('process: when path is NOT allowed, should return original text and cache original text', () => {
// Настраиваем FileFilter, чтобы он запрещал путь
settings.pathGlobs = ["restricted/path.md"]; // Этот путь будет игнорироваться
fileFilter.updateSettings(settings); // Обновляем фильтр с новыми глобами
const filePath = "restricted/path.md";
const currentText = "- [ ] task 1\n- [x] task 2";
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
// 1. Текст не должен измениться
expect(resultText).toBe(currentText);
// 2. Оригинальный текст должен быть закеширован
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: when path IS allowed, but no sync changes occur, should return original text and cache original (synced) text', () => {
// Путь разрешен по умолчанию (pathGlobs пустой)
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
const filePath = "allowed/path.md";
// Текст, который не будет изменен CheckboxUtils при текущих настройках
// (например, нет родительских/дочерних для пропагации, или они уже синхронизованы)
const currentText = "- [ ] task 1\n- [ ] task 2";
const previousTextInHolder = "- [ ] task 1\n- [ ] task 2"; // Предположим, предыдущее состояние было таким же
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
// 1. Текст не должен измениться (т.к. syncText вернул то же самое)
expect(resultText).toBe(currentText);
// 2. В кеше должен быть этот же (оригинальный/синхронизированный) текст
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: when path IS allowed and sync changes occur, should return modified text and cache modified text', () => {
// Путь разрешен
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
// Включаем автоматическую пропагацию, чтобы изменения точно были
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings); // Пересоздаем с новыми настройками
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/changes.md";
const currentText = "- [x] parent\n - [ ] child"; // Родитель изменен, дочерний должен измениться
const previousTextInHolder = "- [ ] parent\n - [ ] child"; // Предыдущее состояние
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
const expectedTextAfterSync = "- [x] parent\n - [x] child"; // Ожидаемый результат от CheckboxUtils
// Assert
// 1. Текст должен измениться
expect(resultText).toBe(expectedTextAfterSync);
// 2. В кеше должен быть измененный текст
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync);
});
it('process: when path IS allowed and file was not in state holder, should process and cache result', () => {
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/new_file.md";
const currentText = "- [x] parent\n - [ ] child";
// Предыдущего состояния нет в fakeFileStateHolder
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
const expectedTextAfterSync = "- [ ] parent\n - [ ] child";
// Assert
expect(resultText).toBe(expectedTextAfterSync);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync);
});
it('process: subsequent calls for the same allowed path should use updated cached state', () => {
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/sequential.md";
const initialText = "- [ ] parent\n - [ ] child 1";
const textAfterFirstEdit = "- [x] parent\n - [ ] child 1"; // Пользователь изменил родителя
const expectedAfterFirstPipeline = "- [x] parent\n - [x] child 1";
// Первый вызов (например, после первого изменения в редакторе)
fakeFileStateHolder.setByPath(filePath, initialText);
let resultText = pipeline.applySyncLogic(textAfterFirstEdit, filePath); // previousText будет undefined
expect(resultText).toBe(expectedAfterFirstPipeline);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterFirstPipeline);
// Второе изменение (пользователь снимает галочку с родителя)
const textAfterSecondEdit = "- [ ] parent\n - [x] child 1"; // previousText теперь expectedAfterFirstPipeline
const expectedAfterSecondPipeline = "- [ ] parent\n - [ ] child 1";
resultText = pipeline.applySyncLogic(textAfterSecondEdit, filePath);
expect(resultText).toBe(expectedAfterSecondPipeline);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterSecondPipeline);
});
});
// --- Можно добавить тесты на специфическое поведение CheckboxUtils через pipeline ---
// Например, если enableAutomaticParentState = true и т.д.
describe('CheckboxUtils Behavior through Pipeline (Path Allowed)', () => {
beforeEach(() => {
// Убедимся, что путь всегда разрешен для этих тестов
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
});
it('process: should propagate state to children if enabled', () => {
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings); // Обновляем CheckboxUtils
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "test.md";
const previousText = "- [ ] Parent\n - [ ] Child";
const currentText = "- [x] Parent\n - [ ] Child"; // Пользователь отметил родителя
const expectedText = "- [x] Parent\n - [x] Child";
fakeFileStateHolder.setInitialStates({ [filePath]: previousText });
const result = pipeline.applySyncLogic(currentText, filePath);
expect(result).toBe(expectedText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText);
});
it('process: should propagate state from children if enabled', () => {
settings.enableAutomaticParentState = true;
// Убедимся, что child state не влияет на этот тест, если он не нужен
settings.enableAutomaticChildState = false;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "test.md";
// Предыдущее состояние не так важно, если мы не проверяем diff-логику
const currentText = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2"; // Дети отмечены
const expectedText = "- [x] Parent\n - [x] Child 1\n - [x] Child 2"; // Родитель должен стать отмеченным
const result = pipeline.applySyncLogic(currentText, filePath);
expect(result).toBe(expectedText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText);
});
});
describe('Text content variations (Path Allowed)', () => {
beforeEach(() => {
// Убедимся, что путь всегда разрешен для этих тестов
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
// Оставляем настройки CheckboxUtils по умолчанию, если не указано иное
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
});
it('process: text with no checkboxes should return original text and cache it', () => {
const filePath = "text_files/no_checkboxes.md";
const currentText = "This is a line.\nAnd another line without any checkboxes.";
const previousTextInHolder = "Some old text";
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
expect(resultText).toBe(currentText);
// CheckboxUtils.syncText не должен был ничего изменить
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: text with only ignored checkboxes should return original text and cache it', () => {
settings.ignoreSymbols = ["~"];
checkboxUtils = new CheckboxUtils(settings); // Обновляем с новыми ignoreSymbols
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "text_files/ignored_checkboxes.md";
const currentText = "- [~] An ignored task\n - [~] Another sub-task also ignored";
const previousTextInHolder = "- [~] An old ignored task";
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
expect(resultText).toBe(currentText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
});
});

View file

@ -0,0 +1,932 @@
import { CheckboxLineInfo, CheckboxUtils } from '../src/core/checkboxUtils'; // Путь к вашему файлу
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine ---
describe('matchCheckboxLine', () => {
it('should match standard unchecked checkbox', () => {
const line = '- [ ] Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkChar: ' ',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Unchecked,
isChecked: false,
listItemText: "Task",
});
});
it('should match standard checked checkbox', () => {
const line = '* [x] Done';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkChar: 'x',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Done",
});
});
it('should match checkbox with indentation', () => {
const line = ' + [x] Indented Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '+',
checkChar: 'x',
checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Indented Task",
});
});
it('should match numbered list checkbox', () => {
const line = '1. [ ] Numbered';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkChar: ' ',
checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4
checkboxState: CheckboxState.Unchecked,
isChecked: false,
listItemText: "Numbered",
});
});
it('should match checkbox with multi-digit numbered list', () => {
const line = '10. [x] Double Digit';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkChar: 'x',
checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Double Digit",
});
});
it('should return null for lines without checkbox format', () => {
expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ]
expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside
expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker
expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker
});
it('should match custom symbols based on settings', () => {
const customSettings = createSettings({
checkedSymbols: ['X', 'V'],
uncheckedSymbols: ['O', '-'],
ignoreSymbols: ['~'],
});
const customUtils = new CheckboxUtils(customSettings);
const checkedLine = '- [V] Custom Checked';
const checkedResult = customUtils.matchCheckboxLine(checkedLine);
expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked);
expect(checkedResult?.isChecked).toBe(true);
expect(checkedResult?.checkChar).toBe('V');
const uncheckedLine = '* [O] Custom Unchecked';
const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine);
expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked);
expect(uncheckedResult?.isChecked).toBe(false);
expect(uncheckedResult?.checkChar).toBe('O');
const ignoredLine = '+ [~] Custom Ignored';
const ignoredResult = customUtils.matchCheckboxLine(ignoredLine);
expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore);
expect(ignoredResult?.isChecked).toBeUndefined();
expect(ignoredResult?.checkChar).toBe('~');
});
});
// --- Тесты для getCheckboxState ---
describe('getCheckboxState', () => {
it('should return Checked for symbols in checkedSymbols', () => {
const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked);
});
it('should return Unchecked for symbols in uncheckedSymbols', () => {
const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
});
it('should return Ignore for symbols in ignoreSymbols', () => {
const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore);
});
it('should use unknownSymbolPolicy for symbols not in any list', () => {
const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked });
const utilsChecked = new CheckboxUtils(settingsChecked);
expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked);
const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked });
const utilsUnchecked = new CheckboxUtils(settingsUnchecked);
expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore });
const utilsIgnore = new CheckboxUtils(settingsIgnore);
expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore);
});
});
// --- Тесты для updateLineCheckboxStateWithInfo ---
describe('updateLineCheckboxStateWithInfo', () => {
it('should update checkbox state from unchecked to checked', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
// Uses the first symbol from settings.checkedSymbols ('x' by default)
expect(updatedLine).toBe('- [x] Task');
});
it('should update checkbox state from checked to unchecked', () => {
const line = '* [x] Done';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
// Uses the first symbol from settings.uncheckedSymbols (' ' by default)
expect(updatedLine).toBe('* [ ] Done');
});
it('should use first symbol from settings for update', () => {
const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] });
const customUtils = new CheckboxUtils(customSettings);
const lineToCheck = '- [O] Task';
const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!;
const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck);
expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V'
const lineToUncheck = '* [V] Done';
const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!;
const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck);
expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O'
});
it('should handle indented lines correctly', () => {
const line = ' - [ ] Indented';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe(' - [x] Indented');
});
it('should return original line if position is invalid (simulated)', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 };
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLine).toBe(line); // Should not change
});
it('should use default checked symbol "x" if checkedSymbols setting is empty', () => {
// Создаем настройки с пустым списком checkedSymbols
const customSettings = createSettings({ checkedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [ ] Task';
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся отметить чекбокс
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x'
});
it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => {
// Создаем настройки с пустым списком uncheckedSymbols
const customSettings = createSettings({ uncheckedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся снять отметку
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел)
});
// Усиленный тест для невалидной позиции (с проверкой console.warn)
it('should return original line and warn if position is invalid', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Мокаем console.warn
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn
const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция
const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2);
expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз
// Восстанавливаем оригинальную функцию console.warn
warnSpy.mockRestore();
});
it('should return original line and warn if checkboxCharPosition is invalid for a valid checkbox type', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Это валидный чекбокс
const invalidPosInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidPosInfo);
expect(updatedLine).toBe(line);
expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line);
warnSpy.mockRestore();
});
it('UPDATE_LINE: should return original line and warn if lineInfo is for NoCheckbox', () => {
const line = "- Plain item";
// Создаем lineInfo, как будто он пришел от plain list item
const plainLineInfo: CheckboxLineInfo = {
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox, // Ключевой момент
listItemText: 'Plain item',
// checkboxCharPosition не важен, т.к. до него не дойдет
};
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, plainLineInfo);
expect(updatedLine).toBe(line); // Строка не должна измениться
expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line);
warnSpy.mockRestore();
});
});
// --- Тесты для propagateStateToChildren ---
describe('propagateStateToChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check children when parent is checked', () => {
const text = [
'- [x] Parent', // line 0
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [ ] Sibling',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1',
' - [x] Grandchild',
' - [x] Child 2',
'- [ ] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should uncheck children when parent is unchecked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Grandchild',
' - [ ] Child 2', // Already unchecked
'- [x] Sibling',
].join('\n');
const expected = [
'- [ ] Parent',
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [x] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation at siblings or less indented lines', () => {
const text = [
' - [x] Parent', // line 0
' - [ ] Child',
' - [ ] Sibling', // Same indent level
'- [ ] Less Indented',
].join('\n');
const expected = [
' - [x] Parent',
' - [x] Child', // Changed
' - [ ] Sibling', // Not changed
'- [ ] Less Indented', // Not changed
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should skip ignored children and their subtrees', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [x] Parent', // 0
' - [ ] Child 1', // 1
' - [~] Ignored Child',// 2 (ignore)
' - [ ] Skipped GC', // 3 (skipped because parent ignored)
' - [ ] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1', // Changed
' - [~] Ignored Child',// Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Child 2', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should do nothing if the parent line is not a checkbox', () => {
const text = [
'Parent Line',
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should do nothing if the parent checkbox is ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [ ] Child',
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should return original text and warn if parent line index does not contain a list item', () => {
const text = "Not a list item\n - [ ] Child";
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const result = checkboxUtils.propagateStateToChildren(text, 0);
expect(result).toBe(text);
expect(warnSpy).toHaveBeenCalledWith("checkbox not found in line 0");
warnSpy.mockRestore();
});
it('should stop propagation if a non-list-item line is encountered among children', () => {
const text = [
'- [x] Parent',
' Not a child list item', // This will make childLineInfo null
' - [ ] Grandchild (should not be processed)',
].join('\n');
const expected = [
'- [x] Parent',
' Not a child list item',
' - [ ] Grandchild (should not be processed)',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should correctly stop processing sub-children of an ignored node if a non-list-item is found', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const text = [
'- [x] Parent',
' - [~] Ignored Child',
' - [ ] Valid Sub-Child (will be skipped by inner loop)', // j инкрементируется
' Not a sub-child list item', // subChildLineInfo = null, inner loop breaks
' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)',
' - [ ] Next Sibling of Ignored', // This should also not be processed due to outer loop break
].join('\n');
// Ожидаем, что Parent и Ignored Child останутся, а остальное не изменится,
// потому что внешний цикл прервется после обработки "Not a sub-child list item"
const expected = [
'- [x] Parent',
' - [~] Ignored Child',
' - [ ] Valid Sub-Child (will be skipped by inner loop)',
' Not a sub-child list item',
' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)',
' - [ ] Next Sibling of Ignored',
].join('\n');
const result = utils.propagateStateToChildren(text, 0);
expect(result).toBe(expected);
// В данном случае, поскольку Parent [x], и мы ничего не меняем, результат будет равен text.
// Важно то, что Next Sibling of Ignored не стал [x].
});
});
// --- Тесты для propagateStateFromChildren ---
describe('propagateStateFromChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check parent if all children are checked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild', // Needs parent (Child 2) to be checked first
].join('\n');
// Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent
const expectedPass1 = [ // After processing Grandchild and Child 2
'- [ ] Parent',
' - [x] Child 1',
' - [x] Child 2', // Stays checked
' - [x] Grandchild',
].join('\n');
const expectedPass2 = [ // After processing Child 1 and Parent
'- [x] Parent', // Changes to checked
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild',
].join('\n');
// Since it processes bottom-up, Child 2 should already be evaluated correctly
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2);
});
it('should uncheck parent if any child is unchecked', () => {
const text = [
'- [x] Parent', // line 0
' - [x] Child 1',
' - [ ] Child 2', // This one makes the parent unchecked
' - [ ] Grandchild',
].join('\n');
const expected = [
'- [ ] Parent', // Changes to unchecked
' - [x] Child 1',
' - [ ] Child 2',
' - [ ] Grandchild',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should handle nested updates correctly (bottom-up)', () => {
const text = [
'- [ ] Grandparent', // 0
' - [ ] Parent 1', // 1
' - [x] Child 1.1',// 2
' - [x] Child 1.2',// 3
' - [x] Parent 2', // 4
' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked)
].join('\n');
const expected = [
'- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked)
' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked)
' - [x] Child 1.1',
' - [x] Child 1.2',
' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked)
' - [ ] Child 2.1',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if it has no relevant children', () => {
const text = [
'- [ ] Parent',
'Sibling', // Not a child checkbox
' Not indented correctly',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should ignore non-checkbox lines when determining parent state', () => {
const text = [
'- [ ] Parent',
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
const expected = [
'- [x] Parent', // Should become checked
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should skip ignored children when determining parent state', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [x] Child 1', // 1
' - [~] Ignored Child',// 2 (skipped)
' - [ ] Skipped GC', // 3 (doesn't affect parent)
' - [x] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only)
' - [x] Child 1',
' - [~] Ignored Child',
' - [ ] Skipped GC',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if all direct children are ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked
const text2 = [
'- [x] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked
});
it('should not update ignored parents', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [x] Child 1',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored
});
it('should stop searching children when a line with equal or lesser indent is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'Some regular text', // indent 0. Stops search for Parent 1 children.
' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1.
'- [ ] Parent 2', // indent 0. Processed independently.
' - [x] Child 2.1', // indent 2. Child of Parent 2.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'Some regular text',
' - [x] Child 1.1',
'- [x] Parent 2', // Updated by Child 2.1
' - [x] Child 2.1',
].join('\n');
expect(result).toBe(expected);
});
it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children.
' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'- [x] Sibling Parent', // Updated by Child SP.1
' - [x] Child SP.1',
].join('\n');
expect(result).toBe(expected);
const text2 = [
' - [ ] Parent 1', // indent 2
' - [x] Child 1.1', // indent 4
' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children.
' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent.
].join('\n');
const result2 = checkboxUtils.propagateStateFromChildren(text2);
const expected2 = [
' - [x] Parent 1', // Updated by Child 1.1
' - [x] Child 1.1',
' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked)
' - [ ] Child SP.1',
].join('\n');
expect(result2).toBe(expected2);
});
it('should ignore non-list-item lines when iterating upwards', () => {
const text = [
'- [ ] Parent',
' - [x] Child',
'Just some random text in between', // parentLineInfo will be null for this
'- [ ] Another Parent',
' - [x] Another Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child',
'Just some random text in between',
'- [x] Another Parent',
' - [x] Another Child',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
});
// --- Тесты для syncText ---
describe('syncText', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
it('should propagate down only if child sync enabled and diff matches', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: false, // Parent sync off
}));
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected);
});
it('should propagate up only if parent sync enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false, // Child sync off
enableAutomaticParentState: true,
}));
const expected = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
// syncText calls propagateFromChildren unconditionally if enabled
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected);
});
it('should propagate down then up if both enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
// 1. User checks parent: '- [x] Parent', ' - [ ] Child'
// 2. Propagate down: '- [x] Parent', ' - [x] Child'
// 3. Propagate up: (no change needed as parent is already checked)
const expectedDown = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown);
// 1. User checks child: '- [ ] Parent', ' - [x] Child'
// 2. Propagate down: (no change, as only one line changed, and it wasn't the parent)
// 3. Propagate up: '- [x] Parent', ' - [x] Child'
const expectedUp = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp);
});
it('should do nothing if both syncs disabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false,
enableAutomaticParentState: false,
}));
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked);
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked);
});
it('should not propagate down if diff is not a single checkbox state change', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: false,
}));
const textMultipleChanges = [
'- [x] Parent Changed', // Text changed too
' - [x] Child also changed',
].join('\n');
const textIndentChange = [
' - [x] Parent', // Indent changed
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges);
// Indent changed (diff > 1 line index)
expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange);
});
});
// --- Тесты для propagateStateToChildrenFromSingleDiff ---
describe('propagateStateToChildrenFromSingleDiff', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
it('should propagate if only checkbox state changed', () => {
const textAfter = [
'- [x] Parent', // Only state changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should propagate if text content also changed', () => {
const textAfter = [
'- [x] Parent Changed Text', // State and text changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent Changed Text',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate if marker changed', () => {
const textAfter = [
'* [x] Parent', // Marker changed from - to *
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if indentation changed', () => {
const textAfter = [
' - [x] Parent', // Indentation changed
' - [ ] Child',
].join('\n');
// This will be detected as > 1 line difference by findDifferentLineIndexes
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if multiple lines changed', () => {
const textAfter = [
'- [x] Parent',
' - [x] Child', // Second line also changed
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if changed line is not a checkbox', () => {
const textBeforeNonCheck = "Line 1\nLine 2";
const textAfterNonCheck = "Line 1 changed\nLine 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck);
});
it('should propagate if changed line is an ignored checkbox', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const textBeforeIgnored = [
'- [~] Parent',
' - [ ] Child',
].join('\n');
const textAfterIgnored = [
'- [x] Parent', // Changed FROM ignored TO checked
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected);
});
it('should return original text if textBefore is undefined', () => {
const text = '- [ ] Line';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text);
});
it('should return original text if line counts differ', () => {
const text1 = '- [ ] Line1\n- [ ] Line2';
const text2 = '- [ ] Line1';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1);
});
it('should return original text if textBefore and text have different line counts', () => {
const textBefore = "- [ ] Line 1";
const text = "- [ ] Line 1\n- [ ] Line 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, textBefore)).toBe(text);
});
it('should return original text if no lines changed (diffIndexes.length === 0)', () => {
const text = "- [ ] Task";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, text)).toBe(text);
});
it('should return original text if multiple lines changed (diffIndexes.length > 1)', () => {
const textBefore = "- [ ] Task1\n- [ ] Task2";
const textAfter = "- [x] Task1\n- [x] Task2"; // 2 diffs
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if changed line was not a list item before', () => {
const textBefore = "Not a list item";
const textAfter = "- [x] Became a list item";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if changed line is not a list item after', () => {
const textBefore = "- [ ] Was a list item";
const textAfter = "Not a list item anymore";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if indent changed', () => {
const textBeforeIndent = [
"- [ ] Parent",
" - [ ] Child",
].join('\n');
const textAfterIndent = [
" - [ ] Parent",
" - [ ] Child",
].join('\n'); // Parent indent changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterIndent, textBeforeIndent)).toBe(textAfterIndent);
});
it('should not propagate if marker changed', () => {
const textBefore = "- [ ] Item\n - [ ] Child";
const textAfter = "* [ ] Item\n - [ ] Child"; // Marker changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if checkbox state did not change (e.g., only text content)', () => {
const textBefore = "- [x] Task Alpha\n - [ ] Child";
const textAfter = "- [x] Task Bravo\n - [ ] Child"; // Only text changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
});
// --- Тесты для findDifferentLineIndexes ---
describe('findDifferentLineIndexes', () => {
it('should return empty array for identical lines', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'b', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]);
});
it('should return index of the single different line', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'B', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]);
});
it('should return indexes of multiple different lines', () => {
const lines1 = ['a', 'b', 'c', 'd'];
const lines2 = ['A', 'b', 'C', 'd'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should detect differences at start and end', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['X', 'b', 'Y'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should throw error if line arrays have different lengths', () => {
const lines1 = ['a', 'b'];
const lines2 = ['a', 'b', 'c'];
expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2))
.toThrow("the length of the lines must be equal");
expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1))
.toThrow("the length of the lines must be equal");
});
});
});

View file

@ -0,0 +1,383 @@
// checkboxUtils.plainLists.test.ts
import { CheckboxUtils } from '../src/core/checkboxUtils';
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types';
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils - Plain List Item Support', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine с поддержкой plain list items ---
describe('matchCheckboxLine with Plain List Items', () => {
it('should match a plain bullet list item', () => {
const line = '- Plain item text';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Plain item text',
// Опциональные поля для чекбокса должны быть undefined
checkChar: undefined,
checkboxCharPosition: undefined,
isChecked: undefined,
});
});
it('should match a plain asterisk list item with indentation', () => {
const line = ' * Indented plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '*',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Indented plain item',
});
});
it('should match a plain plus list item', () => {
const line = '+ Another plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '+',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Another plain item',
});
});
it('should match a plain numbered list item', () => {
const line = '1. Numbered plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Numbered plain item',
});
});
it('should match a plain multi-digit numbered list item', () => {
const line = '10. Long numbered plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Long numbered plain item',
});
});
it('should still match a standard checkbox item', () => {
const line = '- [x] A checkbox item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result?.checkboxState).toBe(CheckboxState.Checked);
expect(result?.isChecked).toBe(true);
expect(result?.checkChar).toBe('x');
expect(result?.listItemText).toBe('A checkbox item');
});
it('should match a plain list item with no text after marker', () => {
const line = '- '; // Marker and space
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox,
listItemText: '', // Empty string for text
});
});
it('should match a plain list item with only spaces after marker', () => {
const line = '* '; // Marker and spaces
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkboxState: CheckboxState.NoCheckbox,
listItemText: '', // Text is trimmed
});
});
it('should correctly parse listItemText for checkboxes', () => {
const line = '- [ ] Task with text';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result?.listItemText).toBe('Task with text');
const lineNoText = '- [x] ';
const resultNoText = checkboxUtils.matchCheckboxLine(lineNoText);
expect(resultNoText?.listItemText).toBe('');
});
it('should return null for lines that are not list items or malformed', () => {
expect(checkboxUtils.matchCheckboxLine('Just some text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('-NoSpaceAfterMarker')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('1.NoSpaceAfterMarker')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('> Not a list marker')).toBeNull();
// Это все еще НЕ чекбокс и НЕ plain list item по текущим правилам (нет пробела после маркера)
expect(checkboxUtils.matchCheckboxLine('-[ ] No space after marker for checkbox')).toBeNull();
// Это plain list item, а не чекбокс, т.к. нет пробела перед [ ]
const trickyLine = "-[ ] Not a checkbox, but could be a plain item if regex allowed marker immediately followed by non-space";
// По текущему regex ^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$ для plain list, это будет null.
// Если бы regex для plain list был ^(\s*)([*+-]|\d+\.)\s*(.*)$, и мы бы отсекали чекбоксы отдельно,
// то "-[ ] text" мог бы считаться plain. Но текущий regex для plain требует \s+ после маркера.
// А regex для чекбокса требует " \[" . Так что "-[ ] text" не матчится ни тем, ни другим.
expect(checkboxUtils.matchCheckboxLine(trickyLine)).toBeNull();
// A line that looks like a checkbox but is missing the char inside brackets
expect(checkboxUtils.matchCheckboxLine('- [] Text')).toBeNull(); // Checkbox regex expects a char
// A line that looks like a checkbox but missing space after brackets (and thus missing listItemText part)
expect(checkboxUtils.matchCheckboxLine('- [x]')).toBeNull(); // Checkbox regex expects \s after [.]
});
});
// --- Тесты для propagateStateToChildren с Plain List Items ---
describe('propagateStateToChildren with Plain List Items', () => {
it('should not change plain list item children', () => {
const text = [
'- [x] Parent Checkbox',
' - Plain Child Item',
' * [ ] Another Checkbox Child',
' - Deep Plain Child',
].join('\n');
const expected = [
'- [x] Parent Checkbox',
' - Plain Child Item', // Unchanged
' * [x] Another Checkbox Child', // Changed
' - Deep Plain Child', // Unchanged
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should not propagate from a plain list item parent', () => {
const text = [
'- Parent Plain Item', // line 0
' - [ ] Child Checkbox',
' * Plain Grandchild',
].join('\n');
// No changes expected as parent is NoCheckbox
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should skip ignored children and not skip childs plain list items correctly', () => {
const customSettings = createSettings({ ignoreSymbols: ['~'] });
const utils = new CheckboxUtils(customSettings);
const text = [
'- [x] Parent', // 0
' - Plain Child', // 1 (NoCheckbox)
' - [ ] Grandchild CB',// 2 (Checkbox under NoCheckbox, should be affected by Parent)
' - [~] Ignored Child', // 3 (Ignore)
' - [ ] Skipped GC', // 4 (Checkbox under Ignore, should not be affected)
' - [ ] Checkbox Child', // 5 (Checkbox)
].join('\n');
const expected = [
'- [x] Parent',
' - Plain Child', // Unchanged
' - [x] Grandchild CB',// Changed
' - [~] Ignored Child', // Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Checkbox Child', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation if a non-list-item line is encountered (indent > parent)', () => {
const text = [
'- [x] Parent',
' This is just text, not a list item.', // childLineInfo будет null, indent > parent.indent. Цикл прервется.
' - [ ] Grandchild (should not be processed as loop breaks)',
].join('\n');
const expected = [
'- [x] Parent',
' This is just text, not a list item.',
' - [ ] Grandchild (should not be processed as loop breaks)',
].join('\n');
// Проверяем, что Grandchild не изменился
const result = checkboxUtils.propagateStateToChildren(text, 0);
expect(result).toBe(expected);
// Дополнительно можно проверить, что matchCheckboxLine для "Grandchild" не вызывался бы
// (если это важно, но для покрытия достаточно проверки результата)
});
});
// --- Тесты для propagateStateFromChildren с Plain List Items ---
describe('propagateStateFromChildren with Plain List Items', () => {
it('should not update a plain list item parent based on children', () => {
const text = [
'- Parent Plain Item',
' - [x] Child Checkbox 1',
' - [x] Child Checkbox 2',
].join('\n');
// Parent is NoCheckbox, so it should not change
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should not ignore plain list item children when determining parent checkbox state', () => {
const text = [
'- [ ] Parent Checkbox',
' - Plain Child 1',
' - [x] Actual Checkbox Child',
' * Plain Child 2',
' - [ ] Grandchild (under plain, ignored for Parent Checkbox)',
].join('\n');
const expected = [
'- [ ] Parent Checkbox', // Becomes checked because "Actual Checkbox Child" is checked
' - Plain Child 1',
' - [x] Actual Checkbox Child',
' * Plain Child 2',
' - [ ] Grandchild (under plain, ignored for Parent Checkbox)',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('parent checkbox remains unchecked if all relevant children are plain or ignored', () => {
const customSettings = createSettings({ ignoreSymbols: ['~'] });
const utils = new CheckboxUtils(customSettings);
const text = [
'- [ ] Parent Checkbox',
' - Plain Child',
' - [~] Ignored Child',
].join('\n');
// No actual checkbox children to influence the parent
expect(utils.propagateStateFromChildren(text)).toBe(text);
const text2 = [
'- [x] Parent Checkbox Initially Checked',
' - Plain Child',
' - [~] Ignored Child',
].join('\n');
// Parent should remain checked as no relevant children to change it
expect(utils.propagateStateFromChildren(text2)).toBe(text2);
});
it('parent checkbox becomes unchecked if it has one unchecked checkbox child and other plain/ignored children', () => {
const text = [
'- [x] Parent Checkbox',
' - Plain Child',
' - [ ] Unchecked Checkbox Child', // This will make parent unchecked
' - [x] Checked Checkbox Child (but one is enough)',
].join('\n');
// Note: propagateStateFromChildren processes from bottom up.
// The order of children doesn't matter as much as their collective state.
// Let's simplify to ensure clarity:
const simplerText = [
'- [x] Parent Checkbox',
' - Plain Child',
' - [ ] Unchecked Checkbox Child',
].join('\n');
const expected = [
'- [ ] Parent Checkbox', // Becomes unchecked
' - Plain Child',
' - [ ] Unchecked Checkbox Child',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(simplerText)).toBe(expected);
});
it('handles mixed children correctly, bottom-up', () => {
const text = [
'- [ ] GP', // 0
' - Plain Child of GP', // 1
' - [ ] P1 (Checkbox)', // 2
' - [x] C1.1 (Checkbox)', // 3
' - Plain Grandchild', // 4
' - [x] P2 (Checkbox)', // 5 (initially checked)
' - Plain Grandchild 2', // 6
' - [ ] C2.1 (Unchecked CB)',// 7
].join('\n');
const expected = [
'- [ ] GP', // P1 becomes [x], P2 becomes [ ]. So GP is [ ]
' - Plain Child of GP',
' - [x] P1 (Checkbox)', // Becomes [x] due to C1.1
' - [x] C1.1 (Checkbox)',
' - Plain Grandchild',
' - [ ] P2 (Checkbox)', // Becomes [ ] due to C2.1
' - Plain Grandchild 2',
' - [ ] C2.1 (Unchecked CB)',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
});
// --- Тесты для syncText с Plain List Items (влияние на propagateStateToChildrenFromSingleDiff) ---
describe('syncText (and propagateStateToChildrenFromSingleDiff) with Plain List Items', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true, // Keep true to see full effect if needed, though child is main focus
}));
it('should propagate down if a line changes from Plain to Checked Checkbox', () => {
const textBefore = [
'- Parent Plain Item', // Was plain
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
const textAfter = [
'- [x] Parent Plain Item', // Now a checked checkbox
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
const expected = [
'- [x] Parent Plain Item',
' - [x] Child Checkbox', // Propagated
' - Plain Grandchild', // Unchanged (it's plain)
].join('\n');
expect(utils.syncText(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate down if a line changes from Checked Checkbox to Plain', () => {
const textBefore = [
'- [x] Parent Checkbox', // Was checkbox
' - [ ] Child Checkbox', // This would have been checked by propagation
' - Plain Grandchild',
].join('\n');
const textAfter = [
'- Parent Checkbox', // Now plain
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
// propagateStateToChildrenFromSingleDiff will call propagateStateToChildren
// propagateStateToChildren will see the parent is NoCheckbox and do nothing.
// Then propagateStateFromChildren runs, but parent is NoCheckbox.
expect(utils.syncText(textAfter, textBefore)).toBe(textAfter);
});
it('should correctly propagate up if a child changes, parent is checkbox, and siblings are plain', () => {
const textBefore = [
'- [ ] Parent Checkbox',
' - Plain Sibling',
' - [ ] Child Checkbox that changes',
].join('\n');
const textAfter = [
'- [ ] Parent Checkbox',
' - Plain Sibling',
' - [x] Child Checkbox that changes', // User checks this
].join('\n');
const expected = [
'- [x] Parent Checkbox', // Propagates up
' - Plain Sibling',
' - [x] Child Checkbox that changes',
].join('\n');
expect(utils.syncText(textAfter, textBefore)).toBe(expected);
});
});
});

View file

@ -1,774 +0,0 @@
import { CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine ---
describe('matchCheckboxLine', () => {
it('should match standard unchecked checkbox', () => {
const line = '- [ ] Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkChar: ' ',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Unchecked,
isChecked: false,
});
});
it('should match standard checked checkbox', () => {
const line = '* [x] Done';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkChar: 'x',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should match checkbox with indentation', () => {
const line = ' + [x] Indented Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '+',
checkChar: 'x',
checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should match numbered list checkbox', () => {
const line = '1. [ ] Numbered';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkChar: ' ',
checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4
checkboxState: CheckboxState.Unchecked,
isChecked: false,
});
});
it('should match checkbox with multi-digit numbered list', () => {
const line = '10. [x] Double Digit';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkChar: 'x',
checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should return null for lines without checkbox format', () => {
expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- Not a checkbox')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ]
expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside
expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker
expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker
});
it('should match custom symbols based on settings', () => {
const customSettings = createSettings({
checkedSymbols: ['X', 'V'],
uncheckedSymbols: ['O', '-'],
ignoreSymbols: ['~'],
});
const customUtils = new CheckboxUtils(customSettings);
const checkedLine = '- [V] Custom Checked';
const checkedResult = customUtils.matchCheckboxLine(checkedLine);
expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked);
expect(checkedResult?.isChecked).toBe(true);
expect(checkedResult?.checkChar).toBe('V');
const uncheckedLine = '* [O] Custom Unchecked';
const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine);
expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked);
expect(uncheckedResult?.isChecked).toBe(false);
expect(uncheckedResult?.checkChar).toBe('O');
const ignoredLine = '+ [~] Custom Ignored';
const ignoredResult = customUtils.matchCheckboxLine(ignoredLine);
expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore);
expect(ignoredResult?.isChecked).toBeUndefined();
expect(ignoredResult?.checkChar).toBe('~');
});
});
// --- Тесты для getCheckboxState ---
describe('getCheckboxState', () => {
it('should return Checked for symbols in checkedSymbols', () => {
const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked);
});
it('should return Unchecked for symbols in uncheckedSymbols', () => {
const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
});
it('should return Ignore for symbols in ignoreSymbols', () => {
const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore);
});
it('should use unknownSymbolPolicy for symbols not in any list', () => {
const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked });
const utilsChecked = new CheckboxUtils(settingsChecked);
expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked);
const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked });
const utilsUnchecked = new CheckboxUtils(settingsUnchecked);
expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore });
const utilsIgnore = new CheckboxUtils(settingsIgnore);
expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore);
});
});
// --- Тесты для updateLineCheckboxStateWithInfo ---
describe('updateLineCheckboxStateWithInfo', () => {
it('should update checkbox state from unchecked to checked', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
// Uses the first symbol from settings.checkedSymbols ('x' by default)
expect(updatedLine).toBe('- [x] Task');
});
it('should update checkbox state from checked to unchecked', () => {
const line = '* [x] Done';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
// Uses the first symbol from settings.uncheckedSymbols (' ' by default)
expect(updatedLine).toBe('* [ ] Done');
});
it('should use first symbol from settings for update', () => {
const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] });
const customUtils = new CheckboxUtils(customSettings);
const lineToCheck = '- [O] Task';
const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!;
const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck);
expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V'
const lineToUncheck = '* [V] Done';
const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!;
const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck);
expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O'
});
it('should handle indented lines correctly', () => {
const line = ' - [ ] Indented';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe(' - [x] Indented');
});
it('should return original line if position is invalid (simulated)', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 };
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLine).toBe(line); // Should not change
});
it('should use default checked symbol "x" if checkedSymbols setting is empty', () => {
// Создаем настройки с пустым списком checkedSymbols
const customSettings = createSettings({ checkedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [ ] Task';
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся отметить чекбокс
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x'
});
it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => {
// Создаем настройки с пустым списком uncheckedSymbols
const customSettings = createSettings({ uncheckedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся снять отметку
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел)
});
// Усиленный тест для невалидной позиции (с проверкой console.warn)
it('should return original line and warn if position is invalid', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Мокаем console.warn
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn
const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция
const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2);
expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз
// Восстанавливаем оригинальную функцию console.warn
warnSpy.mockRestore();
});
});
// --- Тесты для propagateStateToChildren ---
describe('propagateStateToChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check children when parent is checked', () => {
const text = [
'- [x] Parent', // line 0
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [ ] Sibling',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1',
' - [x] Grandchild',
' - [x] Child 2',
'- [ ] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should uncheck children when parent is unchecked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Grandchild',
' - [ ] Child 2', // Already unchecked
'- [x] Sibling',
].join('\n');
const expected = [
'- [ ] Parent',
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [x] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation at siblings or less indented lines', () => {
const text = [
' - [x] Parent', // line 0
' - [ ] Child',
' - [ ] Sibling', // Same indent level
'- [ ] Less Indented',
].join('\n');
const expected = [
' - [x] Parent',
' - [x] Child', // Changed
' - [ ] Sibling', // Not changed
'- [ ] Less Indented', // Not changed
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should skip ignored children and their subtrees', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [x] Parent', // 0
' - [ ] Child 1', // 1
' - [~] Ignored Child',// 2 (ignore)
' - [ ] Skipped GC', // 3 (skipped because parent ignored)
' - [ ] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1', // Changed
' - [~] Ignored Child',// Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Child 2', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should do nothing if the parent line is not a checkbox', () => {
const text = [
'Parent Line',
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should do nothing if the parent checkbox is ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [ ] Child',
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(text);
});
});
// --- Тесты для propagateStateFromChildren ---
describe('propagateStateFromChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check parent if all children are checked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild', // Needs parent (Child 2) to be checked first
].join('\n');
// Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent
const expectedPass1 = [ // After processing Grandchild and Child 2
'- [ ] Parent',
' - [x] Child 1',
' - [x] Child 2', // Stays checked
' - [x] Grandchild',
].join('\n');
const expectedPass2 = [ // After processing Child 1 and Parent
'- [x] Parent', // Changes to checked
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild',
].join('\n');
// Since it processes bottom-up, Child 2 should already be evaluated correctly
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2);
});
it('should uncheck parent if any child is unchecked', () => {
const text = [
'- [x] Parent', // line 0
' - [x] Child 1',
' - [ ] Child 2', // This one makes the parent unchecked
' - [ ] Grandchild',
].join('\n');
const expected = [
'- [ ] Parent', // Changes to unchecked
' - [x] Child 1',
' - [ ] Child 2',
' - [ ] Grandchild',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should handle nested updates correctly (bottom-up)', () => {
const text = [
'- [ ] Grandparent', // 0
' - [ ] Parent 1', // 1
' - [x] Child 1.1',// 2
' - [x] Child 1.2',// 3
' - [x] Parent 2', // 4
' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked)
].join('\n');
const expected = [
'- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked)
' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked)
' - [x] Child 1.1',
' - [x] Child 1.2',
' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked)
' - [ ] Child 2.1',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if it has no relevant children', () => {
const text = [
'- [ ] Parent',
'Sibling', // Not a child checkbox
' Not indented correctly',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should ignore non-checkbox lines when determining parent state', () => {
const text = [
'- [ ] Parent',
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
const expected = [
'- [x] Parent', // Should become checked
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should skip ignored children when determining parent state', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [x] Child 1', // 1
' - [~] Ignored Child',// 2 (skipped)
' - [ ] Skipped GC', // 3 (doesn't affect parent)
' - [x] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only)
' - [x] Child 1',
' - [~] Ignored Child',
' - [ ] Skipped GC',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if all direct children are ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked
const text2 = [
'- [x] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked
});
it('should not update ignored parents', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [x] Child 1',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored
});
it('should stop searching children when a line with equal or lesser indent is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'Some regular text', // indent 0. Stops search for Parent 1 children.
' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1.
'- [ ] Parent 2', // indent 0. Processed independently.
' - [x] Child 2.1', // indent 2. Child of Parent 2.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'Some regular text',
' - [x] Child 1.1',
'- [x] Parent 2', // Updated by Child 2.1
' - [x] Child 2.1',
].join('\n');
expect(result).toBe(expected);
});
it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children.
' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'- [x] Sibling Parent', // Updated by Child SP.1
' - [x] Child SP.1',
].join('\n');
expect(result).toBe(expected);
const text2 = [
' - [ ] Parent 1', // indent 2
' - [x] Child 1.1', // indent 4
' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children.
' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent.
].join('\n');
const result2 = checkboxUtils.propagateStateFromChildren(text2);
const expected2 = [
' - [x] Parent 1', // Updated by Child 1.1
' - [x] Child 1.1',
' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked)
' - [ ] Child SP.1',
].join('\n');
expect(result2).toBe(expected2);
});
});
// --- Тесты для syncText ---
describe('syncText', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
it('should propagate down only if child sync enabled and diff matches', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: false, // Parent sync off
}));
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected);
});
it('should propagate up only if parent sync enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false, // Child sync off
enableAutomaticParentState: true,
}));
const expected = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
// syncText calls propagateFromChildren unconditionally if enabled
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected);
});
it('should propagate down then up if both enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
// 1. User checks parent: '- [x] Parent', ' - [ ] Child'
// 2. Propagate down: '- [x] Parent', ' - [x] Child'
// 3. Propagate up: (no change needed as parent is already checked)
const expectedDown = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown);
// 1. User checks child: '- [ ] Parent', ' - [x] Child'
// 2. Propagate down: (no change, as only one line changed, and it wasn't the parent)
// 3. Propagate up: '- [x] Parent', ' - [x] Child'
const expectedUp = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp);
});
it('should do nothing if both syncs disabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false,
enableAutomaticParentState: false,
}));
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked);
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked);
});
it('should not propagate down if diff is not a single checkbox state change', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: false,
}));
const textMultipleChanges = [
'- [x] Parent Changed', // Text changed too
' - [x] Child also changed',
].join('\n');
const textIndentChange = [
' - [x] Parent', // Indent changed
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges);
// Indent changed (diff > 1 line index)
expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange);
});
});
// --- Тесты для propagateStateToChildrenFromSingleDiff ---
describe('propagateStateToChildrenFromSingleDiff', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
it('should propagate if only checkbox state changed', () => {
const textAfter = [
'- [x] Parent', // Only state changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should propagate if text content also changed', () => {
const textAfter = [
'- [x] Parent Changed Text', // State and text changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent Changed Text',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate if marker changed', () => {
const textAfter = [
'* [x] Parent', // Marker changed from - to *
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if indentation changed', () => {
const textAfter = [
' - [x] Parent', // Indentation changed
' - [ ] Child',
].join('\n');
// This will be detected as > 1 line difference by findDifferentLineIndexes
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if multiple lines changed', () => {
const textAfter = [
'- [x] Parent',
' - [x] Child', // Second line also changed
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if changed line is not a checkbox', () => {
const textBeforeNonCheck = "Line 1\nLine 2";
const textAfterNonCheck = "Line 1 changed\nLine 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck);
});
it('should propagate if changed line is an ignored checkbox', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const textBeforeIgnored = [
'- [~] Parent',
' - [ ] Child',
].join('\n');
const textAfterIgnored = [
'- [x] Parent', // Changed FROM ignored TO checked
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected);
});
it('should return original text if textBefore is undefined', () => {
const text = '- [ ] Line';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text);
});
it('should return original text if line counts differ', () => {
const text1 = '- [ ] Line1\n- [ ] Line2';
const text2 = '- [ ] Line1';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1);
});
});
// --- Тесты для findDifferentLineIndexes ---
describe('findDifferentLineIndexes', () => {
it('should return empty array for identical lines', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'b', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]);
});
it('should return index of the single different line', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'B', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]);
});
it('should return indexes of multiple different lines', () => {
const lines1 = ['a', 'b', 'c', 'd'];
const lines2 = ['A', 'b', 'C', 'd'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should detect differences at start and end', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['X', 'b', 'Y'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should throw error if line arrays have different lengths', () => {
const lines1 = ['a', 'b'];
const lines2 = ['a', 'b', 'c'];
expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2))
.toThrow("the length of the lines must be equal");
expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1))
.toThrow("the length of the lines must be equal");
});
});
});

View file

@ -0,0 +1,355 @@
// import { CheckboxLineInfo, CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу
import { CheckboxUtils2 } from 'src/core/CheckboxUtils2';
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from 'src/types'; // Путь к вашему файлу типов
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils2', () => {
// --- Тесты для syncText ---
describe('syncText', () => {
it('should propagate down only if child sync enabled and diff matches', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: false, // Parent sync off
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected);
});
it('should propagate up only if parent sync enabled', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: false, // Child sync off
enableAutomaticParentState: true,
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
const expected = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
// syncText calls propagateFromChildren unconditionally if enabled
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected);
});
it('should propagate down then up if both enabled', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
// 1. User checks parent: '- [x] Parent', ' - [ ] Child'
// 2. Propagate down: '- [x] Parent', ' - [x] Child'
// 3. Propagate up: (no change needed as parent is already checked)
const expectedDown = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown);
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
// 1. User checks child: '- [ ] Parent', ' - [x] Child'
// 2. Propagate down: (no change, as only one line changed, and it wasn't the parent)
// 3. Propagate up: '- [x] Parent', ' - [x] Child'
const expectedUp = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp);
});
it('should do nothing if both syncs disabled', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: false,
enableAutomaticParentState: false,
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked);
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked);
});
it('should not propagate down if diff is not a single checkbox state change', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: false,
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textMultipleChanges = [
'- [x] Parent Changed', // Text changed too
' - [x] Child also changed',
].join('\n');
const textIndentChange = [
' - [x] Parent', // Indent changed
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges);
// Indent changed (diff > 1 line index)
expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange);
});
it('equals texts', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: true,
}));
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textBefore, textBefore)).toBe(textBefore);
});
it('checkbox, list', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const textBefore = [
'- [ ] Parent',
' - Child1',
' - [ ] Child2',
].join('\n');
const actualText = [
'- [ ] Parent',
' - Child1',
' - [x] Child2',
].join('\n');
const expectedText = [
'- [x] Parent',
' - Child1',
' - [x] Child2',
].join('\n');
// Multiple lines changed
expect(utils.syncText(actualText, textBefore)).toBe(expectedText);
});
it('checkbox, plain text', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const textBefore = [
'- [ ] Parent',
' Child1',
' - [ ] Child2',
].join('\n');
const actualText = [
'- [ ] Parent',
' Child1',
' - [x] Child2',
].join('\n');
const expectedText = [
'- [x] Parent',
' Child1',
' - [x] Child2',
].join('\n');
// Multiple lines changed
expect(utils.syncText(actualText, textBefore)).toBe(expectedText);
});
describe('without textBefore', () => {
it('done from child to parent', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const actualText = [
'- [ ] Parent',
' - [x] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child',
].join('\n');
expect(utils.syncText(actualText, undefined)).toBe(expected);
});
it('todo from child to parent', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const actualText = [
'- [x] Parent',
' - [ ] Child',
].join('\n');
const expected = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
expect(utils.syncText(actualText, undefined)).toBe(expected);
});
});
describe('many roots', () => {
it('two roots witout text before', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const actualText = [
'- [x] Parent1',
' - [ ] Child1',
'- [ ] Parent2',
' - [x] Child2',
].join('\n');
const expected = [
'- [ ] Parent1',
' - [ ] Child1',
'- [x] Parent2',
' - [x] Child2',
].join('\n');
expect(utils.syncText(actualText, undefined)).toBe(expected);
});
it('two roots', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const textBefore = [
'- [x] Parent1',
' - [x] Child1',
'- [ ] Parent2',
' - [ ] Child2',
].join('\n');
const actualText = [
'- [x] Parent1',
' - [ ] Child1',
'- [ ] Parent2',
' - [x] Child2',
].join('\n');
const expected = [
'- [ ] Parent1',
' - [ ] Child1',
'- [x] Parent2',
' - [x] Child2',
].join('\n');
expect(utils.syncText(actualText, textBefore)).toBe(expected);
});
it('all type roots', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const textBefore = [
'- [x] Parent1',
' - [x] Child1',
'- Parent2',
' - Child2',
'Parent3',
' Child3',
].join('\n');
const actualText = [
'- [x] Parent1',
' - [ ] Child1',
'- Parent2',
' - Child2',
'Parent3',
' Child3',
].join('\n');
const expected = [
'- [ ] Parent1',
' - [ ] Child1',
'- Parent2',
' - Child2',
'Parent3',
' Child3',
].join('\n');
expect(utils.syncText(actualText, textBefore)).toBe(expected);
});
});
describe('bags', () => {
it('bag 1 propagete modify to grandparent', () => {
const utils = new CheckboxUtils2(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
const oldText = [
'- [ ] 3д детали',
' - [ ] грузики для веревки ^be5c11',
' - [ ] привод жалюзи ^202ccf',
' - [ ] 3д детали',
' - [ ] корпус',
' - [ ] бобина для веревки',
' - [ ] основной редуктор',
' - [ ] червячный редуктор',
' - [ ] подшипник',
].join('\n');
const actualText = [
'- [ ] 3д детали',
' - [ ] грузики для веревки ^be5c11',
' - [ ] привод жалюзи ^202ccf',
' - [x] 3д детали',
' - [ ] корпус',
' - [ ] бобина для веревки',
' - [ ] основной редуктор',
' - [ ] червячный редуктор',
' - [ ] подшипник',
].join('\n');
const expected = [
'- [ ] 3д детали',
' - [ ] грузики для веревки ^be5c11',
' - [ ] привод жалюзи ^202ccf',
' - [x] 3д детали',
' - [x] корпус',
' - [x] бобина для веревки',
' - [ ] основной редуктор',
' - [ ] червячный редуктор',
' - [ ] подшипник',
].join('\n');
expect(utils.syncText(actualText, oldText)).toBe(expected);
});
});
});
});

View file

@ -0,0 +1,35 @@
import { IFilePathStateHolder } from "src/IFilePathStateHolder";
export class InMemoryFilePathStateHolder implements IFilePathStateHolder {
private states: Map<string, string>;
constructor() {
this.states = new Map<string, string>();
}
public setByPath(filePath: string, text: string): void {
// console.log(`InMemoryFileStateHolder: Setting state for "${filePath}"`);
this.states.set(filePath, text);
}
public getByPath(filePath: string): string | undefined {
const state = this.states.get(filePath);
// console.log(`InMemoryFileStateHolder: Getting state for "${filePath}". Found: ${!!state}`);
return state;
}
// Вспомогательные методы для тестов (не часть интерфейса, но полезны)
public clear(): void {
this.states.clear();
}
public getInternalState(filePath: string): string | undefined {
return this.states.get(filePath); // Для прямых проверок в тестах
}
public setInitialStates(initialStates: Record<string, string>): void {
for (const path in initialStates) {
this.states.set(path, initialStates[path]);
}
}
}

View file

@ -0,0 +1,5 @@
There is partial integration with the Tasks plugin.
The goal is to add or remove a timestamp on checkboxes modified by the current plugin. In theory, if other tags appear, it should work as well.
Unfortunately, their current Tasks API does not provide a method to set a specific symbol or checkbox state, but it does offer a method to emulate a “click” on the checkbox.
Therefore, under certain specific transition settings in the Tasks plugin, this will not work — for example, when it is impossible to transition from the current state to the completed state.

View file

@ -4,6 +4,15 @@ nav_order: 3
---
# Changelog
## [1.2.0] - 2025-08-08
### Added
- Added Feature: **Logging Toggle:** Add a setting to enable/disable detailed logging for debugging purposes.
- Added Feature **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active.
- Added Feature: **Support Non-Checkbox Nodes:** Added support for list-item and plain text inside the checkbox tree
- Added [Integration with Tasks plugin]("Integration with Tasks plugin.md")
### Changed
- Transition to tree structure
## [1.1.0] - 2025-05-05
### Added
- Added Feature: Flexible Checkbox Symbol Configuration[#11](https://github.com/groldsf/obsidian_check_plugin/issues/11).
@ -67,4 +76,4 @@ nav_order: 3
- Fixed cursor and scroll bug.
## [1.0.0] - 2025-02-03
- Initial release of Checkbox Sync.
- Initial release of Checkbox Sync.

View file

@ -24,6 +24,7 @@ This provides flexibility in how you manage your task lists, allowing you to cus
* [Roadmap](roadmap.md)
* [Installation Guide](installation.md)
* [Contributing Guide](contributing.md)
* [Integration with Tasks plugin]("Integration with Tasks plugin.md")
### Acknowledgments
* [Obsidian](https://obsidian.md/) — a platform for creating and organizing notes.
@ -48,6 +49,7 @@ This provides flexibility in how you manage your task lists, allowing you to cus
* [Планы (Roadmap)](roadmap.md)
* [Руководство по установке](installation.md)
* [Руководство для контрибьюторов](contributing.md)
* [Интеграция с Tasks plugin]("Integration with Tasks plugin.md")
### Благодарности
* [Obsidian](https://obsidian.md/) — платформа для создания и организации заметок.

View file

@ -12,16 +12,11 @@ This document outlines the development status and future plans for the Checkbox
*This section lists the main features and technical work currently planned or in progress for the next release.*
* [Feature] **File/Folder Scope Filter:** Implement settings to include or exclude specific files or folders where the plugin should be active.
* [Feature] **Logging Toggle:** Add a setting to enable/disable detailed logging for debugging purposes.
* [Feature] **Support Non-Checkbox Nodes:** Allow regular list items (e.g., `- Parent Item` without `[ ]`) to function as structural nodes within the hierarchy for synchronization logic.
* [Feature] **Configurable Checkbox Character (Auto-Update):** Add a setting to define which character is used to mark checkboxes when automatically updated by the plugin.
* [Tech] **Overhaul List Parsing Logic:** Replace the current line-based parsing with a more robust tree-based approach to improve accuracy and maintainability.
## Future Ideas / Backlog
* [Feature] **List Reordering Functionality:** Explore and potentially implement list item reordering features, possibly inspired by approaches like [obsidian-checkboxReorder](https://github.com/Erl-koenig/obsidian-checkboxReorder) (e.g., moving completed items).
* [Tech] **Improve Change Detection:** Investigate using a library like `jsdiff` to more accurately detect and react to specific changes within files, potentially improving performance and reliability.
* [Tech] **Refactor Settings Handling:** Change how settings are used internally. Instead of passing the settings object by reference, create a new instance of `CheckboxUtils` (or relevant class) when settings are modified to ensure proper state isolation and updates.
---

View file

@ -51,6 +51,19 @@ These settings control when and how the synchronization logic runs.
- **Enabled:** Automatically synchronizes checkbox states when files are loaded/opened and immediately after plugin settings are applied. This ensures consistency but might have performance implications on very large vaults or files. *(Requires Obsidian restart or settings reload to take full effect)*.
- **Disabled (Default):** Synchronization only occurs when you *manually* change a checkbox's state within Obsidian. This is the default behavior to minimize potential performance impact.
### File Scope & Filtering
- **File ignore Rules (.gitignore style)**
- This setting allows you to define which files and folders Checkbox Sync should process. If the list of patterns is empty, the plugin will operate on all markdown files in your vault.
- The filtering uses **.gitignore syntax** and is powered by the [`ignore`](https://github.com/kaelzhang/node-ignore) library.
### Dev
- **Enable console log**
- **Description:** Toggles detailed logging to the developer console. When enabled, the plugin will output more information about its operations, which can be helpful for troubleshooting or understanding its behavior.
- **Enabled:** Debug logs are printed to the console.
- **Disabled (Default):** Debug logs are suppressed.
### Actions and Status
- **Error Display:** An area below the settings displays any errors encountered, such as invalid JSON in the symbol configuration.
@ -105,10 +118,23 @@ These settings control when and how the synchronization logic runs.
- **Включено:** Автоматически синхронизирует состояния чекбоксов при загрузке/открытии файлов и сразу после применения настроек плагина. Это обеспечивает консистентность, но может влиять на производительность в очень больших хранилищах или файлах. *(Требует перезапуска Obsidian или перезагрузки настроек для полного вступления в силу)*.
- **Отключено (По умолчанию):** Синхронизация происходит только тогда, когда вы *вручную* изменяете состояние чекбокса в Obsidian. Это поведение по умолчанию для минимизации потенциального влияния на производительность.
### File Scope & Filtering
- **Правила игнорирования файлов (стиль .gitignore)**
- Этот параметр позволяет определить, какие файлы и папки должен обрабатывать Checkbox Sync. Если список шаблонов пуст, плагин будет обрабатывать все файлы Markdown в вашем хранилище.
- Фильтрация использует **синтаксис .gitignore** и работает на основе библиотеки [`ignore`](https://github.com/kaelzhang/node-ignore).
### Разработка / Отладка
- **Включить Отладочное Логирование (Enable console log)**
- **Описание:** Переключает вывод подробных логов в консоль разработчика. Когда включено, плагин будет выводить больше информации о своих операциях, что может быть полезно для устранения неполадок или понимания его поведения.
- **Включено:** Отладочные логи выводятся в консоль.
- **Отключено (По умолчанию):** Отладочные логи не выводятся.
### Действия и Статус
- **Отображение Ошибок:** Область под настройками отображает любые возникшие ошибки, например, невалидный JSON в конфигурации символов.
- **Кнопки:**
- `Apply Changes` (Применить изменения): Сохраняет и применяет измененные настройки. Активна только при наличии изменений.
- `Reset changes` (Отменить изменения): Возвращает несохраненные изменения к последнему примененному состоянию настроек.
- `Reset to defaults` (Сбросить по умолчанию): Сбрасывает все настройки к значениям по умолчанию и немедленно применяет их.
- `Reset to defaults` (Сбросить по умолчанию): Сбрасывает все настройки к значениям по умолчанию и немедленно применяет их.

View file

@ -0,0 +1,78 @@
import { promises } from "fs";
/**
* Плагин esbuild для обертывания вызовов console.* в условие.
* @param {object} options
* @param {string} [options.debugFlagName='DEBUG_WRAP_CONSOLE_PLUGIN_FLAG'] - Имя глобальной переменной для проверки.
* @param {string[]} [options.methods=['log', 'warn', 'info', 'debug', 'error']] - Методы console, которые нужно обернуть.
* @returns {import('esbuild').Plugin}
*/
export const debugWrapConsolePlugin = ({
debugFlagName = "DEBUG_WRAP_CONSOLE_PLUGIN_FLAG", // Имя глобального флага (лучше сделать уникальным для вашего плагина)
methods = ["log", "warn", "info", "debug", "error"], // Какие методы console оборачивать
} = {}) => ({
name: "debug-wrap-console",
setup(build) {
// Создаем фильтр для методов console
const methodsPattern = methods.join("|");
// Регулярное выражение для поиска вызовов console.method(...)
// Оно пытается обработать простые случаи, но может быть неидеальным для сложных вложенных выражений или многострочных вызовов.
// $& в замене представляет всю совпавшую строку.
const consoleRegex = new RegExp(
// Match 'console.' followed by one of the specified methods
`(^|\\s+|\\{|\\;)(console\\.(${methodsPattern}))\\s*\\(` +
// Match arguments (non-greedy) - this is the tricky part and might not capture perfectly balanced parentheses in all complex cases
`([\\s\\S]*?)` +
// Match the closing parenthesis and optional semicolon
`\\)(;?)`,
"g" // Global search
);
const wrapperStart = `if (window.${debugFlagName}) { `;
const wrapperEnd = ` }`;
// Перехватываем загрузку JS/TS файлов
build.onLoad({ filter: /\.[jt]sx?$/ }, async (args) => {
try {
// Читаем содержимое файла
const source = await promises.readFile(args.path, "utf8");
// Заменяем вызовы console.*
const contents = source.replace(
consoleRegex,
(match, prefix, fullCall, methodName, argsContent, semicolon) => {
// prefix: Пробел, начало строки, {, ; перед вызовом console
// fullCall: Сам вызов, например, console.log
// methodName: Имя метода, например, log
// argsContent: Содержимое скобок
// semicolon: Завершающая точка с запятой (если была)
// Собираем обернутый вызов
// Мы используем 'match' целиком, чтобы сохранить оригинальное форматирование и содержимое,
// но убираем исходный префикс (пробел/начало строки/итд) и добавляем его перед if.
const originalCall = match.substring(prefix.length);
return `${prefix}${wrapperStart}${originalCall}${wrapperEnd}`;
}
);
// Возвращаем измененное содержимое и указываем esbuild,
// что это все еще JS/TS код (в зависимости от исходного файла)
const loader =
args.path.endsWith(".ts") || args.path.endsWith(".tsx") ? "ts" : "js";
return {
contents,
loader,
};
} catch (error) {
console.error(
`Error processing file ${args.path} in debug-wrap-console plugin:`,
error
);
// В случае ошибки возвращаем null или выбрасываем ошибку, чтобы сборка прервалась
return null;
}
});
},
});

View file

@ -1,6 +1,7 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { debugWrapConsolePlugin } from "./esbuild-debug-wrap-plugin.mjs";
const banner =
`/*
@ -39,6 +40,12 @@ const context = await esbuild.context({
treeShaking: true,
outfile: "main.js",
minify: prod,
plugins: [
debugWrapConsolePlugin({
debugFlagName: 'CHECKBOX_SYNC_DEBUG',
methods: ['log', 'info', 'debug'],
}),
],
});
if (prod) {

View file

@ -3,10 +3,18 @@
* https://jestjs.io/docs/configuration
*/
import type {Config} from 'jest';
import type { Config } from 'jest';
const config: Config = {
reporters: [
'default',
['jest-html-reporter', {
pageTitle: 'Test Report',
outputPath: './test-report.html',
}]
],
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
@ -17,14 +25,15 @@ const config: Config = {
coverageDirectory: "coverage",
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// coverageProvider: "v8",
coverageProvider: "babel",
// An array of file extensions your modules use
moduleFileExtensions: [
"ts",
"tsx",
"js",
"jsx",
"jsx",
"json",
"node"
],
@ -42,6 +51,11 @@ const config: Config = {
'^src/(.*)$': '<rootDir>/src/$1',
},
testPathIgnorePatterns: [
"/node_modules/", // Хорошо иметь это явно, хотя Jest часто делает это по умолчанию
"/__tests__/fakes/" // Исключаем папку fakes внутри __tests__
],
};
export default config;

View file

@ -1,7 +1,7 @@
{
"id": "checkbox-sync",
"name": "Checkbox Sync",
"version": "1.1.0",
"version": "1.2.0",
"minAppVersion": "0.12.0",
"description": "Automatically checks the parent checkbox if all child checkboxes are completed, and unchecks it otherwise.",
"author": "Grol",

1102
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "checkbox-sync",
"version": "1.0.0",
"version": "1.2.0",
"description": "Automatically checks the parent checkbox if all child checkboxes are completed, and unchecks it otherwise",
"main": "main.js",
"scripts": {
@ -22,6 +22,7 @@
"builtin-modules": "3.3.0",
"esbuild": "^0.25.0",
"jest": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"obsidian": "^1.7.2",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
@ -29,6 +30,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"async-mutex": "^0.5.0"
"async-mutex": "^0.5.0",
"ignore": "^7.0.4"
}
}
}

54
src/FileFilter.ts Normal file
View file

@ -0,0 +1,54 @@
import { CheckboxSyncPluginSettings } from './types';
import ignore, { Ignore } from 'ignore';
export class FileFilter {
private settings: Readonly<CheckboxSyncPluginSettings>;
private ignorer: Ignore | null = null;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
this.initIgnorer();
}
private initIgnorer(): void {
const globs = this.settings.pathGlobs;
if (globs && globs.length > 0) {
this.ignorer = ignore();
// Добавляем только непустые строки и не комментарии
const validGlobs = globs.filter(g => g && g.trim() !== '' && !g.trim().startsWith('#'));
if (validGlobs.length > 0) {
this.ignorer.add(validGlobs);
} else {
// Если все глобы были пустыми или комментариями
this.ignorer = null;
}
} else {
this.ignorer = null;
}
}
/**
* Checks if a given file path is allowed based on the pathGlobs setting
* using .gitignore-like logic provided by the 'ignore' library.
* @param filePath The file path to check (relative to the vault root).
* @returns True if the file is allowed for processing (i.e., NOT ignored), false otherwise.
*/
public isPathAllowed(filePath: string): boolean {
if (!this.ignorer) {
return true; // Если нет правил (или все были комментариями), все разрешено
}
// Метод ignorer.ignores(filePath) вернет true, если файл должен быть ИГНОРИРОВАН.
// Нам нужно инвертировать это, чтобы получить "разрешен ли файл".
return !this.ignorer.ignores(filePath);
}
/**
* Updates the filter المستقيمs with new plugin settings and re-initializes the ignorer.
* @param newSettings The new plugin settings.
*/
public updateSettings(newSettings: Readonly<CheckboxSyncPluginSettings>): void {
console.log("FileFilter: Settings updated."); // Для отладки
this.settings = newSettings; // Обновляем ссылку на настройки
this.initIgnorer(); // Переинициализируем ignorer с новыми pathGlobs
}
}

View file

@ -1,66 +1,84 @@
import { Mutex } from "async-mutex";
import { TFile, Vault } from "obsidian";
import { IFilePathStateHolder } from "./IFilePathStateHolder";
export default class FileStateHolder {
private map: Map<TFile, string>;
private vault: Vault;
private mutex: Mutex;
export default class FileStateHolder implements IFilePathStateHolder{
private map: Map<TFile, string>;
private vault: Vault;
private mutex: Mutex;
constructor(vault: Vault) {
this.vault = vault;
this.map = new Map();
this.mutex = new Mutex();
}
constructor(vault: Vault) {
this.vault = vault;
this.map = new Map();
this.mutex = new Mutex();
}
/**
* 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<boolean> {
if (this.has(file)) {
return false;
}
let res = await this.mutex.runExclusive<boolean>(async () => {
if (this.has(file)) {
return false;
}
// console.log(`updateIfNeeded "${file.name}" start`);
if (!text) {
text = await this.vault.read(file);
}
this.set(file, text);
return true;
});
return res;
}
/**
* 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<boolean> {
if (this.has(file)) {
return false;
}
let res = await this.mutex.runExclusive<boolean>(async () => {
if (this.has(file)) {
return false;
}
// console.log(`updateIfNeeded "${file.name}" start`);
if (!text) {
text = await this.vault.read(file);
}
this.set(file, text);
return true;
});
return res;
}
has(file: TFile) {
return this.map.has(file);
}
has(file: TFile) {
return this.map.has(file);
}
set(file: TFile, text: string) {
if (text !== this.map.get(file)) {
console.log(`File "${file.name}" load to holder`);
this.map.set(file, text);
}
}
set(file: TFile, text: string) {
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);
}
setByPath(filePath: string, text: string) {
const file = this.vault.getFileByPath(filePath);
if (file) {
this.set(file, text);
} else {
throw new Error(`file not found by path: ${filePath}.`);
}
}
getAllFiles(): TFile[] {
const keysIterator = this.map.keys();
const keysArray = Array.from(keysIterator);
return keysArray;
}
get(file: TFile) {
return this.map.get(file);
}
delete(file: TFile): boolean {
const existed = this.map.delete(file);
return existed;
}
}
getByPath(filePath: string) {
const file = this.vault.getFileByPath(filePath);
if (file) {
return this.get(file);
}
return undefined;
}
getAllFiles(): TFile[] {
const keysIterator = this.map.keys();
const keysArray = Array.from(keysIterator);
return keysArray;
}
delete(file: TFile): boolean {
const existed = this.map.delete(file);
return existed;
}
}

3
src/GlobalVars.ts Normal file
View file

@ -0,0 +1,3 @@
export class GlobalVars{
static APP:any;
}

View file

@ -0,0 +1,16 @@
export interface IFilePathStateHolder {
/**
* Sets or updates the text state associated with a file identified by its path.
* @param filePath - The path of the file.
* @param text - The text content to store.
* @throws Error if the file is not found by path (implementations may vary).
*/
setByPath(filePath: string, text: string): void;
/**
* Retrieves the text state associated with a file identified by its path.
* @param filePath - The path of the file.
* @returns The stored text content, or `undefined` if not found or file not found.
*/
getByPath(filePath: string): string | undefined;
}

View file

@ -1,94 +1,89 @@
import { Mutex } from "async-mutex";
import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian";
import { CheckboxUtils } from "./checkboxUtils";
import FileStateHolder from "./FileStateHolder";
import TextSyncPipeline from "./TextSyncPipeline";
export default class SyncController {
// private plugin: CheckboxSyncPlugin;//delete
private vault: Vault;
private checkboxUtils: CheckboxUtils;
private fileStateHolder: FileStateHolder;
private vault: Vault;
textSyncPipeline: TextSyncPipeline
private mutex: Mutex;
private mutex: Mutex;
constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder) {
// this.plugin = plugin;//delete
this.vault = vault;
this.checkboxUtils = checkboxUtils;
this.fileStateHolder = fileStateHolder;
this.mutex = new Mutex();
}
constructor(vault: Vault, textSyncPipeline: TextSyncPipeline) {
this.vault = vault;
this.textSyncPipeline = textSyncPipeline;
this.mutex = new Mutex();
}
async syncEditor(editor: Editor, info: MarkdownView | MarkdownFileInfo) {
if (!info.file) {
return;
}
await this.mutex.runExclusive(() => {
const file = info.file!;
console.log(`sync editor "${file.basename}" start.`);
const text = editor.getValue();
const textBefore = this.fileStateHolder.get(file);
async syncEditor(editor: Editor, info: MarkdownView | MarkdownFileInfo) {
if (!info.file) {
return;
}
await this.mutex.runExclusive(async () => {
const currentText = editor.getValue();
const resultingText = this.textSyncPipeline.applySyncLogic(currentText, info.file!.path);
if (resultingText !== currentText) {
await this.editEditor(editor, info as MarkdownView, currentText, resultingText);
}
});
}
let newText = this.checkboxUtils.syncText(text, textBefore);
if (newText === text) {
this.fileStateHolder.set(file, newText);
console.log(`sync editor "${file.basename}" stop. new text equals old text.`);
return;
}
async syncFile(file: TFile | null) {
if (!(file instanceof TFile) || file.extension !== "md") {
return;
}
await this.mutex.runExclusive(async () => {
await this.vault.process(file, (currentText) => {
return this.textSyncPipeline.applySyncLogic(currentText, file.path);
});
});
}
this.fileStateHolder.set(file, newText);
this.editEditor(editor, text, newText);
private async editEditor(editor: Editor, info: MarkdownView, oldText: string, newText: string) {
const cursor = editor.getCursor();
console.log(`syncEditor "${file.basename}" stop.`);
});
}
const newLines = newText.split("\n");
const oldLines = oldText.split("\n");
async syncFile(file: TFile | null) {
if (!(file instanceof TFile) || file.extension !== "md") {
return;
}
await this.mutex.runExclusive(async () => {
console.log(`sync file "${file.basename}" start.`);
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);
console.log(`sync file "${file.basename}" stop.`);
});
}
const diffIndexes = this.findDifferentLineIndexes(oldLines, newLines);
private editEditor(editor: Editor, oldText: string, newText: string) {
const cursor = editor.getCursor();
const changes: EditorChange[] = [];
const newLines = newText.split("\n");
const oldLines = oldText.split("\n");
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
});
const diffIndexes = this.checkboxUtils.findDifferentLineIndexes(oldLines, newLines);
editor.setCursor(cursor);
const changes: EditorChange[] = [];
const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1;
if (lastDifferentLineIndex != -1) {
editor.scrollIntoView({
from: { line: lastDifferentLineIndex, ch: 0 },
to: { line: lastDifferentLineIndex, ch: 0 }
});
}
await info.save();
}
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
});
private findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] {
if (lines1.length !== lines2.length) {
throw new Error("the length of the lines must be equal");
}
editor.setCursor(cursor);
const length = lines1.length;
const result: number[] = [];
for (let i = 0; i < length; i++) {
if (lines1[i] !== lines2[i]) {
result.push(i);
}
}
return result;
}
const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1;
if (lastDifferentLineIndex != -1) {
editor.scrollIntoView({
from: { line: lastDifferentLineIndex, ch: 0 },
to: { line: lastDifferentLineIndex, ch: 0 }
});
}
}
}
}

40
src/TextSyncPipeline.ts Normal file
View file

@ -0,0 +1,40 @@
import { ICheckboxUtils } from "./core/interface/ICheckboxUtils";
import { FileFilter } from "./FileFilter";
import { IFilePathStateHolder } from "./IFilePathStateHolder";
export default class TextSyncPipeline {
private checkboxUtils: ICheckboxUtils;
private fileStateHolder: IFilePathStateHolder;
private fileFilter: FileFilter;
constructor(checkboxUtils: ICheckboxUtils, fileStateHolder: IFilePathStateHolder, fileFilter: FileFilter) {
this.checkboxUtils = checkboxUtils;
this.fileStateHolder = fileStateHolder;
this.fileFilter = fileFilter;
}
public applySyncLogic(currentText: string, filePath: string): string {
return this.fileStateHolderDecorator(currentText, filePath);
}
private coreDecorator(currentText: string, textBefore: string | undefined): string {
const resultingText = this.checkboxUtils.syncText(currentText, textBefore);
return resultingText;
}
private pathAllowedDecorator(currentText: string, textBefore: string | undefined, filePath: string): string {
if (!this.fileFilter.isPathAllowed(filePath)) {
console.log(`pathAllowedDecorator "${filePath}" skip, path is not allowed.`);
return currentText;
}
return this.coreDecorator(currentText, textBefore);
}
private fileStateHolderDecorator(currentText: string, filePath: string): string {
const textBefore = this.fileStateHolder.getByPath(filePath);
const resultingText = this.pathAllowedDecorator(currentText, textBefore, filePath);
this.fileStateHolder.setByPath(filePath, resultingText);
return resultingText;
}
}

View file

@ -1,212 +0,0 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "./types";
export interface CheckboxLineInfo {
indent: number;
marker: string;
checkChar: string;
checkboxCharPosition: number;
checkboxState: CheckboxState;
isChecked: boolean | undefined;
}
export class CheckboxUtils {
private settings: Readonly<CheckboxSyncPluginSettings>;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
}
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;
const checkboxState = this.getCheckboxState(checkChar);
let isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked;
return {
indent,
marker,
checkChar,
checkboxCharPosition,
checkboxState,
isChecked
};
}
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 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);
}
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 parentLineInfo = this.matchCheckboxLine(lines[parentLine]);
if (!parentLineInfo) {
console.warn(`checkbox not found in line ${parentLine}`)
return text;
}
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
return text;
}
let parentIsChecked = parentLineInfo.isChecked!;
let j = parentLine + 1;
while (j < lines.length) {
const childText = lines[j];
const childLineInfo = this.matchCheckboxLine(childText);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
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");
}
propagateStateFromChildren(text: string): string {
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const parentLineInfo = this.matchCheckboxLine(lines[i]);
if (!parentLineInfo) continue;
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
continue;
}
const parentIsChecked = parentLineInfo.isChecked!;
let allChildrenChecked = true;
let hasChildren = false;
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 = childLineInfo.isChecked!;
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
}
if (hasChildren && parentIsChecked !== allChildrenChecked) {
lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allChildrenChecked, parentLineInfo);
}
}
return lines.join("\n");
}
syncText(text: string, textBefore: string | undefined): string {
let newText = text;
if (this.settings.enableAutomaticChildState) {
newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore);
}
if (this.settings.enableAutomaticParentState) {
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.checkboxState !== lineInfoAfter.checkboxState
) {
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;
}
}

View file

@ -0,0 +1,40 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { ICheckboxUtils } from "./interface/ICheckboxUtils";
import { ContextFactory } from "./model/ContextFactory";
import { PropagateStateToChildrenProcess } from "./process/PropagateStateToChildrenProcess";
import { PropagateStateToParentProcess } from "./process/PropagateStateToParentProcess";
export class CheckboxUtils2 implements ICheckboxUtils {
private settings: Readonly<CheckboxSyncPluginSettings>;
private propagateStateToChildrenProcess: PropagateStateToChildrenProcess;
private propagateStateToParentProcess: PropagateStateToParentProcess;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
this.propagateStateToChildrenProcess = new PropagateStateToChildrenProcess();
this.propagateStateToParentProcess = new PropagateStateToParentProcess();
}
syncText(text: string, textBefore: string | undefined): string {
if (text === textBefore) {
return text;
}
console.log(`text before:`);
console.log(text);
console.log("___");
const context = ContextFactory.createContext(text, textBefore, this.settings);
this.propagateStateToChildrenProcess.process(context);
this.propagateStateToParentProcess.process(context);
console.log(`text after:`);
const res = context.getResultText()
console.log(res);
console.log("___");
return context.getResultText();
}
}

3
src/core/Regexp.ts Normal file
View file

@ -0,0 +1,3 @@
export const TEXT_REGEXP = /^(\s*)(.*)$/;
export const LIST_ITEM_REGEXP = /^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/;
export const CHECKBOX_REGEXP = /^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/;

248
src/core/checkboxUtils.ts Normal file
View file

@ -0,0 +1,248 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "src/types";
import { ICheckboxUtils } from "./interface/ICheckboxUtils";
export interface CheckboxLineInfo {
indent: number;
marker: string;
checkChar?: string;
checkboxCharPosition?: number;
checkboxState: CheckboxState;
isChecked?: boolean | undefined;
listItemText?: string;
}
export class CheckboxUtils implements ICheckboxUtils {
private settings: Readonly<CheckboxSyncPluginSettings>;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
}
matchCheckboxLine(line: string): CheckboxLineInfo | null {
// const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s.*)?$/);
// const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s|$)(.*)$/);
const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/);
if (checkboxMatch) {
const indent = checkboxMatch[1].length;
const marker = checkboxMatch[2];
const checkChar = checkboxMatch[3];
const checkboxCharPosition = indent + marker.length + 2;
const checkboxState = this.getCheckboxState(checkChar);
const isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked;
const listItemText = (checkboxMatch[4] || "").trimStart();
return {
indent,
marker,
checkChar,
checkboxCharPosition,
checkboxState,
isChecked,
listItemText
};
}
const listItemMatch = line.match(/^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/);
if (listItemMatch) {
const indent = listItemMatch[1].length;
const marker = listItemMatch[2];
const listItemText = listItemMatch[3].trimStart();
return {
indent,
marker,
// checkChar, checkboxCharPosition, isChecked - не применимы
checkboxState: CheckboxState.NoCheckbox,
listItemText
};
}
return null;
}
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 {
if (lineInfo.checkboxState === CheckboxState.NoCheckbox) {
console.warn("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line);
return line;
}
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);
} else {
console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line);
return line;
}
}
propagateStateToChildren(text: string, parentLine: number): string {
const lines = text.split("\n");
const parentLineInfo = this.matchCheckboxLine(lines[parentLine]);
if (!parentLineInfo) {
console.warn(`checkbox not found in line ${parentLine}`)
return text;
}
if (parentLineInfo.checkboxState === CheckboxState.Ignore ||
parentLineInfo.checkboxState === CheckboxState.NoCheckbox ||
parentLineInfo.isChecked === undefined) {
return text;
}
const parentIsChecked = parentLineInfo.isChecked;
let j = parentLine + 1;
while (j < lines.length) {
const childText = lines[j];
const childLineInfo = this.matchCheckboxLine(childText);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) {
break;
}
if (childLineInfo.checkboxState === CheckboxState.Ignore) {
//skip children ignore node
j++;
while (j < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
} else if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) {
j++;
} else {
lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo);
j++;
}
}
return lines.join("\n");
}
propagateStateFromChildren(text: string): string {
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const parentLineInfo = this.matchCheckboxLine(lines[i]);
if (!parentLineInfo) continue;
if (
parentLineInfo.checkboxState === CheckboxState.Ignore ||
parentLineInfo.checkboxState === CheckboxState.NoCheckbox
) {
continue;
}
const parentIsChecked = parentLineInfo.isChecked!;
let allRelevantChildrenChecked = true;
let hasRelevantChildren = false;
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;
}
if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) {
continue;
}
hasRelevantChildren = true;
const childrenIsChecked = childLineInfo.isChecked!;
if (!childrenIsChecked) {
allRelevantChildrenChecked = false;
break;
}
}
if (hasRelevantChildren && parentIsChecked !== allRelevantChildrenChecked) {
lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allRelevantChildrenChecked, parentLineInfo);
}
}
return lines.join("\n");
}
syncText(text: string, textBefore: string | undefined): string {
let newText = text;
if (this.settings.enableAutomaticChildState) {
newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore);
}
if (this.settings.enableAutomaticParentState) {
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.checkboxState !== lineInfoAfter.checkboxState
) {
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;
}
}

View file

@ -0,0 +1,5 @@
import { Context } from "../model/Context";
export interface CheckboxProcess {
process(context: Context): void;
}

View file

@ -0,0 +1,3 @@
export interface ICheckboxUtils {
syncText(text: string, textBefore: string | undefined): string;
}

75
src/core/model/Context.ts Normal file
View file

@ -0,0 +1,75 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { View } from "./View";
import { Line } from "./Line";
import { ContextFactory } from "./ContextFactory";
export class Context {
private settings: Readonly<CheckboxSyncPluginSettings>;
private text: string;
private textLines?: string[];
private textBeforeChange?: string;
private textBeforeChangeLines?: string[];
private lines?: Line[];
private view?: View;
constructor(text: string, textBeforeChange: string | undefined, settings: Readonly<CheckboxSyncPluginSettings>) {
this.text = text;
this.textBeforeChange = textBeforeChange;
this.settings = settings;
}
getSettings(): Readonly<CheckboxSyncPluginSettings> {
return this.settings;
}
textBeforeChangeIsPresent(): boolean {
if (this.textBeforeChange) {
return true;
}
return false;
}
getText() {
return this.text;
}
getTextLines() {
if (!this.textLines) {
this.textLines = this.getText().split("\n");
}
return this.textLines;
}
getTextBeforeChange() {
return this.textBeforeChange;
}
getTextBeforeChangeLines() {
if (!this.textBeforeChangeLines) {
this.textBeforeChangeLines = this.textBeforeChange?.split("\n");
}
return this.textBeforeChangeLines;
}
getLines() {
if (!this.lines) {
this.lines = ContextFactory.createLines(this);
}
return this.lines;
}
getView(): View {
if (!this.view) {
this.view = ContextFactory.createView(this);
}
return this.view;
}
getResultText(): string {
return this.getView().toResultText();
}
}

View file

@ -0,0 +1,156 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { Context } from "./Context";
import { TreeNode } from "./TreeNode";
import { Line } from "./Line";
import { View } from "./View";
import { CheckboxLine } from "./line/CheckboxLine";
import { ListLine } from "./line/ListLine";
import { TextLine } from "./line/TextLine";
export class ContextFactory {
private constructor() {
}
static createContext(text: string, textBefore: string | undefined, settings: Readonly<CheckboxSyncPluginSettings>): Context {
return new Context(text, textBefore, settings);
}
static createView(context: Context) {
const lines = context.getLines();
const [rootNodes, changeNode] = this.createRootTreeNodesFromLines(lines);
return new View(rootNodes, changeNode);
}
// создаёт TreeNode из Line
// возвращает только корневые узлы и изменённую строку
private static createRootTreeNodesFromLines(lines: Line[]): [roots: TreeNode[], changeNode: TreeNode | null] {
const [flatTreeNodes, changeNode] = this.createFlatNodesFromLines(lines);
const rootNodes = flatTreeNodes.filter(node => !node.getParent());
return [rootNodes, changeNode];
}
// создаёт TreeNode из Line и возвращает изменённую строку если есть.
// осторожно, возвращает ВСЕ ноды в порядке линий
private static createFlatNodesFromLines(lines: Line[]): [treeNodes: TreeNode[], changeNode: TreeNode | null] {
const nodes: TreeNode[] = [];
const stack: TreeNode[] = [];
let changeNode: null | TreeNode = null;
for (const line of lines) {
const node = new TreeNode(line);
if (line instanceof CheckboxLine && line.isChange()) {
changeNode = node;
}
// найти родителя
let parent = undefined;
while (stack.length > 0) {
const previousNode = stack[stack.length - 1];
const previousLine = previousNode.getLine();
if (previousLine.getIndent() < line.getIndent()) {
parent = previousNode;
break;
} else {
stack.pop();
}
}
// добавить родителю, если есть
parent?.addChildren(node);
nodes.push(node);
stack.push(node);
}
return [nodes, changeNode];
}
static createLines(context: Context): Line[] {
const settings = context.getSettings();
const lines = this.createLinesFromTextLines(context.getTextLines(), settings);
this.markChangeIfNeeded(lines, context);
return lines;
}
// помечает line как изменённую при необходимости
static markChangeIfNeeded(lines: Line[], context: Context) {
if (!context.textBeforeChangeIsPresent()) {
return;
}
const textLines = context.getTextLines();
const textBeforeLines = context.getTextBeforeChangeLines()!;
if (textLines.length !== textBeforeLines.length) {
return;
}
const diffIndex = this.findSingleDiffLineIndex(textLines, textBeforeLines);
if (diffIndex === undefined) {
return;
}
const oldLine = this.createLineFromTextLine(textBeforeLines[diffIndex], context.getSettings());
const actualLine = lines[diffIndex];
// если новая и старая строка чекбокс
if (oldLine instanceof CheckboxLine && actualLine instanceof CheckboxLine) {
// если изменилось состояние чекбокса
if (
oldLine.getState() !== actualLine.getState()
) {
actualLine.setChange(true);
const text = actualLine.toResultText();
console.log(`ContextFactory:markChangeIfNeeded: ${text} is change`);
}
}
}
// создаёт Line[] из текста
private static createLinesFromTextLines(textLines: string[], settings: Readonly<CheckboxSyncPluginSettings>): Line[] {
return textLines.map(line => {
return this.createLineFromTextLine(line, settings);
});
}
// создаёт объект из одного из классов, кто реализует Line в зависимости от строки.
static createLineFromTextLine(stringLine: string, settings: Readonly<CheckboxSyncPluginSettings>): Line {
const checkboxLine = CheckboxLine.createFromLine(stringLine, settings);
if (checkboxLine) {
return checkboxLine;
}
const listLine = ListLine.createFromLine(stringLine, settings);
if (listLine) {
return listLine;
}
const textLine = TextLine.createFromLine(stringLine, settings)!;
return textLine;
}
private static findSingleDiffLineIndex(lines1: string[], lines2: string[]): number | undefined {
const diffLines = this.findDifferentLineIndexes(lines1, lines2);
if (diffLines.length == 1) {
return diffLines[0];
}
return undefined;
}
private static 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;
}
}

12
src/core/model/Line.ts Normal file
View file

@ -0,0 +1,12 @@
import { CheckboxState } from "src/types";
export interface Line {
getIndent(): number;
getText(): string;
toResultText(): string;
getState(): CheckboxState;
}

View file

@ -0,0 +1,63 @@
import { Line } from "./Line";
import { CheckboxLine } from "./line/CheckboxLine";
export class TreeNode {
private parent?: TreeNode;
private childrens: TreeNode[] = [];
private line: Line;
// метка того, что Единичное изменение произошло в этой ноде или дочерних нодах
private modify = false;
constructor(line: Line) {
this.line = line;
if (line instanceof CheckboxLine && line.isChange()) {
this.modify = true;
}
}
getLine(): Line {
return this.line;
}
isModify(): boolean {
return this.modify;
}
setModify(isModify:boolean){
this.modify = isModify;
}
private setParent(parent: TreeNode) {
this.parent = parent;
}
getParent(): TreeNode | undefined {
return this.parent;
}
addChildren(children: TreeNode) {
this.childrens.push(children);
children.setParent(this);
}
hasChildren(): boolean {
return this.childrens.length > 0;
}
getChildrenNodes(): TreeNode[] {
return [...this.childrens];
}
toResultText(): string {
const parts: string[] = [];
parts.push(this.line.toResultText());
for (const children of this.childrens) {
parts.push(children.toResultText());
}
return parts.join("\n");
}
}

32
src/core/model/View.ts Normal file
View file

@ -0,0 +1,32 @@
import { TreeNode } from "./TreeNode";
export class View {
private treeNodes: TreeNode[];
// метка того, что Единичное изменение произошло в этой View
private changeNode: TreeNode | null;
constructor(treeNodes: TreeNode[], changeNode: TreeNode | null) {
this.treeNodes = treeNodes;
this.changeNode = changeNode;
}
isModify(): boolean {
return this.changeNode ? true : false;
}
getChangeNode(): TreeNode | null {
return this.changeNode;
}
getTreeNodes(): TreeNode[] {
return this.treeNodes;
}
toResultText(): string {
const parts: string[] = [];
for (const treeNode of this.treeNodes) {
parts.push(treeNode.toResultText());
}
return parts.join("\n");
}
}

View file

@ -0,0 +1,44 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "src/types";
import { Line } from "../Line";
export abstract class AbstractLine implements Line {
protected indentString: string;
protected tabSize: number;
// длина отступа
protected indent: number;
// текст после чекбокса
protected listText: string;
constructor(indentString: string, lineText: string, settings: Readonly<CheckboxSyncPluginSettings>) {
this.indentString = indentString;
this.listText = lineText;
this.tabSize = settings.tabSize;
this.indent = this.getIndentFromString(indentString, this.tabSize);
}
private getIndentFromString(indentString: string, tabSize: number): number {
let indent = 0;
for (const char of indentString) {
if (char === '\t') {
indent += tabSize;
} else if (char === ' ') {
indent += 1;
}
}
return indent;
}
getIndent(): number {
return this.indent;
}
getText(): string {
return this.listText;
}
abstract toResultText(): string;
abstract getState(): CheckboxState;
}

View file

@ -0,0 +1,142 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "src/types";
import { AbstractLine } from "./AbstractLine";
import { GlobalVars } from "src/GlobalVars";
import { CHECKBOX_REGEXP } from "src/core/Regexp";
export class CheckboxLine extends AbstractLine {
// символы перед чекбоксом
private marker: string;
// символ в чекбоксе
private checkChar: string;
// позиция символа чекбокса в исходной строке
// private checkboxCharPosition: number;
// интерпретация символа чекбокса(или его отсутствия)
private checkboxState: CheckboxState;
// интерпретация состояния чекбокса(или его отсутствия)
// private isChecked?: boolean | undefined;
// метка того, что Единичное изменение произошло в этой строке
private hasChange = false;
protected settings: Readonly<CheckboxSyncPluginSettings>;
constructor(indentString: string, marker: string, checkChar: string, listItemText: string, settings: Readonly<CheckboxSyncPluginSettings>) {
super(indentString, listItemText, settings);
this.marker = marker;
this.checkChar = checkChar;
this.settings = settings;
// this.checkboxCharPosition = indent + marker.length + 2;
this.checkboxState = this.getCheckboxState(checkChar);
}
static createFromLine(textLine: string, settings: Readonly<CheckboxSyncPluginSettings>): CheckboxLine | null {
const checkboxMatch = textLine.match(CHECKBOX_REGEXP);
if (checkboxMatch) {
const indentString = checkboxMatch[1];
const marker = checkboxMatch[2];
const checkChar = checkboxMatch[3];
const listItemText = checkboxMatch[4].trimStart();
return new CheckboxLine(indentString, marker, checkChar, listItemText, settings);
}
return null;
}
isChange(): boolean {
return this.hasChange;
}
setChange(hasChange: boolean) {
this.hasChange = hasChange;
}
private static getNewCheckboxLineFromTasksApi(api: any, checkboxLine: CheckboxLine, targetState: CheckboxState): CheckboxLine | null {
let line: CheckboxLine | null = checkboxLine;
const seenChars = new Set<string>();
seenChars.add(checkboxLine.checkChar);
while (true) {
const textLine = line.toResultText();
const newTextLine = api.executeToggleTaskDoneCommand(textLine, null);
const newBox = CheckboxLine.createFromLine(newTextLine, line.settings);
if (!newBox) {
return null;
}
if (newBox.checkboxState === targetState) {
return newBox;
}
if (seenChars.has(newBox.checkChar)) {
return null;
}
seenChars.add(newBox.checkChar);
line = newBox;
}
}
setState(state: CheckboxState): void {
const taskPlugin = GlobalVars.APP?.plugins.plugins['obsidian-tasks-plugin'];
if (taskPlugin) {
const api = taskPlugin.apiV1;
const res = CheckboxLine.getNewCheckboxLineFromTasksApi(api, this, state);
if (res !== null) {
this.listText = res.listText;
}
}
// обновить checkboxState
this.checkboxState = state;
// обновить checkChar
this.checkChar = this.getCharFromState(state);
}
setStateIfNotEquals(newState: CheckboxState) {
if (newState !== this.checkboxState) {
this.setState(newState);
}
}
getState(): CheckboxState {
return this.checkboxState;
}
toResultText(): string {
// const spaces = ' '.repeat(this.indent);
const resultText = this.indentString + this.marker + " [" + this.checkChar + "] " + this.listText;
return resultText;
}
private 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;
}
private getCharFromState(state: CheckboxState): string {
switch (state) {
case CheckboxState.Checked:
return this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт
case CheckboxState.Unchecked:
return this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт
case CheckboxState.Ignore:
if (this.settings.ignoreSymbols.length > 0) {
return this.settings.ignoreSymbols[0];
} else {
throw new Error("Not found ignore char.");
}
case CheckboxState.NoCheckbox:
throw new Error(`Unexpected value for parameter [state] = ${state}. "NoCheckbox".`);
default:
throw new Error(`Unexpected value for parameter [state] = ${state}.`);
}
}
}

View file

@ -0,0 +1,43 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "src/types";
import { AbstractLine } from "./AbstractLine";
import { LIST_ITEM_REGEXP } from "src/core/Regexp";
export class ListLine extends AbstractLine {
// символы перед текстом
private marker: string;
constructor(indentString: string, marker: string, listText: string, settings: Readonly<CheckboxSyncPluginSettings>) {
super(indentString, listText, settings);
this.marker = marker;
}
static createFromLine(textLine: string, settings: Readonly<CheckboxSyncPluginSettings>): ListLine | null {
const listItemMatch = textLine.match(LIST_ITEM_REGEXP);
if (listItemMatch) {
const indentString = listItemMatch[1];
const marker = listItemMatch[2];
const listItemText = listItemMatch[3].trimStart();
return new ListLine(indentString, marker, listItemText, settings);
}
return null;
}
getMarker(): string {
return this.marker;
}
setMarker(marker: string) {
this.marker = marker;
}
toResultText(): string {
// const spaces = ' '.repeat(this.indent);
const resultText = this.indentString + this.marker + " " + this.listText;
return resultText;
}
getState(): CheckboxState {
return CheckboxState.NoCheckbox;
}
}

View file

@ -0,0 +1,26 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "src/types";
import { AbstractLine } from "./AbstractLine";
import { TEXT_REGEXP } from "src/core/Regexp";
export class TextLine extends AbstractLine {
static createFromLine(textLine: string, settings: Readonly<CheckboxSyncPluginSettings>): TextLine | null {
const textLineMatch = textLine.match(TEXT_REGEXP);
if (textLineMatch) {
const indentString = textLineMatch[1];
const itemText = textLineMatch[2];
return new TextLine(indentString, itemText, settings);
}
return null;
}
toResultText(): string {
// const spaces = ' '.repeat(this.indent);
const resultText = this.indentString + this.listText;
return resultText;
}
getState(): CheckboxState {
return CheckboxState.NoCheckbox;
}
}

View file

@ -0,0 +1,52 @@
import { CheckboxState } from "src/types";
import { CheckboxProcess } from "../interface/CheckboxProcess";
import { Context } from "../model/Context";
import { TreeNode } from "../model/TreeNode";
import { CheckboxLine } from "../model/line/CheckboxLine";
export class PropagateStateToChildrenProcess implements CheckboxProcess {
process(context: Context): void {
if (!context.getSettings().enableAutomaticChildState) {
return;
}
if (!context.textBeforeChangeIsPresent()) {
console.log("PropagateStateToChildrenProcess: textBeforeChange is not present");
return;
}
// получить представление текста
const view = context.getView();
if (!view.isModify()) {
console.log("PropagateStateToChildrenProcess: not modify");
return;
}
const changeNode = view.getChangeNode()!;
this.propagateStateToChildrenFromNode(changeNode);
}
propagateStateToChildrenFromNode(modifiedNode: TreeNode) {
const line = modifiedNode.getLine();
if (!(line instanceof CheckboxLine)) {
console.warn(`propagateStateToChildren from !CheckboxLine.`);
return;
}
const state = line.getState();
if (state === CheckboxState.Ignore || state === CheckboxState.NoCheckbox) {
console.warn(`propagateStateToChildren from uncorrect state: ${state.toString()}`);
return;
}
this.propagateStateToChildren(modifiedNode, state);
}
propagateStateToChildren(node: TreeNode, state: CheckboxState) {
const line = node.getLine();
if (line instanceof CheckboxLine) {
line.setStateIfNotEquals(state);
}
for (const childrenNode of node.getChildrenNodes()) {
this.propagateStateToChildren(childrenNode, state);
}
}
}

View file

@ -0,0 +1,71 @@
import { CheckboxState } from "src/types";
import { CheckboxProcess } from "../interface/CheckboxProcess";
import { Context } from "../model/Context";
import { TreeNode } from "../model/TreeNode";
import { CheckboxLine } from "../model/line/CheckboxLine";
export class PropagateStateToParentProcess implements CheckboxProcess {
process(context: Context): void {
if (!context.getSettings().enableAutomaticParentState) {
return;
}
// получить представление текста
const view = context.getView();
const nodes = view.getTreeNodes();
for (const node of nodes) {
this.propagateStateToParent(node);
}
}
// возврат значения нужен для того, чтобы правильно обрабатывать случаи с "не чекбоксам", чтобы они передавали значения детей
propagateStateToParent(node: TreeNode): CheckboxState {
const line = node.getLine();
// обходим всех детей
const childrenStates: CheckboxState[] = [];
for (const childrenNode of node.getChildrenNodes()) {
const childrenState = this.propagateStateToParent(childrenNode);
childrenStates.push(childrenState);
}
const newState = this.getNewState(line.getState(), childrenStates);
if (line instanceof CheckboxLine){
line.setStateIfNotEquals(newState);
}
return newState;
}
getNewState(actualState: CheckboxState, childrenStates: CheckboxState[]): CheckboxState {
if (actualState === CheckboxState.Ignore) {
return actualState;
}
// обходим всех детей
// если есть релевантные дети, то помечаем это и обновляем "состояние" Line
// "состояние" - потому что даже не чекбоксы могут иметь состояние через своих детей
let resultIfHasRelevantChildren = CheckboxState.Checked;
let hasRelevantChildren = false;
for (const childrenState of childrenStates) {
if (childrenState !== CheckboxState.Ignore && childrenState !== CheckboxState.NoCheckbox) {
hasRelevantChildren = true;
}
if (childrenState === CheckboxState.Unchecked) {
resultIfHasRelevantChildren = CheckboxState.Unchecked;
}
}
if (hasRelevantChildren) {
return resultIfHasRelevantChildren;
} else {
return actualState;
}
}
}

View file

@ -42,4 +42,4 @@ export class FileChangeEventHandler {
})
);
}
}
}

View file

@ -1,55 +1,83 @@
import { Plugin, TFile } from "obsidian";
import { CheckboxUtils } from "./checkboxUtils";
import { FileChangeEventHandler } from "./events/FileChangeEventHandler";
import { FileLoadEventHandler } from "./events/FileLoadEventHandler";
import FileStateHolder from "./FileStateHolder";
import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab";
import SyncController from "./SyncController";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types";
import { FileFilter } from "./FileFilter";
import TextSyncPipeline from "./TextSyncPipeline";
import { ICheckboxUtils } from "./core/interface/ICheckboxUtils";
import { CheckboxUtils2 } from "./core/CheckboxUtils2";
import { GlobalVars } from "./GlobalVars";
const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG';
// --- Объявление глобальной переменной для TypeScript ---
// Это нужно, чтобы TypeScript не ругался на window[DEBUG_FLAG_NAME]
declare global {
interface Window {
[key: string]: any; // Позволяем индексировать window строкой
}
}
export default class CheckboxSyncPlugin extends Plugin {
private _settings: CheckboxSyncPluginSettings;
private _settings: CheckboxSyncPluginSettings;
private syncController: SyncController;
private checkboxUtils: CheckboxUtils;
private fileStateHolder: FileStateHolder;
private fileLoadEventHandler: FileLoadEventHandler;
private fileChangeEventHandler: FileChangeEventHandler;
private syncController: SyncController;
private checkboxUtils: ICheckboxUtils;
private fileStateHolder: FileStateHolder;
private fileLoadEventHandler: FileLoadEventHandler;
private fileChangeEventHandler: FileChangeEventHandler;
private fileFilter: FileFilter;
private textSyncPipeline: TextSyncPipeline;
async onload() {
await this.loadSettings();
async onload() {
GlobalVars.APP = this.app;
await this.loadSettings();
this.fileStateHolder = new FileStateHolder(this.app.vault);
this.checkboxUtils = new CheckboxUtils(this.settings);
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.fileStateHolder);
this.fileFilter = new FileFilter(this.settings);
this.fileStateHolder = new FileStateHolder(this.app.vault);
this.checkboxUtils = new CheckboxUtils2(this.settings);
this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils, this.fileStateHolder, this.fileFilter);
this.syncController = new SyncController(this.app.vault, this.textSyncPipeline);
this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder);
this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder);
this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this));
this.fileLoadEventHandler.registerEvents();
this.fileChangeEventHandler.registerEvents();
}
this.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this));
this.fileLoadEventHandler.registerEvents();
this.fileChangeEventHandler.registerEvents();
}
async loadSettings() {
this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async loadSettings() {
this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.setDebugFlag(this.settings.consoleEnabled);
}
get settings(): Readonly<CheckboxSyncPluginSettings> {
return this._settings;
}
get settings(): Readonly<CheckboxSyncPluginSettings> {
return this._settings;
}
async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise<void>) {
await callback(this._settings);
await this.saveData(this.settings);
async updateSettings(callback: (settings: CheckboxSyncPluginSettings) => void | Promise<void>) {
await callback(this._settings);
this.setDebugFlag(this.settings.consoleEnabled);
this.fileFilter.updateSettings(this.settings);
await this.saveData(this.settings);
if (this.settings.enableAutomaticFileSync) {
//надо пересинхронизировать все файлы в кеше
const allFile = this.fileStateHolder.getAllFiles();
await Promise.all(
allFile.map(async (file: TFile) => {
await this.syncController.syncFile(file);
})
);
}
}
if (this.settings.enableAutomaticFileSync) {
//надо пересинхронизировать все файлы в кеше
const allFile = this.fileStateHolder.getAllFiles();
await Promise.all(
allFile.map(async (file: TFile) => {
await this.syncController.syncFile(file);
})
);
}
}
private setDebugFlag(enabled: boolean) {
window[DEBUG_FLAG_NAME] = enabled;
}
}

View file

@ -1,4 +1,5 @@
export interface CheckboxSyncPluginSettings {
tabSize: number,
enableAutomaticParentState: boolean;
enableAutomaticChildState: boolean;
checkedSymbols: string[];
@ -6,15 +7,19 @@ export interface CheckboxSyncPluginSettings {
ignoreSymbols: string[];
unknownSymbolPolicy: CheckboxState;
enableAutomaticFileSync: boolean;
consoleEnabled: boolean;
pathGlobs: string[];
}
export enum CheckboxState {
Checked = 'checked',
Unchecked = 'unchecked',
Ignore = 'ignore'
Ignore = 'ignore',
NoCheckbox = "noCheckbox"
}
export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
tabSize: 4,
enableAutomaticParentState: true,
enableAutomaticChildState: true,
checkedSymbols: ['x'],
@ -22,4 +27,6 @@ export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
ignoreSymbols: [],
unknownSymbolPolicy: CheckboxState.Checked,
enableAutomaticFileSync: false,
};
consoleEnabled: false,
pathGlobs: [],
};

View file

@ -15,283 +15,302 @@ import { EnableParentSyncSettingComponent } from "./components/synchronizationBe
import { ConfirmModal, InfoModal, SaveConfirmModal } from "./modals";
import { SettingsValidator } from "./validation/SettingsValidator";
import { ValidationError } from "./validation/types";
import { EnableConsoleLogSettingComponent } from "./components/devGroup/EnableDebug";
import { PathGlobsSettingComponent } from "./components/pathGroup/PathGlobsSettingComponent";
export class CheckboxSyncPluginSettingTab extends PluginSettingTab {
plugin: CheckboxSyncPlugin;
plugin: CheckboxSyncPlugin;
private settingGroups: SettingGroup[] = []
private settingGroups: SettingGroup[] = []
private errorDisplay: ErrorDisplay;
private errorDisplay: ErrorDisplay;
private settingsControls: SettingsControls;
private settingsControls: SettingsControls;
private isDirty: boolean = false;
private actionMutex = new Mutex();
private isDirty: boolean = false;
private actionMutex = new Mutex();
constructor(app: App, plugin: CheckboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
this.initializeSettingGroups();
}
constructor(app: App, plugin: CheckboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
this.initializeSettingGroups();
}
// Метод для создания и конфигурации групп и компонентов
private initializeSettingGroups(): void {
// Создаем экземпляры наших компонентов
// Метод для создания и конфигурации групп и компонентов
private initializeSettingGroups(): void {
// Создаем экземпляры наших компонентов
const checkedSymbolsComp = new CheckedSymbolsSettingComponent();
const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent();
const unknownPolicyComp = new UnknownPolicySettingComponent();
const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent();
const checkedSymbolsComp = new CheckedSymbolsSettingComponent();
const uncheckedSymbolsComp = new UncheckedSymbolsSettingComponent();
const unknownPolicyComp = new UnknownPolicySettingComponent();
const ignoreSymbolsComp = new IgnoreSymbolsSettingComponent();
const symbolGroup = new SettingGroup(
"Checkbox Symbol Configuration (Advanced: JSON)",
[checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp],
"Warning: Requires valid JSON format. Use double quotes for strings."
);
const symbolGroup = new SettingGroup(
"Checkbox Symbol Configuration (Advanced: JSON)",
[checkedSymbolsComp, uncheckedSymbolsComp, ignoreSymbolsComp, unknownPolicyComp],
"Warning: Requires valid JSON format. Use double quotes for strings."
);
const parentToggleComp = new EnableParentSyncSettingComponent();
const childrenToggleComp = new EnableChildSyncSettingComponent();
const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent();
const parentToggleComp = new EnableParentSyncSettingComponent();
const childrenToggleComp = new EnableChildSyncSettingComponent();
const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent();
const behaviorGroup = new SettingGroup(
"Synchronization Behavior",
[parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp]
);
const behaviorGroup = new SettingGroup(
"Synchronization Behavior",
[parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp]
);
// Сохраняем созданную группу (или группы) в поле класса
this.settingGroups = [
symbolGroup,
behaviorGroup
];
const pathGlobsComp = new PathGlobsSettingComponent();
const changeListener = () => this.settingChanged();
for (const group of this.settingGroups) {
for (const component of group.components) {
// Устанавливаем общий listener для всех компонентов
component.setChangeListener(changeListener);
}
}
}
const filteringGroup = new SettingGroup(
"File Scope & Filtering",
[pathGlobsComp],
"Define rules to include or exclude files/folders from plugin processing."
);
private settingChanged() {
this.isDirty = true;
this.settingsControls.setApplyState({ disabled: false, cta: true });
}
const consoleLog = new EnableConsoleLogSettingComponent();
private settingSaved() {
this.isDirty = false;
this.settingsControls.setApplyState({ disabled: true, cta: false });
}
const devGroup = new SettingGroup(
"Dev",
[consoleLog]
);
display(): void {
this.isDirty = false;
const containerEl = this.containerEl;
containerEl.empty();
containerEl.createEl('h2', { text: 'Checkbox Sync Settings' });
// Сохраняем созданную группу (или группы) в поле класса
this.settingGroups = [
symbolGroup,
behaviorGroup,
filteringGroup,
devGroup
];
for (const group of this.settingGroups) {
group.render(containerEl, this.plugin.settings);
}
const changeListener = () => this.settingChanged();
for (const group of this.settingGroups) {
for (const component of group.components) {
// Устанавливаем общий listener для всех компонентов
component.setChangeListener(changeListener);
}
}
}
// --- Область для вывода ошибок ---
this.errorDisplay = new ErrorDisplay(containerEl);
private settingChanged() {
this.isDirty = true;
this.settingsControls.setApplyState({ disabled: false, cta: true });
}
// Создаем экземпляр SettingsControls
const controlActions: ISettingsControlActions = {
onApply: () => this.applyChanges(),
onReset: () => this.resetInputsToSavedSettings(),
// Оставляем ConfirmModal здесь, но передаем вызов applyDefaultSettings
onResetDefaults: () => {
new ConfirmModal(this.app,
"Reset all settings to default and apply immediately?\nThis cannot be undone.",
// Только если пользователь нажал ОК, вызываем реальный сброс
async () => { await this.applyDefaultSettings(); }
// Optional: callback for cancel if needed
).open();
}
};
this.settingsControls = new SettingsControls(containerEl, controlActions);
private settingSaved() {
this.isDirty = false;
this.settingsControls.setApplyState({ disabled: true, cta: false });
}
// Устанавливаем начальное состояние кнопки Apply
// (isDirty здесь всегда false при первом вызове display)
this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty });
display(): void {
this.isDirty = false;
const containerEl = this.containerEl;
containerEl.empty();
containerEl.createEl('h2', { text: 'Checkbox Sync Settings' });
// Начальное состояние кнопки Reset Defaults (всегда включена)
this.settingsControls.setResetDefaultsState({ disabled: false });
}
for (const group of this.settingGroups) {
group.render(containerEl, this.plugin.settings);
}
private async applyChanges() {
await this.actionMutex.runExclusive(async () => {
this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false });
this.errorDisplay.clear();
// --- Область для вывода ошибок ---
this.errorDisplay = new ErrorDisplay(containerEl);
try {
const result = await this.validateAndSaveSettings();
if (result.success) {
this.settingSaved();
new Notice("Checkbox Sync settings applied!", 3000);
} else {
console.warn("Validation errors:", result.errors);
this.errorDisplay.displayErrors(result.errors);
}
} catch (error: any) {
console.error("Unexpected error during applyChanges:", error);
const message = error.message || "Unknown error";
this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`);
} finally {
this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty })
}
});
}
// Создаем экземпляр SettingsControls
const controlActions: ISettingsControlActions = {
onApply: () => this.applyChanges(),
onReset: () => this.resetInputsToSavedSettings(),
// Оставляем ConfirmModal здесь, но передаем вызов applyDefaultSettings
onResetDefaults: () => {
new ConfirmModal(this.app,
"Reset all settings to default and apply immediately?\nThis cannot be undone.",
// Только если пользователь нажал ОК, вызываем реальный сброс
async () => { await this.applyDefaultSettings(); }
// Optional: callback for cancel if needed
).open();
}
};
this.settingsControls = new SettingsControls(containerEl, controlActions);
/**
* Resets the settings UI elements to reflect the currently saved plugin settings.
*/
private resetInputsToSavedSettings() {
const settings = this.plugin.settings;
// Устанавливаем начальное состояние кнопки Apply
// (isDirty здесь всегда false при первом вызове display)
this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty });
for (const group of this.settingGroups) {
for (const component of group.components) {
const key = component.getSettingKey();
if (key in settings) {
try {
component.setValueInUi(settings[key]);
} catch (error) {
console.error(`Error resetting UI for component ${key}:`, error);
}
}
}
}
// Начальное состояние кнопки Reset Defaults (всегда включена)
this.settingsControls.setResetDefaultsState({ disabled: false });
}
// Очищаем ошибку и сбрасываем состояние "грязный"
this.errorDisplay.clear();
this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply
}
private async applyChanges() {
await this.actionMutex.runExclusive(async () => {
this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false });
this.errorDisplay.clear();
/**
* Applies the default settings immediately after confirmation.
*/
private async applyDefaultSettings() {
await this.actionMutex.runExclusive(async () => {
this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' });
this.errorDisplay.clear();
try {
const result = await this.validateAndSaveSettings();
if (result.success) {
this.settingSaved();
new Notice("Checkbox Sync settings applied!", 3000);
} else {
console.warn("Validation errors:", result.errors);
this.errorDisplay.displayErrors(result.errors);
}
} catch (error: any) {
console.error("Unexpected error during applyChanges:", error);
const message = error.message || "Unknown error";
this.errorDisplay.displayMessage(`An unexpected error occurred: ${message}`);
} finally {
this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty })
}
});
}
try {
const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией
/**
* Resets the settings UI elements to reflect the currently saved plugin settings.
*/
private resetInputsToSavedSettings() {
const settings = this.plugin.settings;
await this.plugin.updateSettings(settings => {
// Полностью перезаписываем текущие настройки дефолтными
Object.assign(settings, defaultsCopy);
});
for (const group of this.settingGroups) {
for (const component of group.components) {
const key = component.getSettingKey();
if (key in settings) {
try {
component.setValueInUi(settings[key]);
} catch (error) {
console.error(`Error resetting UI for component ${key}:`, error);
}
}
}
}
// После успешного сохранения обновляем UI и состояние
this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply
// Очищаем ошибку и сбрасываем состояние "грязный"
this.errorDisplay.clear();
this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply
}
new Notice("Settings reset to defaults and applied.", 3000);
/**
* Applies the default settings immediately after confirmation.
*/
private async applyDefaultSettings() {
await this.actionMutex.runExclusive(async () => {
this.settingsControls.setResetDefaultsState({ disabled: true, text: 'Resetting...' });
this.errorDisplay.clear();
} catch (error: any) {
console.error("Error resetting settings to default:", error);
const message = error.message || "Unknown error";
this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`);
} finally {
// Восстанавливаем кнопку Reset
this.settingsControls.setResetDefaultsState({ disabled: false });
}
});
}
try {
const defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией
/**
* Overridden hide method to handle unsaved changes.
*/
async hide() {
if (this.isDirty) {
// Колбэк для кнопки "Save"
const saveCallback = async () => {
try {
const result = await this.validateAndSaveSettings();
if (result.success) {
this.isDirty = false; // Сбрасываем флаг только при успехе
new Notice("Settings saved.", 2000);
} else {
console.error("Error saving settings on hide (validation):", result.errors);
const errorMessage = result.errors
.map(e => `${e.field ? `[${e.field}]: ` : ''}${e.message}`)
.join('\n');
new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open();
}
} catch (error: any) {
// Ловим НЕОЖИДАННЫЕ ошибки (например, ошибка сохранения)
console.error("Error saving settings on hide (unexpected):", error);
let errorMessage = "An unknown error occurred.";
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
}
new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open();
}
};
await this.plugin.updateSettings(settings => {
// Полностью перезаписываем текущие настройки дефолтными
Object.assign(settings, defaultsCopy);
});
// Колбэк для кнопки "Discard"
const discardCallback = () => {
this.isDirty = false; // Считаем изменения отмененными
};
// После успешного сохранения обновляем UI и состояние
this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply
// Открываем модалку Save/Discard и ЖДЕМ ее закрытия
const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback);
saveModal.open();
}
// Продолжаем стандартное закрытие
super.hide();
}
new Notice("Settings reset to defaults and applied.", 3000);
/**
* 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<{ success: boolean; errors: ValidationError[] }> {
} catch (error: any) {
console.error("Error resetting settings to default:", error);
const message = error.message || "Unknown error";
this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`);
} finally {
// Восстанавливаем кнопку Reset
this.settingsControls.setResetDefaultsState({ disabled: false });
}
});
}
let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации
let newSettingsData: Partial<CheckboxSyncPluginSettings> = {}; // Объект для новых данных
/**
* Overridden hide method to handle unsaved changes.
*/
async hide() {
if (this.isDirty) {
// Колбэк для кнопки "Save"
const saveCallback = async () => {
try {
const result = await this.validateAndSaveSettings();
if (result.success) {
this.isDirty = false; // Сбрасываем флаг только при успехе
new Notice("Settings saved.", 2000);
} else {
console.error("Error saving settings on hide (validation):", result.errors);
const errorMessage = result.errors
.map(e => `${e.field ? `[${e.field}]: ` : ''}${e.message}`)
.join('\n');
new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open();
}
} catch (error: any) {
// Ловим НЕОЖИДАННЫЕ ошибки (например, ошибка сохранения)
console.error("Error saving settings on hide (unexpected):", error);
let errorMessage = "An unknown error occurred.";
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
}
new InfoModal(this.app, `Failed to save settings:\n\n${errorMessage}\n\nYour changes were not saved.`).open();
}
};
for (const group of this.settingGroups) {
for (const component of group.components) {
const key = component.getSettingKey();
try {
const value = component.getValueFromUi();
const validationError = component.validate();
if (validationError) {
errors.push(validationError);
} else {
newSettingsData[key] = value;
}
} catch (err: any) {
console.error(`Error processing component ${key}:`, err);
errors.push({ field: key, message: `Failed to read value: ${err.message}` });
}
}
}
// Колбэк для кнопки "Discard"
const discardCallback = () => {
this.isDirty = false; // Считаем изменения отмененными
};
if (errors.length === 0) {
const settingsValidator = new SettingsValidator();
const crossErrors = settingsValidator.validate(newSettingsData);
errors.push(...crossErrors);
}
// Открываем модалку Save/Discard и ЖДЕМ ее закрытия
const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback);
saveModal.open();
}
// Продолжаем стандартное закрытие
super.hide();
}
if (errors.length > 0) {
return { success: false, errors: errors };
}
/**
* 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<{ success: boolean; errors: ValidationError[] }> {
try {
await this.plugin.updateSettings(settings => {
Object.assign(settings, newSettingsData);
});
return { success: true, errors: [] };
} catch (saveError: any) {
// Ловим ТОЛЬКО ошибки сохранения (от updateSettings)
console.error("Error saving settings:", saveError);
throw saveError;
}
}
}
let errors: ValidationError[] = []; // Массив для сбора всех ошибок валидации
let newSettingsData: Partial<CheckboxSyncPluginSettings> = {}; // Объект для новых данных
for (const group of this.settingGroups) {
for (const component of group.components) {
const key = component.getSettingKey();
try {
const value = component.getValueFromUi();
const validationError = component.validate();
if (validationError) {
errors.push(validationError);
} else {
newSettingsData[key] = value;
}
} catch (err: any) {
console.error(`Error processing component ${key}:`, err);
errors.push({ field: key, message: `Failed to read value: ${err.message}` });
}
}
}
if (errors.length === 0) {
const settingsValidator = new SettingsValidator();
const crossErrors = settingsValidator.validate(newSettingsData);
errors.push(...crossErrors);
}
if (errors.length > 0) {
return { success: false, errors: errors };
}
try {
await this.plugin.updateSettings(settings => {
Object.assign(settings, newSettingsData);
});
return { success: true, errors: [] };
} catch (saveError: any) {
// Ловим ТОЛЬКО ошибки сохранения (от updateSettings)
console.error("Error saving settings:", saveError);
throw saveError;
}
}
}

View file

@ -25,4 +25,16 @@ export abstract class BaseSettingComponent implements ISettingComponent {
public setChangeListener(listener: () => void): void {
this.onChangeCallback = listener;
}
}
/**
* Creates a DocumentFragment from an HTML string.
* Useful for setting complex descriptions with HTML content.
* @param html The HTML string.
* @returns A DocumentFragment containing the parsed HTML.
*/
protected createFragmentWithHTML(html: string): DocumentFragment {
return createFragment((documentFragment) => {
documentFragment.createDiv().innerHTML = html;
});
}
}

View file

@ -0,0 +1,57 @@
import { ToggleComponent, Setting } from "obsidian";
import { CheckboxSyncPluginSettings } from "src/types";
import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent";
import { ValidationError } from "src/ui/validation/types";
import { validateValueIsBoolean } from "src/ui/validation/validators";
export class EnableConsoleLogSettingComponent extends BaseSettingComponent {
private toggleComponent: ToggleComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'consoleEnabled';
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
.setName('Enable console logs')
.addToggle(toggle => {
this.toggleComponent = toggle;
toggle
.setValue(currentValue as boolean)
.onChange(this.onChangeCallback);
});
}
getValueFromUi(): boolean {
if (this.toggleComponent) {
return this.toggleComponent.getValue();
}
throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`);
}
setValueInUi(value: any): void {
if (this.toggleComponent) {
this.toggleComponent.setValue(value as boolean);
} else {
throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`);
}
}
validate(): ValidationError | null {
const valueFromUi = this.getValueFromUi();
const validationResult = validateValueIsBoolean(valueFromUi);
if (validationResult) {
return {
field: this.getSettingKey(),
message: validationResult.message
};
}
return null;
}
}

View file

@ -0,0 +1,122 @@
import { Setting, TextAreaComponent } from 'obsidian';
import { CheckboxSyncPluginSettings } from 'src/types';
import { BaseSettingComponent } from 'src/ui/basedClasses/BaseSettingComponent';
import { ValidationError } from 'src/ui/validation/types';
export class PathGlobsSettingComponent extends BaseSettingComponent {
private textInput: TextAreaComponent;
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'pathGlobs';
}
private arrayToMultilineString(value: string[] | undefined | null): string {
if (Array.isArray(value)) {
try {
return value.join('\n');
} catch (e) {
console.error(`[${this.getSettingKey()}] Error joining array to multiline string: `, e);
return '';
}
}
return '';
}
render(container: HTMLElement, currentValue: any): void {
const multilineStringValue = this.arrayToMultilineString(currentValue as string[] | undefined);
const descHtml =
`<p>Define rules to specify which files and folders the plugin should IGNORE.
Uses the same pattern syntax as <code>.gitignore</code> files (powered by the 'ignore' library).</p>
<p>For detailed syntax, refer to the
<a href="https://git-scm.com/docs/gitignore" target="_blank" rel="noopener noreferrer">official .gitignore documentation</a>.
</p>`;
this.setting = new Setting(container)
.setName('File Ignore Rules (.gitignore style)')
// Используем createFragmentWithHTML для описания
.setDesc(this.createFragmentWithHTML(descHtml))
.addTextArea(text => {
this.textInput = text;
text.setPlaceholder(
'# Examples:\n' +
'# Ignore all files in the "Drafts" folder\n' +
'Drafts/\n\n'
);
text.setValue(multilineStringValue);
text.onChange(() => {
this.onChangeCallback();
});
});
// Применяем стиль "многострочной настройки" как в Tasks плагине
this.makeMultilineTextSetting();
}
/**
* Изменяет стиль Setting, чтобы описание было над TextArea, а TextArea занимала всю ширину.
*/
private makeMultilineTextSetting(): void {
if (!this.setting) return;
const { settingEl, infoEl, controlEl } = this.setting;
const textAreaEl: HTMLTextAreaElement | null = controlEl.querySelector('textarea');
if (textAreaEl === null) {
// Это не настройка с TextArea, ничего не делаем
return;
}
settingEl.style.display = 'block';
// infoEl может не существовать, если .setName() и .setDesc() не вызывались или были очищены.
// В нашем случае они вызываются, так что infoEl должен быть.
if (infoEl) {
infoEl.style.marginBottom = 'var(--size-4-2)'; // Небольшой отступ под описанием
infoEl.style.marginRight = '0px'; // Убираем отступ справа, если он был
}
// Для controlEl (контейнер для textarea)
controlEl.style.width = '100%'; // Заставляем контейнер контрола быть на всю ширину
// Для самой textarea
textAreaEl.style.width = '100%'; // Занимает всю ширину controlEl
textAreaEl.style.minHeight = '120px'; // Минимальная высота для нескольких строк
textAreaEl.rows = 8;
}
getValueFromUi(): string[] {
if (this.textInput) {
const rawValue = this.textInput.getValue();
return rawValue
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0 && !s.startsWith('#'));
}
throw new Error(`[${this.getSettingKey()}] Cannot get value from UI: TextArea component not rendered or available.`);
}
setValueInUi(value: any): void {
if (this.textInput) {
if (value === undefined) {
this.textInput.setValue('');
return;
}
this.textInput.setValue((value as string[]).join('\n'));
} else {
throw new Error(`[${this.getSettingKey()}] Cannot set value in UI: TextArea component not rendered or available.`);
}
}
validate(): ValidationError | null {
try {
this.getValueFromUi();
} catch (e: any) {
return { field: this.getSettingKey(), message: e.message };
}
return null;
}
}

View file

@ -55,4 +55,4 @@ export class EnableChildSyncSettingComponent extends BaseSettingComponent {
}
return null;
}
}
}

View file

@ -41,8 +41,9 @@ export class EnableParentSyncSettingComponent extends BaseSettingComponent {
setValueInUi(value: any): void {
if (this.toggleComponent) {
this.toggleComponent.setValue(value as boolean);
}
throw new Error(`[${this.getSettingKey()}] Attempted to set value before component is rendered.`);
}else{
throw new Error(`[${this.getSettingKey()}] Attempted to set value before component is rendered.`);
}
}
validate(): ValidationError | null {
@ -68,4 +69,4 @@ export class EnableParentSyncSettingComponent extends BaseSettingComponent {
}
return null;
}
}
}

View file

@ -4,7 +4,7 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
@ -13,18 +13,22 @@
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2018",
"DOM.Iterable"
],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
// "strict": true, // Включение этой опции активирует множество строгих проверок (включая noImplicitAny, strictNullChecks, и др.) - очень рекомендуется для новых проектов. Если сейчас вызовет много ошибок, можно добавлять постепенно.
// "rootDir": "src", // Указываем, где исходный код
// "outDir": "dist", // Куда компилировать
"resolveJsonModule": true // Поддержка импорта JSON файлов
"resolveJsonModule": true, // Поддержка импорта JSON файлов
"skipLibCheck": true,
},
"include": [
"**/*.ts",
"src/**/*.ts", // Включаем только исходники из папки src
"__tests__/**/*.ts" // Включаем тесты из папки __tests__
]
],
"exclude": ["node_modules"],
}