Compare commits

...

120 commits

Author SHA1 Message Date
Grol Grol
85b0154ca6 fix readme 2025-08-08 13:26:33 +03:00
Grol Grol
c6b111ac4c fix doc 2025-08-08 13:20:27 +03:00
Grol Grol
32dcf4eff0 Merge branch 'dev' 1.2.0 2025-08-08 13:17:04 +03:00
Grol Grol
e701cd6828 Finalize for v1.2.0 2025-08-08 13:15:01 +03:00
Grol Grol
9755034870 update doc 2025-08-08 13:09:09 +03:00
Grol Grol
b685655e51 fix infinite loop with tasks plugin 2025-08-08 13:06:19 +03:00
Grol Grol
235f1cb942 update doc 2025-08-08 12:59:00 +03:00
Grol Grol
0276070445 ref: упростил хранение изменённой строки 2025-07-14 09:08:59 +03:00
Grol Grol
cf6b3d4b80 ref 2025-07-13 21:03:42 +03:00
Grol Grol
c64f943ca8 fix bug propagete modify to grandparent 2025-07-13 21:03:02 +03:00
Grol Grol
93bb79bb3e интеграция с Tasks 2025-07-06 14:59:15 +03:00
Grol Grol
6e40414083 фикс изменения текста вместе с чекбоксом, ради интеграции с Tasks 2025-07-05 16:28:28 +03:00
Grol Grol
2be5325eaf фикс бага с вставленным файлом 2025-07-05 15:28:47 +03:00
Grol Grol
0e5b863a01 Merge branch 'checkbox_utils_tree_style' into dev 2025-07-05 15:02:26 +03:00
Grol Grol
96727c61f5 рабочая версия 2025-07-05 14:27:48 +03:00
Grol Grol
a7f33e676d init state 2025-06-05 11:56:36 +03:00
Grol Grol
b0d58d6447 ref 2025-05-23 03:49:38 +03:00
Grol Grol
273495e9bb Merge branch 'refactor/syncController' into dev 2025-05-21 15:34:17 +03:00
Grol Grol
93597b2626 fix fake test 2025-05-21 15:32:53 +03:00
Grol Grol
0b797bed5a ref and add test 2025-05-21 15:28:04 +03:00
Grol Grol
a851cc8428 Merge branch 'feature/support-plain-list-items' into dev 2025-05-20 02:40:16 +03:00
Grol Grol
5115988918 implement feature 2025-05-20 02:33:50 +03:00
Grol Grol
b18acc10b1 123 2025-05-18 21:43:12 +03:00
Grol Grol
392acb7e17 Merge branch 'feature/file-filtering' into dev 2025-05-18 21:29:53 +03:00
Grol Grol
9398dc67ec upd docs 2025-05-18 21:26:01 +03:00
Grol Grol
a7cfbd70df feat(filter): Implement path filtering in SyncController 2025-05-18 20:09:21 +03:00
Grol Grol
e5f3318809 feat(filter): Integrate FileFilter into plugin lifecycle 2025-05-18 18:42:36 +03:00
Grol Grol
683b358053 feat(filter): Implement file filtering using 'ignore' library 2025-05-18 18:21:50 +03:00
Grol Grol
8ac4b60509 refactor(settings): Make createFragmentWithHTML an instance method 2025-05-16 01:41:36 +03:00
Grol Grol
f3f0685bdc feat(settings): Add UI for file filtering glob patterns 2025-05-16 00:50:09 +03:00
Grol Grol
a69f23159a feat: Add pathGlobs setting for file filtering 2025-05-15 13:26:55 +03:00
Grol Grol
77653389f3 fix space 2025-05-14 18:45:01 +03:00
Grol Grol
775940a664 Merge branch 'fix' into dev 2025-05-14 14:18:57 +03:00
Grol Grol
a453a07b18 fix error, miss else block 2025-05-14 14:17:33 +03:00
Grol Grol
e9715b71ad Merge branch 'feature/logging_toggle' into dev 2025-05-14 13:48:48 +03:00
Grol Grol
4bcb4a168a fix loading settings 2025-05-14 13:48:26 +03:00
Grol Grol
88809f104b Merge branch 'feature/logging_toggle' into dev 2025-05-14 13:38:04 +03:00
Grol Grol
595bb8194e feat(settings): Add option to enable/disable debug logging 2025-05-14 13:24:08 +03:00
grol
d23be52814
Update tg_notify.yml 2025-05-12 12:39:32 +03:00
grol
bbbef6f9c8
Update tg_notify.yml 2025-05-12 12:26:56 +03:00
grol
76677067fa
Update tg_notify.yml 2025-05-12 12:06:46 +03:00
grol
9c26ad2c27
Update tg_notify.yml 2025-05-12 11:58:36 +03:00
grol
4f70f30987
Update tg_notify.yml 2025-05-12 11:57:22 +03:00
Grol Grol
da1168d343 add pull request template 2025-05-06 08:28:59 +03:00
Grol Grol
2809d15aa7 Merge branch 'master' of https://github.com/groldsf/obsidian_check_plugin 2025-05-06 08:23:29 +03:00
Grol Grol
9458bb0407 add issue tamplates 2025-05-06 08:23:26 +03:00
grol
846c266e99
Create SECURITY.md 2025-05-06 08:04:07 +03:00
Grol Grol
d5f26a340b add Code of Conduct 2025-05-06 06:32:41 +03:00
Grol Grol
3011e52514 Merge branch 'dev' 2025-05-05 15:34:11 +03:00
Grol Grol
28ebd1ddf0 chore: Prepare release 1.1.0 2025-05-05 15:30:35 +03:00
Grol Grol
4f9c5ab5e3 Merge branch 'master' into dev 2025-05-05 14:28:51 +03:00
Grol Grol
4b654709af new readme 2025-05-05 14:08:40 +03:00
Grol Grol
71246acc85 fix text 2025-05-05 13:02:33 +03:00
Grol Grol
0ac47fa4df fix 2025-05-05 12:51:23 +03:00
Grol Grol
9e8b315bff upd changelog 2025-05-05 12:43:23 +03:00
Grol Grol
acc7dd8d8a upd roadmap 2025-05-05 11:32:21 +03:00
Grol Grol
72c174c89f feat: Set navigation order using nav_order 2025-05-04 07:12:49 +03:00
Grol Grol
9764ce2962 swap color scheme 2025-05-04 06:17:17 +03:00
Grol Grol
eab303a9b7 fix 2025-05-04 06:15:02 +03:00
Grol Grol
560b184f14 add config to site 2025-05-04 06:03:19 +03:00
Grol Grol
e65dd50548 fix? 2025-05-03 05:41:24 +03:00
Grol Grol
252c0ff8b7 Preparing to create github pages wiki 2025-05-03 04:13:22 +03:00
Grol Grol
a694453dcf Merge branch 'refactor/settingstab' into dev 2025-04-27 08:45:23 +03:00
Grol Grol
6756a4a8a7 moving files 2025-04-27 08:27:25 +03:00
Grol Grol
89f3b56871 refactor(settings): Improve tab UI handling and listeners 2025-04-27 08:19:59 +03:00
Grol Grol
ae8740b3f9 refactor(settings): Encapsulate button logic in SettingsControls class 2025-04-27 07:57:53 +03:00
Grol Grol
6014663ac0 refactor(settings): Encapsulate error display logic in ErrorDisplay class 2025-04-27 07:11:29 +03:00
Grol Grol
b025aabea7 refactor(settings): Return validation results instead of throwing errors 2025-04-27 06:23:41 +03:00
Grol Grol
eef7023804 refactor(settings): cleanup of CheckboxSyncPluginSettingTab 2025-04-27 05:56:30 +03:00
Grol Grol
eb5e52ca1f refactor(settings): Implement cross-field validation using SettingsValidator 2025-04-27 05:51:26 +03:00
Grol Grol
cdcf3a9db3 refactor(settings): Migrate all remaining settings to component architecture 2025-04-27 05:16:18 +03:00
Grol Grol
400ce20193 fix tab 2025-04-27 04:18:56 +03:00
Grol Grol
d4e23856c1 refactor(settings): Implement component save/load logic and remove getDefaultValue 2025-04-27 00:26:43 +03:00
Grol Grol
7c78611f1c refactor(settings): Integrate first component into display 2025-04-26 23:35:01 +03:00
Grol Grol
840a50ae41 refactor(settings): Add EnableParentSync component and testable validator 2025-04-26 23:25:41 +03:00
Grol Grol
0f4b53f8dd fix license 2025-04-26 21:38:18 +03:00
Grol Grol
5074bf6464 refactor(settings): Add base structures for settings tab architecture 2025-04-26 21:35:26 +03:00
Grol Grol
d1a76ae87c Merge branch 'feature/enableAutomaticFileSync' into dev 2025-04-25 03:25:54 +03:00
Grol Grol
ef380a57bc disable autoapdate if settings is disabled after settings update 2025-04-25 03:09:41 +03:00
Grol Grol
ef0b3017d7 disabale autoapdate if settings is disabled 2025-04-25 03:07:51 +03:00
Grol Grol
c7e2e3835c add new field to settings tab 2025-04-25 02:52:08 +03:00
Grol Grol
8e089baee4 add new field enableAutomaticFileSync: boolean 2025-04-25 02:43:51 +03:00
Grol Grol
b4a789f04b Merge branch 'feature/flexible-checkbox-symbols' into dev 2025-04-24 05:00:41 +03:00
Grol Grol
e992b94c0b fix 2025-04-24 01:16:36 +03:00
Grol Grol
46dcb29109 add new tests 2025-04-24 01:16:28 +03:00
Grol Grol
d905f92969 remove old test 2025-04-24 01:16:01 +03:00
Grol Grol
78bc89c129 feat: Add setting for ignored checkbox symbols 2025-04-23 22:11:07 +03:00
Grol Grol
5a18eb2db8 delete xOnlyMode 2025-04-23 20:58:33 +03:00
Grol Grol
17a5ad0938 skipping children of ignored checkboxes, ref 2025-04-23 20:55:50 +03:00
Grol Grol
51588d4a85 fix import 2025-04-23 06:08:21 +03:00
Grol Grol
fd5653cafb rename package "settings" to "ui" 2025-04-23 06:07:08 +03:00
Grol Grol
ade67c54ba feat: Add reset buttons and dirty state handling to settings 2025-04-23 06:05:55 +03:00
Grol Grol
7068d32440 move settings tab 2025-04-23 02:02:56 +03:00
Grol Grol
63dac704f5 feat: Implement core logic using flexible symbols and policy 2025-04-22 02:21:15 +03:00
Grol Grol
01ed090ad6 ref type to enum 2025-04-22 00:28:26 +03:00
Grol Grol
f567160324 feat: Implement settings UI for flexible symbols (JSON input) 2025-04-21 23:16:03 +03:00
Grol Grol
2a0a12bce0 feat: Prepare settings structure for flexible symbols 2025-04-20 15:31:13 +03:00
Grol Grol
03ee45cc1c v upd, fix bug #10 2025-04-12 10:55:26 +03:00
Grol Grol
e044691186 fix gif 2025-04-11 23:31:49 +03:00
Grol Grol
975cac157b fix gif 2025-04-11 23:29:42 +03:00
Grol Grol
25578447ea fix gif 2025-04-11 23:22:39 +03:00
Grol Grol
7b41faa6f8 fix gif 2025-04-11 23:15:58 +03:00
Grol Grol
9a9028d363 v upd 2025-04-11 23:08:12 +03:00
Grol Grol
3c38362331 upd readme 2025-04-11 22:48:43 +03:00
Grol Grol
3d1ea00009 add settings 2025-04-11 21:09:29 +03:00
Grol Grol
c751add5fe ref events. add delete action. optimize loadAndSyncActiveFiles 2025-04-11 18:42:49 +03:00
Grol Grol
198adf253f stable? 2025-04-11 18:07:53 +03:00
Grol Grol
32a5fe4229 stable ver? bugs... 2025-04-06 21:58:56 +03:00
Grol Grol
d3e7b14593 miniref. delete clickEvent 2025-04-04 16:02:45 +03:00
Grol Grol
13ab4da8e7 fix sync unloadFile in previewMode 2025-04-04 16:00:13 +03:00
Grol Grol
b9a9d7b73c fix load. unreg postproc 2025-04-04 15:40:05 +03:00
Grol Grol
1f00148d53 lazy loading of files 2025-04-04 15:27:25 +03:00
Grol Grol
ac25654f65 fix view mod, fix cascading changes, need refactor 2025-04-02 16:20:23 +03:00
Grol Grol
89e4089cbc stable version, works in editor mod 2025-04-02 10:33:04 +03:00
Grol Grol
c9f50f1894 stable version, works in view mode 2025-04-02 04:20:27 +03:00
Grol Grol
5649eacab0 v upd 2025-03-20 23:46:39 +03:00
Grol Grol
6decbc8e78 fix scroll 2025-03-20 23:46:03 +03:00
Grol Grol
bb609c2685 fix callback 2025-03-20 23:45:43 +03:00
Grol Grol
e2e6c47920 v upd 2025-03-20 00:28:36 +03:00
Grol Grol
06b85267d8 fix cursor, add mutex, add tests, refactor 2025-03-20 00:24:35 +03:00
87 changed files with 7483 additions and 630 deletions

View file

@ -6,5 +6,5 @@ charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4
indent_size = 2
tab_width = 2

View file

@ -18,6 +18,7 @@
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
"@typescript-eslint/no-empty-function": "off",
"indent": ["error", 2]
}
}

34
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,34 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] A brief description of the bug"
labels: 'bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or Screencasts**
If applicable, add screenshots or a short screen recording (GIF) to help explain your problem.
**Environment (please complete the following information):**
- Operating System: [e.g. Windows 10, macOS Sonoma, Ubuntu 22.04]
- Obsidian Version: [e.g. 1.5.8]
- Checkbox Sync Plugin Version: [e.g. 1.1.0]
**Additional context**
Add any other context about the problem here. For example:
- Does the issue happen with specific file content or structure?
- Does it happen with default plugin settings?
- Check the developer console (Ctrl+Shift+I or Cmd+Option+I), are there any errors related to 'checkbox-sync'? (Paste relevant errors here within code blocks ```like this```)

View file

@ -0,0 +1,19 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEAT] A brief description of the feature"
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen. How should the new feature work?
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context, mockups, or screenshots about the feature request here. Why would this feature be useful to other users?

57
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,57 @@
<!--
Thanks for contributing!
Please provide detailed information about your changes to help the reviewers.
-->
**Type of Change**
<!-- Check relevant options with "x" -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Refactoring (internal code improvements, no user-facing changes)
- [ ] Documentation update
- [ ] Other (please describe):
**Related Issue(s)**
<!-- Link the related GitHub issue(s) here. -->
<!-- Use "Fixes #issue_number" if this PR closes the issue. -->
<!-- Use "Related to #issue_number" if it's relevant but doesn't close it. -->
- Fixes #
- Related to #
**Proposed Changes**
<!-- Describe your changes clearly and concisely. -->
<!-- What problem does this PR solve? What changes were made? -->
-
-
-
**Testing Steps**
<!-- Describe how the reviewer can test your changes. -->
<!-- Provide specific steps. e.g.: -->
<!-- 1. Open a note with a nested list of checkboxes -->
<!-- 2. Enable setting 'X' -->
<!-- 3. Click the parent checkbox -->
<!-- 4. Verify that child checkboxes now have symbol 'Y' -->
1.
2.
3.
**Checklist**
<!-- Go through all the following points, and put an "x" in all the boxes that apply. -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My code follows the style guidelines of this project (if any).
- [ ] I have performed a self-review of my own code.
- [ ] I have commented my code where necessary, particularly in hard-to-understand areas.
- [ ] I have made corresponding changes to the documentation (in the `/docs` folder).
- [ ] My changes generate no new warnings.
- [ ] I have added tests that prove my fix is effective or that my feature works (if applicable).
- [ ] New and existing unit tests pass locally with my changes (if applicable).
- [ ] Any dependent changes have been merged and published in downstream modules.
**Additional Information**
<!-- Add any other context, screenshots, or considerations here. -->
<!-- For example: performance implications, potential side effects, etc. -->

View file

@ -11,19 +11,57 @@ on:
types: [submitted]
jobs:
build:
name: Build
notify_telegram: # Переименовал job для ясности
name: Notify Telegram
runs-on: ubuntu-latest
steps:
- name: send telegram message on push
- name: Prepare notification message
id: prepare_message # Добавляем ID шагу, чтобы ссылаться на его output
run: |
TITLE="${{ github.event.issue.title || github.event.pull_request.title || 'Комментарий' }}"
BODY="${{ github.event.issue.body || github.event.comment.body || github.event.review.body || '' }}" # Добавил github.event.review.body для pull_request_review
URL="${{ github.event.issue.html_url || github.event.pull_request.html_url || github.event.comment.html_url || github.event.review.html_url }}"
# Формируем полное сообщение
FULL_MESSAGE="Репозиторий: ${{ github.repository }}
Тип: ${{ github.event_name }}
Действие: ${{ github.event.action }}
Заголовок: ${TITLE}
${BODY}
Подробнее: ${URL}"
# Формируем короткое сообщение
SHORT_MESSAGE="Репозиторий: ${{ github.repository }}
Тип: ${{ github.event_name }}
Действие: ${{ github.event.action }}
Заголовок: ${TITLE}
Произошло обновление. Подробнее: ${URL}"
# Проверяем длину полного сообщения (приблизительно, т.к. точный лимит зависит от кодировки и спецсимволов, но 3800 должно быть безопасно)
# Лимит Telegram 4096. Оставляем запас.
MAX_LENGTH=3800
if [ ${#FULL_MESSAGE} -gt $MAX_LENGTH ]; then
echo "Сообщение слишком длинное, отправляем короткую версию."
# Используем echo -e для корректной обработки многострочности и сохраняем в переменную окружения для следующего шага
echo "TELEGRAM_MESSAGE<<EOF" >> $GITHUB_ENV
echo -e "${SHORT_MESSAGE}" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "TELEGRAM_MESSAGE<<EOF" >> $GITHUB_ENV
echo -e "${FULL_MESSAGE}" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
# Важно: нужно корректно обрабатывать пустые значения, если некоторых полей нет в событии
env:
TITLE: ${{ github.event.issue.title || github.event.pull_request.title || 'Комментарий к событию' }}
BODY: ${{ github.event.issue.body || github.event.comment.body || github.event.review.body || '' }} # Если комментарий пуст или это не событие комментария
URL: ${{ github.event.issue.html_url || github.event.pull_request.html_url || github.event.comment.html_url || github.event.review.html_url }}
- name: Send Telegram Message
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
Repository: ${{ github.repository }}
Type: ${{ github.event_name }}
Action: ${{ github.event.action }}
Titile: ${{ github.event.issue.title || github.event.pull_request.title || 'Комментарий' }}
${{github.event.comment.body || ''}}
See ${{ github.event.issue.html_url || github.event.pull_request.html_url || github.event.comment.html_url || github.event.review.html_url }}
message: ${{ env.TELEGRAM_MESSAGE }} # Используем подготовленное сообщение
format: markdown # Или html, если хотите использовать разметку в сообщениях

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ data.json
.DS_Store
coverage/
test-report.html

31
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,31 @@
# Code of Conduct / Кодекс Поведения
## English
This project encourages open and free communication among its participants. We believe diverse perspectives and constructive discussions are valuable.
All interactions related to this project (including issues, pull requests, comments, etc.) are expected to adhere to the **[GitHub Community Guidelines](https://docs.github.com/en/site-policy/github-community-guidelines)** and **[GitHub Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)**.
We ask participants to be generally respectful and stay focused on topics relevant to the project. Behavior that violates GitHub's policies is not acceptable.
Project maintainers reserve the right to moderate discussions (e.g., close off-topic issues, delete inappropriate comments, or block disruptive users) primarily to ensure compliance with GitHub's platform rules and maintain a productive environment focused on the project itself.
**Disclaimer:** Project maintainers primarily act as moderators to keep discussions focused and aligned with GitHub's policies. They are not responsible for mediating all disputes or interpreting nuanced applications of GitHub's rules. Significant platform policy issues should be addressed through GitHub's official reporting mechanisms. Maintainers contribute voluntarily and response times may vary.
By participating in this project, you agree to abide by these principles and GitHub's platform rules.
---
## Русский
Этот проект поощряет открытое и свободное общение между участниками. Мы верим в ценность различных точек зрения и конструктивных обсуждений.
Ожидается, что все взаимодействия, связанные с этим проектом (включая issues, pull requests, комментарии и т.д.), будут соответствовать **[Правилам Сообщества GitHub](https://docs.github.com/ru/site-policy/github-community-guidelines)** и **[Условиям Обслуживания GitHub](https://docs.github.com/ru/site-policy/github-terms/github-terms-of-service)**.
Мы просим участников быть взаимно уважительными и фокусироваться на темах, относящихся к проекту. Поведение, нарушающее политики GitHub, неприемлемо.
Сопровождающие (мейнтенеры) проекта оставляют за собой право модерировать обсуждения (например, закрывать issues не по теме, удалять неуместные комментарии или блокировать деструктивных пользователей) в первую очередь для обеспечения соответствия правилам платформы GitHub и поддержания продуктивной среды, сфокусированной на самом проекте.
**Отказ от ответственности:** Сопровождающие (мейнтенеры) проекта действуют в качестве модераторов в первую очередь для поддержания сфокусированности обсуждений и их соответствия политикам GitHub. Они не несут ответственности за разрешение всех споров или интерпретацию неявных применений правил GitHub. О серьезных нарушениях правил платформы следует сообщать через официальные механизмы GitHub. Мейнтенеры вносят свой вклад на добровольной основе, и время ответа может варьироваться.
Участвуя в этом проекте, вы соглашаетесь следовать этим принципам и правилам платформы GitHub.

163
README.md
View file

@ -1,148 +1,47 @@
# Checkbox Sync
# Checkbox Sync for Obsidian
[![License: 0BSD](https://img.shields.io/badge/License-0BSD-blue.svg)](LICENSE)
[![Latest Release](https://img.shields.io/github/v/release/groldsf/obsidian_check_plugin)](https://github.com/groldsf/obsidian_check_plugin/releases)
[![Downloads](https://img.shields.io/github/downloads/groldsf/obsidian_check_plugin/total.svg)](https://github.com/groldsf/obsidian_check_plugin/releases)
- [English](#english)
- [Русский](#русский)
**Checkbox Sync** is an [Obsidian](https://obsidian.md/) plugin that helps automate the management of checkbox states within hierarchical lists (parent ↔ children synchronization).
## English
It automatically updates parent checkboxes based on their children's state, and optionally cascades changes from parent to children, providing flexibility for managing task lists and nested checklists.
### Description
<p align="center">
<img src="https://raw.githubusercontent.com/groldsf/obsidian_check_plugin/refs/heads/master/img/showcase.gif" alt="Checkbox Sync Demo: Parent state updates automatically based on children." width="400">
<br/>
<em>Example: Parent checkbox automatically reflects child completion status.</em>
</p>
**Checkbox Sync** is a plugin for [Obsidian](https://obsidian.md/) that automates the management of checkbox states in your notes. If all child checkboxes are checked, the plugin automatically checks the parent checkbox, and if at least one of them is unchecked, it unchecks the parent.
[**➡️ Read the Full Documentation (English & Русский) for more examples and settings**](https://groldsf.github.io/obsidian_check_plugin/)
### Supported Lists
The plugin works with both ordered and unordered lists:
## Key Features
- **Unordered Lists** - Lists created with `-`, `+` or `*` markers
- **Ordered Lists** - Numbered lists (1., 2., etc.)
* Automatically check/uncheck parent based on children state.
* Automatically check/uncheck children when parent state changes manually.
* Configurable synchronization directions (upward/downward).
* Supports ordered and unordered lists.
* 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.
The plugin respects list indentation, allowing for nested checkbox hierarchies at any level.
## Quick Links
### Settings
* [**Documentation Home**](https://groldsf.github.io/obsidian_check_plugin/)
* [Installation Guide](https://groldsf.github.io/obsidian_check_plugin/installation)
* [Settings Overview](https://groldsf.github.io/obsidian_check_plugin/settings)
* [Usage & Examples](https://groldsf.github.io/obsidian_check_plugin/usage)
* [Changelog](https://groldsf.github.io/obsidian_check_plugin/changelog)
* [Roadmap](https://groldsf.github.io/obsidian_check_plugin/roadmap)
* [Report an Issue / Suggestion](https://github.com/groldsf/obsidian_check_plugin/issues)
The plugin offers the following configuration options:
## Contributing
- **X-Only Mode** - When enabled, only checkboxes marked with 'x' are considered checked. When disabled, any character inside the checkbox (except space) marks it as checked.
Contributions are welcome! Please see the [**Contributing Guide**](https://groldsf.github.io/obsidian_check_plugin/contributing) on our documentation site for details on how to help improve Checkbox Sync.
### Usage
## License
After activating the plugin, it automatically tracks checkboxes in your notes. Example usage:
![](https://raw.githubusercontent.com/groldsf/obsidian_check_plugin/refs/heads/master/img/showcase.gif)
In this example, as soon as both child checkboxes are checked, the plugin will automatically check the parent checkbox. If at least one child checkbox is unchecked, the parent will be unchecked.
### Installation
#### Manual Installation
1. Go to the [releases page](https://github.com/groldsf/obsidian_check_plugin/releases) and download the latest plugin archive.
2. Extract the archive to the directory:
`<your-vault>/.obsidian/plugins/checkbox-sync`
3. Restart Obsidian.
4. Open **Settings → Community plugins** and enable **Checkbox Sync**.
#### Installation via Plugin Manager (if supported)
1. Open Obsidian and go to **Settings → Community plugins**.
2. Click the **Browse** button, search for "Checkbox Sync", and install it.
3. Enable the plugin after installation.
### Development and Contribution
If you want to make changes or improvements to the plugin:
1. **Fork** the repository.
2. Create a new branch:
`git checkout -b feature/feature-name`
3. Make changes and commit:
`git commit -m "Description of changes"`
4. Push changes to your fork:
`git push origin feature/feature-name`
5. Open a Pull Request in the original repository.
### License
This project is licensed under the 0BSD license.
### Acknowledgments
- [Obsidian](https://obsidian.md/) — a platform for creating and organizing notes.
- The project is based on the [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin) template.
---
If you have any questions, suggestions, or find a bug, please [create an issue](https://github.com/groldsf/obsidian_check_plugin/issues) in the repository.
## Русский
### Описание
**Checkbox Sync** — это плагин для [Obsidian](https://obsidian.md/), который автоматизирует управление состоянием чекбоксов в ваших заметках. Если все дочерние чекбоксы выполнены, плагин автоматически отмечает родительский чекбокс, а если хотя бы один из них не отмечен — снимает отметку с родительского.
### Поддерживаемые списки
Плагин работает как с нумерованными, так и с ненумерованными списками:
- **Ненумерованные списки** - Списки, созданные с помощью маркеров `-`, `+` или `*`
- **Нумерованные списки** - Списки с цифровой нумерацией (1., 2. и т.д.)
Плагин учитывает отступы списков, позволяя создавать вложенные иерархии чекбоксов на любом уровне.
### Настройки
Плагин предлагает следующие параметры конфигурации:
- **Режим X-Only** - Когда включен, только чекбоксы, отмеченные символом 'x', считаются выполненными. Когда отключен, любой символ внутри чекбокса (кроме пробела) считается отметкой о выполнении.
### Использование
После активации плагина, он автоматически отслеживает чекбоксы в ваших заметках. Пример использования:
![](https://raw.githubusercontent.com/groldsf/obsidian_check_plugin/refs/heads/master/img/showcase.gif)
В данном примере, как только оба дочерних чекбокса будут отмечены, плагин автоматически отметит родительский чекбокс. Если хотя бы один дочерний чекбокс не отмечен, родительский сбросится.
### Установка
#### Ручная установка
1. Перейдите на [страницу релизов](https://github.com/groldsf/obsidian_check_plugin/releases) и скачайте последний архив плагина.
2. Распакуйте архив в директорию:
`<ваше-хранилище>/.obsidian/plugins/checkbox-sync`
3. Перезапустите Obsidian.
4. Откройте **Настройки → Плагины сообщества** и активируйте **Checkbox Sync**.
#### Установка через менеджер плагинов (если поддерживается)
1. Откройте Obsidian и перейдите в **Настройки → Плагины сообщества**.
2. Нажмите кнопку **Обзор**, найдите "Checkbox Sync" и установите его.
3. Активируйте плагин после установки.
### Разработка и вклад
Если вы хотите внести изменения или улучшения в плагин:
1. **Форкните** репозиторий.
2. Создайте новую ветку:
`git checkout -b feature/имя-функционала`
3. Внесите изменения и сделайте коммит:
`git commit -m "Описание изменений"`
4. Отправьте изменения в свой форк:
`git push origin feature/имя-функционала`
5. Откройте Pull Request в оригинальном репозитории.
### Лицензия
Этот проект лицензирован под лицензией 0BSD.
### Благодарности
- [Obsidian](https://obsidian.md/) — платформа для создания и организации заметок.
- Проект создан на основе шаблона [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin).
---
Если у вас есть вопросы, предложения или вы обнаружили баг, пожалуйста, [создайте issue](https://github.com/groldsf/obsidian_check_plugin/issues) в репозитории.
This project is licensed under the 0BSD license. See the [LICENSE](LICENSE) file for details.

13
SECURITY.md Normal file
View file

@ -0,0 +1,13 @@
# Security Policy
## Supported Versions
We are committed to addressing security issues in the **latest release** of the Checkbox Sync plugin available in the Obsidian Community Plugins store.
## Reporting a Vulnerability
If you discover a security vulnerability, please report it privately through **GitHub's private vulnerability reporting feature**. You can find instructions on how to do this [here](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability).
**Please do not report security vulnerabilities through public GitHub issues.**
We aim to acknowledge receipt of your report within 48 hours and will work with you to understand and address the issue. We appreciate your efforts in responsibly disclosing your findings.

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

@ -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

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

View file

@ -0,0 +1,78 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { SettingsValidator } from "src/ui/validation/SettingsValidator";
describe('SettingsValidator', () => {
let validator: SettingsValidator;
beforeEach(() => {
validator = new SettingsValidator();
});
// Хелпер для создания Partial настроек
const createSettings = (checked: string[], unchecked: string[], ignore: string[]): Partial<CheckboxSyncPluginSettings> => ({
checkedSymbols: checked,
uncheckedSymbols: unchecked,
ignoreSymbols: ignore,
});
test('should return no errors for valid lists with no intersections', () => {
const settings = createSettings(['x'], [' '], ['-']);
const errors = validator.validate(settings);
expect(errors).toEqual([]);
});
test('should return no errors for empty ignore list', () => {
const settings = createSettings(['x'], [' '], []);
const errors = validator.validate(settings);
expect(errors).toEqual([]);
});
test('should return no errors when lists are missing (treated as empty)', () => {
const settings: Partial<CheckboxSyncPluginSettings> = { checkedSymbols: ['x'] }; // unchecked и ignore отсутствуют
const errors = validator.validate(settings);
expect(errors).toEqual([]);
});
test('should return error for intersection between checked and unchecked', () => {
const settings = createSettings(['x', 'a'], [' ', 'a'], ['-']);
const errors = validator.validate(settings);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('Checked and Unchecked');
expect(errors[0].message).toContain('["a"]');
});
test('should return error for intersection between checked and ignore', () => {
const settings = createSettings(['x', '-'], [' '], ['-', 'o']);
const errors = validator.validate(settings);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('Checked and Ignore');
expect(errors[0].message).toContain('["-"]');
});
test('should return error for intersection between unchecked and ignore', () => {
const settings = createSettings(['x'], [' ', '~'], ['-', '~']);
const errors = validator.validate(settings);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('Unchecked and Ignore');
expect(errors[0].message).toContain('["~"]');
});
test('should return multiple errors for multiple intersections', () => {
const settings = createSettings(['x', 'a'], [' ', 'a'], ['-', 'x']); // a: checked/unchecked, x: checked/ignore
const errors = validator.validate(settings);
expect(errors).toHaveLength(2);
// Проверяем наличие обоих сообщений (порядок может быть разным)
expect(errors.some(e => e.message.includes('Checked and Unchecked') && e.message.includes('["a"]'))).toBe(true);
expect(errors.some(e => e.message.includes('Checked and Ignore') && e.message.includes('["x"]'))).toBe(true);
});
test('should return all three errors if one symbol intersects all lists', () => {
const settings = createSettings(['x'], ['x'], ['x']);
const errors = validator.validate(settings);
expect(errors).toHaveLength(3);
expect(errors.some(e => e.message.includes('Checked and Unchecked'))).toBe(true);
expect(errors.some(e => e.message.includes('Checked and Ignore'))).toBe(true);
expect(errors.some(e => e.message.includes('Unchecked and Ignore'))).toBe(true);
});
});

View file

@ -0,0 +1,173 @@
import { CheckboxState } from "src/types";
import { validateIsCheckboxState, validateJsonStringArray, validateNotEmptyArray, validateValueIsBoolean } from "src/ui/validation/validators";
// Группа тестов для всего файла валидаторов
describe('Validation Utility Functions', () => {
// Подгруппа тестов для конкретной функции validateValueIsBoolean
describe('validateValueIsBoolean', () => {
// Тест кейсы для валидных значений (должны возвращать null)
test('should return null for true', () => {
expect(validateValueIsBoolean(true)).toBeNull();
});
test('should return null for false', () => {
expect(validateValueIsBoolean(false)).toBeNull();
});
// Тест кейсы для невалидных значений (должны возвращать объект ошибки)
test('should return error object for null', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(null)).toEqual(expectedError);
});
test('should return error object for undefined', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(undefined)).toEqual(expectedError);
});
test('should return error object for numbers', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(0)).toEqual(expectedError);
expect(validateValueIsBoolean(1)).toEqual(expectedError);
expect(validateValueIsBoolean(123)).toEqual(expectedError);
expect(validateValueIsBoolean(-10)).toEqual(expectedError);
});
test('should return error object for strings', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean('')).toEqual(expectedError);
expect(validateValueIsBoolean('true')).toEqual(expectedError);
expect(validateValueIsBoolean('false')).toEqual(expectedError);
expect(validateValueIsBoolean('any string')).toEqual(expectedError);
});
test('should return error object for objects', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean({})).toEqual(expectedError);
expect(validateValueIsBoolean({ a: 1 })).toEqual(expectedError);
});
test('should return error object for arrays', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean([])).toEqual(expectedError);
expect(validateValueIsBoolean([true])).toEqual(expectedError);
});
});
describe('validateIsCheckboxState', () => {
it('should return null for valid CheckboxState values', () => {
expect(validateIsCheckboxState(CheckboxState.Checked)).toBeNull();
expect(validateIsCheckboxState(CheckboxState.Unchecked)).toBeNull();
expect(validateIsCheckboxState(CheckboxState.Ignore)).toBeNull();
});
it('should return error object for invalid values', () => {
const expectedError = { message: `Invalid checkbox state selected. Must be one of: ${Object.values(CheckboxState).join(', ')}.` };
expect(validateIsCheckboxState('checked ')).toEqual(expectedError); // Лишний пробел
expect(validateIsCheckboxState('CHECKED')).toEqual(expectedError); // Не тот регистр
expect(validateIsCheckboxState(null)).toEqual(expectedError);
expect(validateIsCheckboxState(undefined)).toEqual(expectedError);
expect(validateIsCheckboxState(1)).toEqual(expectedError);
expect(validateIsCheckboxState({})).toEqual(expectedError);
expect(validateIsCheckboxState('')).toEqual(expectedError);
});
});
describe('validateJsonStringArray', () => {
// Валидные случаи
test('should return null for valid single-element array', () => {
expect(validateJsonStringArray('["x"]')).toBeNull();
expect(validateJsonStringArray('[" "]')).toBeNull();
});
test('should return null for valid multi-element array', () => {
expect(validateJsonStringArray('["x", " ", "✓"]')).toBeNull();
});
test('should return null for empty array string "[]"', () => {
expect(validateJsonStringArray('[]')).toBeNull();
});
test('should return null for empty string ""', () => {
// Мы решили, что пустая строка - валидный пустой массив
expect(validateJsonStringArray('')).toBeNull();
});
test('should return null for string with whitespace around valid JSON', () => {
expect(validateJsonStringArray(' ["x", "o"] ')).toBeNull();
});
// Невалидные случаи: Не JSON
test('should return error for non-JSON string', () => {
expect(validateJsonStringArray('["x"')).toEqual({ message: expect.stringContaining('JSON format') });
expect(validateJsonStringArray('abc')).toEqual({ message: expect.stringContaining('JSON format') });
expect(validateJsonStringArray('[x]')).toEqual({ message: expect.stringContaining('JSON format') }); // 'x' должен быть в кавычках
});
// Невалидные случаи: Не массив
test('should return error for valid JSON that is not an array', () => {
expect(validateJsonStringArray('{"a": 1}')).toEqual({ message: expect.stringContaining('must be a valid JSON array') });
expect(validateJsonStringArray('"string"')).toEqual({ message: expect.stringContaining('must be a valid JSON array') });
expect(validateJsonStringArray('123')).toEqual({ message: expect.stringContaining('must be a valid JSON array') });
expect(validateJsonStringArray('null')).toEqual({ message: expect.stringContaining('must be a valid JSON array') });
});
// Невалидные случаи: Содержимое массива - не строки
test('should return error if array contains non-string elements', () => {
expect(validateJsonStringArray('[1]')).toEqual({ message: expect.stringContaining('not a string') });
expect(validateJsonStringArray('["x", 2]')).toEqual({ message: expect.stringContaining('not a string') });
expect(validateJsonStringArray('[null]')).toEqual({ message: expect.stringContaining('not a string') });
expect(validateJsonStringArray('[[]]')).toEqual({ message: expect.stringContaining('not a string') });
});
// Невалидные случаи: Строки не из одного символа
test('should return error if array contains strings of incorrect length', () => {
expect(validateJsonStringArray('["xx"]')).toEqual({ message: expect.stringContaining('must be a single character') });
expect(validateJsonStringArray('["x", "ab"]')).toEqual({ message: expect.stringContaining('must be a single character') });
expect(validateJsonStringArray('[""]')).toEqual({ message: expect.stringContaining('must be a single character') }); // Пустая строка тоже не 1 символ
});
// Невалидные случаи: Дубликаты символов
test('should return error if array contains duplicate symbols', () => {
expect(validateJsonStringArray('["x", "x"]')).toEqual({ message: expect.stringContaining('Duplicate symbol "x"') });
expect(validateJsonStringArray('["a", "b", "a"]')).toEqual({ message: expect.stringContaining('Duplicate symbol "a"') });
});
// Невалидные случаи: Null или undefined на входе
test('should return error for null or undefined input', () => {
expect(validateJsonStringArray(null)).toEqual({ message: "Input must be a string." });
expect(validateJsonStringArray(undefined)).toEqual({ message: "Input must be a string." });
});
});
describe('validateNotEmptyArray', () => {
// Валидные случаи (не пустой массив)
test('should return null for non-empty arrays', () => {
expect(validateNotEmptyArray(['a'])).toBeNull();
expect(validateNotEmptyArray([1, 2])).toBeNull();
expect(validateNotEmptyArray([null])).toBeNull();
expect(validateNotEmptyArray([undefined])).toBeNull();
expect(validateNotEmptyArray([[]])).toBeNull(); // Массив, содержащий пустой массив
});
// Невалидные случаи: Пустой массив
test('should return error object for an empty array', () => {
expect(validateNotEmptyArray([])).toEqual({ message: 'The list cannot be empty.' });
});
// Невалидные случаи: Не массив
test('should return error object for non-array values', () => {
const expectedError = { message: 'Value must be an array.' };
expect(validateNotEmptyArray(null)).toEqual(expectedError);
expect(validateNotEmptyArray(undefined)).toEqual(expectedError);
expect(validateNotEmptyArray('abc')).toEqual(expectedError);
expect(validateNotEmptyArray(123)).toEqual(expectedError);
expect(validateNotEmptyArray({})).toEqual(expectedError);
});
});
});

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.

17
docs/_config.yml Normal file
View file

@ -0,0 +1,17 @@
remote_theme: pmarsceill/just-the-docs
# --- Основные настройки сайта (используются темой) ---
title: Checkbox Sync Documentation
description: Documentation for the Checkbox Sync Obsidian plugin.
# --- Базовые настройки "Just the Docs" (можно добавить позже) ---
# Включить встроенный поиск (требует небольшой настройки, см. док-ю темы)
search_enabled: false
# Вспомогательные ссылки в шапке (необязательно)
aux_links:
"Checkbox Sync on GitHub":
- "https://github.com/groldsf/obsidian_check_plugin" # Замените на URL вашего репозитория
# Настройки цветовой схемы (необязательно, по умолчанию светлая)
color_scheme: light

BIN
docs/assets/showcase.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

BIN
docs/assets/showcase2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

79
docs/changelog.md Normal file
View file

@ -0,0 +1,79 @@
---
title: Changelog
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).
- Added Feature: Setting to Disable Automatic Checkbox Synchronization on File Open[#13](https://github.com/groldsf/obsidian_check_plugin/issues/13).
### Changed
- Refactored the internal structure of the plugin's settings tab for better maintainability and easier addition of future options [#15](https://github.com/groldsf/obsidian_check_plugin/issues/15).
- Remove old settings xOnlyMod
## [1.0.10] - 2025-04-12
### Fixed
- Fix bug [#10](https://github.com/groldsf/obsidian_check_plugin/issues/10).
## [1.0.9] - 2025-04-11
### Added
- Added "Allow (un)checking of parent" [#9](https://github.com/groldsf/obsidian_check_plugin/issues/9).
- Added file cache.
- Added new settings.
## [1.0.8] - 2025-03-20
### Fixed
- Fix callback.
- Fix scroll.
## [1.0.7] - 2025-03-20
### Added
- Added mutex.
## [1.0.6] - 2025-03-19
### Fixed
- Bug: non-cascading update in view mode [#8](https://github.com/groldsf/obsidian_check_plugin/issues/8)
## [1.0.5] - 2025-03-15
### Added
- Added settings tab.
### Fixed
- Fix name and description.
- [#4](https://github.com/groldsf/obsidian_check_plugin/issues/4).
- [#6](https://github.com/groldsf/obsidian_check_plugin/issues/6).
- [#7](https://github.com/groldsf/obsidian_check_plugin/issues/7).
## [1.0.4] - 2025-03-10
### Added
- Add checkbox sync support for Reading View via vault.on('modify'), keep editor-change for real-time updates, [#3](https://github.com/groldsf/obsidian_check_plugin/issues/3).
### Fixed
- Prevent editor scrolling on checkbox sync by moving cursor to end of first changed line and hiding it with blur, [#2](https://github.com/groldsf/obsidian_check_plugin/issues/2).
- Fix license.
## [1.0.3] - 2025-02-18
### Added
- Added tests.
## [1.0.2] - 2025-02-17
### Added
- Added support for different list types.
### Fixed
- Fix name and description.
## [1.0.1] - 2025-02-08
### Fixed
- Fixed cursor and scroll bug.
## [1.0.0] - 2025-02-03
- Initial release of Checkbox Sync.

40
docs/contributing.md Normal file
View file

@ -0,0 +1,40 @@
---
title: Contributing
nav_order: 6
---
# Contributing
- [English](#english)
- [Русский](#русский)
## English
If you want to make changes or improvements to the plugin:
1. **Fork** the repository.
2. **Clone** your fork locally.
3. **Install dependencies:** `npm install` (or yarn)
4. **Start development build:** `npm run dev` (or yarn dev)
5. Create a new branch: `git checkout -b feature/feature-name`
6. Make changes and commit: `git commit -m "feat: Description of changes"` (Try to follow [conventional commit messages](https://www.conventionalcommits.org/))
7. Push changes to your fork: `git push origin feature/feature-name`
8. Open a Pull Request in the original repository.
Please ensure your code adheres to the existing style and include updates to the documentation (`/docs`) if your changes affect usage or settings.
---
## Русский
Если вы хотите внести изменения или улучшения в плагин:
1. **Форкните** репозиторий.
2. **Клонируйте** ваш форк локально.
3. **Установите зависимости:** `npm install` (или yarn)
4. **Запустите сборку для разработки:** `npm run dev` (или yarn dev)
5. Создайте новую ветку: `git checkout -b feature/имя-функционала`
6. Внесите изменения и сделайте коммит: `git commit -m "feat: Описание изменений"` (Старайтесь следовать [conventional commit messages](https://www.conventionalcommits.org/))
7. Отправьте изменения в свой форк: `git push origin feature/имя-функционала`
8. Откройте Pull Request в оригинальном репозитории.
Пожалуйста, убедитесь, что ваш код соответствует существующему стилю, и включите обновления документации (`/docs`), если ваши изменения затрагивают использование или настройки.

56
docs/index.md Normal file
View file

@ -0,0 +1,56 @@
---
title: Welcome
nav_order: 0
---
# Welcome to Checkbox Sync Documentation
- [English](#english)
- [Русский](#русский)
## English
**Checkbox Sync** is a plugin for [Obsidian](https://obsidian.md/) that helps automate the management of checkbox states within hierarchical lists in your notes.
It offers bidirectional synchronization options based on your settings:
1. **Parent state from children:** If enabled, the parent checkbox state automatically reflects the completion status of its children.
2. **Child state from parent:** If enabled, manually changing a parent checkbox's state will cascade that change down to all its children.
This provides flexibility in how you manage your task lists, allowing you to customize checkbox symbol recognition and synchronization behavior.
**Quick Links:**
* [Usage and Examples](usage.md)
* [Settings Explained](settings.md)
* [Changelog](changelog.md)
* [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.
* The project is based on the [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin) template.
---
## Русский
**Checkbox Sync** — это плагин для [Obsidian](https://obsidian.md/), который помогает автоматизировать управление состоянием чекбоксов в иерархических списках ваших заметок.
Он предлагает опции двунаправленной синхронизации в соответствии с вашими настройками:
1. **Состояние родителя от детей:** Если включено, состояние родительского чекбокса автоматически отражает статус выполнения его дочерних элементов.
2. **Состояние детей от родителя:** Если включено, ручное изменение состояния родительского чекбокса каскадно применит это изменение ко всем его дочерним элементам.
Это обеспечивает гибкость в управлении списками задач, позволяя вам настраивать распознавание символов чекбоксов и поведение синхронизации.
**Быстрые ссылки:**
* [Использование и примеры](usage.md)
* [Описание настроек](settings.md)
* [История изменений](changelog.md)
* [Планы (Roadmap)](roadmap.md)
* [Руководство по установке](installation.md)
* [Руководство для контрибьюторов](contributing.md)
* [Интеграция с Tasks plugin](Integration_with_Tasks_plugin.md)
### Благодарности
* [Obsidian](https://obsidian.md/) — платформа для создания и организации заметок.
* Проект создан на основе шаблона [obsidian-sample-plugin](https://github.com/obsidianmd/obsidian-sample-plugin).

38
docs/installation.md Normal file
View file

@ -0,0 +1,38 @@
---
title: Installation
nav_order: 5
---
# Installation
- [English](#english)
- [Русский](#русский)
## English
### Via Community Plugins (Recommended)
1. Open Obsidian and go to **Settings → Community plugins**.
2. Click the **Browse** button, search for "Checkbox Sync", and click **Install**.
3. Once installed, click **Enable**.
### Manual Installation
1. Go to the [releases page](https://github.com/groldsf/obsidian_check_plugin/releases) and download the latest `main.js`, `manifest.json`, and `styles.css` (if present).
2. Create a new folder named `checkbox-sync` inside your vault's plugin folder: `<your-vault>/.obsidian/plugins/`.
3. Copy the downloaded files into the `<your-vault>/.obsidian/plugins/checkbox-sync/` folder.
4. Restart Obsidian.
5. Open **Settings → Community plugins**, find **Checkbox Sync** in the list, and enable it.
---
## Русский
### Через Плагины сообщества (Рекомендуется)
1. Откройте Obsidian и перейдите в **Настройки → Плагины сообщества**.
2. Нажмите кнопку **Обзор**, найдите "Checkbox Sync" и нажмите **Установить**.
3. После установки нажмите **Включить**.
### Ручная установка
1. Перейдите на [страницу релизов](https://github.com/groldsf/obsidian_check_plugin/releases) и скачайте последние `main.js`, `manifest.json` и `styles.css` (при наличии).
2. Создайте новую папку с именем `checkbox-sync` в папке плагинов вашего хранилища: `<ваше-хранилище>/.obsidian/plugins/`.
3. Скопируйте загруженные файлы в папку `<ваше-хранилище>/.obsidian/plugins/checkbox-sync/`.
4. Перезапустите Obsidian.
5. Откройте **Настройки → Плагины сообщества**, найдите **Checkbox Sync** в списке и активируйте его.

24
docs/roadmap.md Normal file
View file

@ -0,0 +1,24 @@
---
title: Roadmap
nav_order: 4
---
# Roadmap
This document outlines the development status and future plans for the Checkbox Sync plugin. It is divided into the main focus for the **upcoming release** and a backlog of **ideas for future versions**.
*(Note: This roadmap is tentative and priorities may shift.)*
## Next Release Candidates
*This section lists the main features and technical work currently planned or in progress for the next release.*
* [Feature] **Configurable Checkbox Character (Auto-Update):** Add a setting to define which character is used to mark checkboxes when automatically updated by the plugin.
## 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] **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.
---
*Contributions and suggestions are welcome! Please open an issue to discuss these items or suggest new ones.*

140
docs/settings.md Normal file
View file

@ -0,0 +1,140 @@
---
title: Settings
nav_order: 2
---
# Settings
- [English](#english)
- [Русский](#русский)
## English
The plugin offers the following configuration options, accessible via Obsidian's settings panel.
### Checkbox Symbol Configuration (Advanced: JSON)
This section allows for fine-grained control over how different symbols within checkboxes `[ ]` are interpreted by the plugin. **Note:** These settings replace the previous, simpler "X-Only Mode".
**Warning:** The symbol lists require valid JSON array format. Use double quotes for strings within the array (e.g., `["x", " "]`). Invalid JSON may cause errors.
- **Checked Symbols**
- **Description:** Define symbols that should be treated as "checked". Enter symbols as a JSON array of single-character strings.
- **Example:** `["x", "✓", "/"]`
- **Default:** `["x"]`
- **Unchecked Symbols**
- **Description:** Define symbols that should be treated as "unchecked". Enter symbols as a JSON array of single-character strings.
- **Example:** `[" ", "?"]`
- **Default:** `[" "]`
- **Ignore Symbols**
- **Description:** Define symbols that should be completely ignored by the plugin's synchronization logic. Checkboxes with these symbols will not be modified by parent/child updates, nor will they influence the parent's state. Enter symbols as a JSON array of single-character strings.
- **Example:** `["-", "~"]`
- **Default:** `[]`
- **Unknown Symbol Policy**
- **Description:** Determines how to treat checkbox symbols that are not explicitly listed in the Checked, Unchecked, or Ignore lists.
- **Options:**
- `Treat as Checked`: Unknown symbols count as checked.
- `Treat as Unchecked`: Unknown symbols count as unchecked.
- `Ignore`: Unknown symbols are ignored (like those in the Ignore Symbols list).
- **Default:** `Treat as Unchecked`
### Synchronization Behavior
These settings control when and how the synchronization logic runs.
- **Update parent checkbox state automatically**
- **Enabled (Default):** The state of a parent checkbox is automatically updated based on the state of its children (checked if all applicable children are checked, unchecked otherwise).
- **Disabled:** The parent checkbox state is not automatically changed when child states change. Manual changes to a parent *can* still affect children if the setting below is enabled.
- **Update child checkbox state automatically**
- **Enabled (Default):** Manually changing the state of a parent checkbox automatically updates the state of all its direct and nested children (that are not ignored) to match.
- **Disabled:** Manually changing a parent checkbox does not affect its children's state.
- **Enable automatic file synchronization**
- **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.
- **Buttons:**
- `Apply Changes`: Saves and applies any modified settings. Enabled only when changes are detected.
- `Reset changes`: Reverts any modifications back to the last applied settings state.
- `Reset to defaults`: Resets all settings to their default values and applies them immediately.
---
## Русский
Плагин предлагает следующие параметры конфигурации, доступные через панель настроек Obsidian.
### Конфигурация Символов Чекбоксов (Продвинутый: JSON)
Этот раздел позволяет детально настроить, как плагин интерпретирует различные символы внутри чекбоксов `[ ]`. **Примечание:** Эти настройки заменяют предыдущий, более простой режим "X-Only".
**Предупреждение:** Списки символов требуют валидного формата JSON-массива. Используйте двойные кавычки для строк внутри массива (например, `["x", " "]`). Невалидный JSON может вызвать ошибки.
- **Символы "Отмечено" (Checked Symbols)**
- **Описание:** Определите символы, которые должны считаться "отмеченными". Введите символы как JSON-массив строк из одного символа.
- **Пример:** `["x", "✓", "/"]`
- **По умолчанию:** `["x"]`
- **Символы "Не отмечено" (Unchecked Symbols)**
- **Описание:** Определите символы, которые должны считаться "не отмеченными". Введите символы как JSON-массив строк из одного символа.
- **Пример:** `[" ", "?"]`
- **По умолчанию:** `[" "]`
- **Игнорируемые Символы (Ignore Symbols)**
- **Описание:** Определите символы, которые должны полностью игнорироваться логикой синхронизации плагина. Чекбоксы с этими символами не будут изменяться при обновлении родителя/детей и не будут влиять на состояние родителя. Введите символы как JSON-массив строк из одного символа.
- **Пример:** `["-", "~"]`
- **По умолчанию:** `[]`
- **Политика для Неизвестных Символов (Unknown Symbol Policy)**
- **Описание:** Определяет, как обрабатывать символы в чекбоксах, которые явно не указаны в списках "Отмечено", "Не отмечено" или "Игнорируемые".
- **Опции:**
- `Treat as Checked` (Считать отмеченным): Неизвестные символы считаются отмеченными.
- `Treat as Unchecked` (Считать не отмеченным): Неизвестные символы считаются не отмеченными.
- `Ignore` (Игнорировать): Неизвестные символы игнорируются (как те, что в списке "Игнорируемые Символы").
- **По умолчанию:** `Treat as Unchecked`
### Поведение Синхронизации
Эти настройки управляют тем, когда и как запускается логика синхронизации.
- **Автоматически обновлять состояние родительского чекбокса**
- **Включено (По умолчанию):** Состояние родительского чекбокса автоматически обновляется на основе состояния дочерних (отмечен, если все применимые дочерние отмечены, не отмечен в противном случае).
- **Отключено:** Состояние родительского чекбокса не изменяется автоматически при изменении состояния дочерних. Ручные изменения родителя *могут* влиять на дочерние, если включена настройка ниже.
- **Автоматически обновлять состояние дочерних чекбоксов**
- **Включено (По умолчанию):** Ручное изменение состояния родительского чекбокса автоматически обновляет состояние всех его прямых и вложенных дочерних элементов (которые не игнорируются).
- **Отключено:** Ручное изменение родительского чекбокса не влияет на состояние его дочерних элементов.
- **Включить автоматическую синхронизацию файлов**
- **Включено:** Автоматически синхронизирует состояния чекбоксов при загрузке/открытии файлов и сразу после применения настроек плагина. Это обеспечивает консистентность, но может влиять на производительность в очень больших хранилищах или файлах. *(Требует перезапуска 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` (Сбросить по умолчанию): Сбрасывает все настройки к значениям по умолчанию и немедленно применяет их.

62
docs/usage.md Normal file
View file

@ -0,0 +1,62 @@
---
title: Usage and Examples
nav_order: 1
---
# Usage and Examples
- [English](#english)
- [Русский](#русский)
## English
After activating the plugin, it automatically tracks and synchronizes checkboxes based on your settings. You can enable or disable the upward sync (parent from children) and downward sync (children from parent) independently via the settings to tailor the behavior to your preferred workflow.
**Example 1: Parent state updates from children**
*(Requires `enableAutomaticParentState` setting)*
<img src="assets/showcase.gif" alt="Checking the last child causes the parent to become checked. Unchecking any child unchecks the parent." width="300">
*Checking the last child causes the parent to become checked. Unchecking any child unchecks the parent.*
**Example 2: Child state updates from parent**
*(Requires `enableAutomaticChildState` setting)*
<img src="assets/showcase2.gif" alt="Manually checking/unchecking the parent checkbox cascades the state change to all children." width="300">
*Manually checking/unchecking the parent checkbox cascades the state change to all children.*
### Supported Lists
The plugin works with both ordered and unordered lists:
- **Unordered Lists** - Lists created with `-`, `+` or `*` markers
- **Ordered Lists** - Numbered lists (1., 2., etc.)
The plugin respects list indentation, allowing for nested checkbox hierarchies at any level. It also attempts to handle checkboxes within embedded files (`![[...]]`), though behavior might depend on Obsidian's own handling of embeds.
---
## Русский
После активации плагина, он автоматически отслеживает и синхронизирует чекбоксы в соответствии с вашими настройками. Вы можете независимо включать или отключать синхронизацию вверх (родитель от детей) и вниз (дети от родителя) с помощью настроек, чтобы адаптировать поведение под ваш рабочий процесс.
**Пример 1: Состояние родителя обновляется от детей**
*(Требует включенной настройки `enableAutomaticParentState`)*
<img src="assets/showcase.gif" alt="Отметка последнего дочернего чекбокса приводит к отметке родительского. Снятие отметки с любого дочернего элемента снимает отметку с родительского." width="300">
*Отметка последнего дочернего чекбокса приводит к отметке родительского. Снятие отметки с любого дочернего элемента снимает отметку с родительского.*
**Пример 2: Состояние детей обновляется от родителя**
*(Требует включенной настройки `enableAutomaticChildState`)*
<img src="assets/showcase2.gif" alt="Ручная отметка/снятие отметки с родительского чекбокса каскадно применяет изменение ко всем дочерним элементам." width="300">
*Ручная отметка/снятие отметки с родительского чекбокса каскадно применяет изменение ко всем дочерним элементам.*
### Поддерживаемые списки
Плагин работает как с нумерованными, так и с ненумерованными списками:
- **Ненумерованные списки** - Списки, созданные с помощью маркеров `-`, `+` или `*`
- **Нумерованные списки** - Списки с цифровой нумерацией (1., 2. и т.д.)
Плагин учитывает отступы списков, позволяя создавать вложенные иерархии чекбоксов на любом уровне. Он также пытается корректно обрабатывать чекбоксы внутри встроенных файлов (`![[...]]`), хотя поведение может зависеть от собственной обработки встраиваний в Obsidian.

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) {

BIN
img/showcase2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

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,7 +25,8 @@ 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: [
@ -33,6 +42,20 @@ const config: Config = {
testEnvironment: "node",
// Limit the number of workers that run tests in parallel
maxWorkers: 1, // <--- Задает последовательное выполнение
moduleNameMapper: {
// Этот паттерн говорит: если импорт начинается с 'src/',
// замени 'src/' на '<rootDir>/src/' при поиске файла.
'^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.0.6",
"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",

1113
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,19 @@
{
"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": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"prebuild": "npm test",
"prebuild": "npm run test",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest"
"test": "jest",
"test:fast": "jest --no-coverage"
},
"keywords": [],
"author": "",
"license": "MIT",
"license": "0BSD",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^16.11.6",
@ -22,10 +22,15 @@
"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",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"async-mutex": "^0.5.0",
"ignore": "^7.0.4"
}
}

View file

@ -1,54 +0,0 @@
import { Editor, TFile } from "obsidian";
import CheckboxSyncPlugin from "./main";
export default class CheckboxManager {
private plugin: CheckboxSyncPlugin;
private isProcessingFile = false;
constructor(plugin: CheckboxSyncPlugin) {
this.plugin = plugin;
}
async syncEditor(editor: Editor) {
await this.syncEditorCheckboxes(editor);
}
async syncFile(file: any) {
if (this.isProcessingFile || !(file instanceof TFile) || file.extension !== "md") return;
this.isProcessingFile = true;
try {
await this.syncFileCheckboxes(file);
} finally {
this.isProcessingFile = false;
}
}
private async syncEditorCheckboxes(editor: Editor) {
const updates = this.plugin.checkboxUtils.syncCheckboxes(editor.getValue());
if (updates.length === 0) return;
const firstUpdateLine = updates[0].line;
editor.setCursor({ line: firstUpdateLine, ch: editor.getLine(firstUpdateLine).length });
editor.blur();
updates.forEach(({ line, ch, value }) => {
editor.replaceRange(value, { line, ch }, { line, ch: ch + 1 });
});
}
private async syncFileCheckboxes(file: TFile) {
let text = await this.plugin.app.vault.read(file);
let updates = this.plugin.checkboxUtils.syncCheckboxes(text);
if (updates.length === 0) return;
while (true) {
const lines = text.split("\n");
updates.forEach(({ line, ch, value }) => {
lines[line] = lines[line].substring(0, ch) + value + lines[line].substring(ch + 1);
});
text = lines.join("\n");
updates = this.plugin.checkboxUtils.syncCheckboxes(text);
if (updates.length === 0) break;
}
await this.plugin.app.vault.modify(file, text);
}
}

View file

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

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
}
}

84
src/FileStateHolder.ts Normal file
View file

@ -0,0 +1,84 @@
import { Mutex } from "async-mutex";
import { TFile, Vault } from "obsidian";
import { IFilePathStateHolder } from "./IFilePathStateHolder";
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();
}
/**
* 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);
}
set(file: TFile, text: string) {
if (text !== this.map.get(file)) {
console.log(`File "${file.name}" load to holder`);
this.map.set(file, text);
}
}
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}.`);
}
}
get(file: TFile) {
return this.map.get(file);
}
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;
}

89
src/SyncController.ts Normal file
View file

@ -0,0 +1,89 @@
import { Mutex } from "async-mutex";
import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian";
import TextSyncPipeline from "./TextSyncPipeline";
export default class SyncController {
private vault: Vault;
textSyncPipeline: TextSyncPipeline
private mutex: 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(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);
}
});
}
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);
});
});
}
private async editEditor(editor: Editor, info: MarkdownView, oldText: string, newText: string) {
const cursor = editor.getCursor();
const newLines = newText.split("\n");
const oldLines = oldText.split("\n");
const diffIndexes = this.findDifferentLineIndexes(oldLines, newLines);
const changes: EditorChange[] = [];
for (let ind of diffIndexes) {
changes.push({
from: { line: ind, ch: 0 },
to: { line: ind, ch: oldLines[ind].length },
text: newLines[ind]
});
}
editor.transaction({
changes: changes
});
editor.setCursor(cursor);
const lastDifferentLineIndex = diffIndexes.length > 0 ? diffIndexes[0] : -1;
if (lastDifferentLineIndex != -1) {
editor.scrollIntoView({
from: { line: lastDifferentLineIndex, ch: 0 },
to: { line: lastDifferentLineIndex, ch: 0 }
});
}
await info.save();
}
private 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;
}
}

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,56 +0,0 @@
import { CheckboxSyncPluginSettings } from "./types";
export class CheckboxUtils {
constructor(private settings: CheckboxSyncPluginSettings) {}
updateSettings(settings: CheckboxSyncPluginSettings) {
this.settings = settings;
}
findCheckboxesLine(line: string): RegExpMatchArray | null {
return line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/);
}
isCheckedSymbol(text: string): boolean {
return this.settings.xOnlyMode ? text === "x" : text !== " ";
}
syncCheckboxes(text: string): { line: number; ch: number; value: string }[] {
const lines = text.split("\n");
let updates: { line: number; ch: number; value: string }[] = [];
for (let i = lines.length - 1; i >= 0; i--) {
const match = this.findCheckboxesLine(lines[i]);
if (!match) continue;
const indent = match[1].length;
const isChecked = this.isCheckedSymbol(match[3]);
let allChildrenChecked = true;
let hasChildren = false;
let j = i + 1;
while (j < lines.length) {
const childMatch = this.findCheckboxesLine(lines[j]);
if (!childMatch || childMatch[1].length <= indent) break;
hasChildren = true;
const childrenIsChecked = this.isCheckedSymbol(childMatch[3]);
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
j++;
}
if (hasChildren) {
const checkboxPos = match[1].length + match[2].length + 2;
if (allChildrenChecked && !isChecked) {
updates.push({ line: i, ch: checkboxPos, value: "x" });
} else if (!allChildrenChecked && isChecked) {
updates.push({ line: i, ch: checkboxPos, value: " " });
}
}
}
return updates;
}
}

View file

@ -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

@ -0,0 +1,45 @@
import { App, TAbstractFile, TFile } from "obsidian";
import FileStateHolder from "src/FileStateHolder";
import CheckboxSyncPlugin from "src/main";
import SyncController from "src/SyncController";
export class FileChangeEventHandler {
private plugin: CheckboxSyncPlugin;
private app: App;
private syncController: SyncController;
private fileStateHolder: FileStateHolder;
constructor(plugin: CheckboxSyncPlugin, app: App, syncController: SyncController, fileStateHolder: FileStateHolder) {
this.plugin = plugin;
this.app = app;
this.syncController = syncController;
this.fileStateHolder = fileStateHolder;
}
registerEvents() {
//запуск плагина при модификации файла(для обработки в режиме просмотра)
this.plugin.registerEvent(
this.app.vault.on("modify", async (file) => {
if (!(file instanceof TFile)) return;
if (file.extension !== "md") return;
console.log(`modify file ${file.path}`);
await this.syncController.syncFile(file);
})
);
//запуск плагина при изменении в режиме редактора
this.plugin.registerEvent(
this.app.workspace.on("editor-change", async (editor, info) => {
console.log(`editor-change file ${info.file?.path}`);
await this.syncController.syncEditor(editor, info);
})
);
this.plugin.registerEvent(
this.app.vault.on("delete", (file: TAbstractFile) => {
if (file instanceof TFile && file.extension === "md") {
this.fileStateHolder.delete(file);
}
})
);
}
}

View file

@ -0,0 +1,123 @@
import { Mutex } from "async-mutex";
import { App, MarkdownPostProcessorContext, MarkdownRenderer, MarkdownView, TFile, WorkspaceLeaf } from "obsidian";
import FileStateHolder from "src/FileStateHolder";
import CheckboxSyncPlugin from "src/main";
import SyncController from "src/SyncController";
export class FileLoadEventHandler {
private plugin: CheckboxSyncPlugin;
private app: App;
private syncController: SyncController;
private fileStateHolder: FileStateHolder;
private markdownPostProcessorMutex: Mutex;//для более последовательной обработки файлов в постпроцессоре. но вообще это лишь замедляет плагин
constructor(plugin: CheckboxSyncPlugin, app: App, syncController: SyncController, fileStateHolder: FileStateHolder) {
this.plugin = plugin;
this.app = app;
this.syncController = syncController;
this.fileStateHolder = fileStateHolder;
this.markdownPostProcessorMutex = new Mutex();
}
registerEvents() {
//запуск плагина при открытии файла
this.plugin.registerEvent(
this.app.workspace.on("file-open", async (file) => {
if (file && file.extension === "md") {
console.log(`file-open ${file.path}`);
const isUpdate = await this.fileStateHolder.initIfNeeded(file);
if (isUpdate && this.plugin.settings.enableAutomaticFileSync) {
await this.syncController.syncFile(file);
}
}
})
);
this.plugin.registerMarkdownPostProcessor(async (el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
// console.log(`MarkdownPostProcessor "${ctx.sourcePath}"`);
await this.markdownPostProcessorMutex.runExclusive(async () => {
const file = this.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (file instanceof TFile && file.extension === "md") {
const isUpdate = await this.fileStateHolder.initIfNeeded(file);
if (isUpdate && this.plugin.settings.enableAutomaticFileSync) {
await this.syncController.syncFile(file);
}
}
});
});
this.plugin.registerEvent(
this.app.workspace.on('quick-preview', async (file: TFile, data: string) => {
if (file && file.extension === "md") {
const isUpdate = await this.fileStateHolder.initIfNeeded(file, data);
if (isUpdate && this.plugin.settings.enableAutomaticFileSync) {
await this.syncController.syncFile(file);
}
}
})
);
this.plugin.app.workspace.onLayoutReady(async () => {
await this.loadAndSyncActiveFiles();
});
}
//загрузка в кеш и синхронизация открытых файлов
async loadAndSyncActiveFiles() {
const markdownLeaves = this.app.workspace.getLeavesOfType('markdown');
await Promise.all(markdownLeaves.map(async (leaf) => await this.initializeStateForLeaf(leaf)));
}
private async initializeStateForLeaf(leaf: WorkspaceLeaf) {
const view = leaf.view as MarkdownView;
if (!view.file) return;
const file = view.file;
console.log(`Initializing state for active file and its embeds: ${file.path}`);
const filesToInitialize: Set<TFile> = new Set([file]);
await this.findEmbedsRecursive(file, filesToInitialize);
for (const fileToInit of filesToInitialize) {
try {
const isUpdateNeeded = await this.fileStateHolder.initIfNeeded(fileToInit);
//skip if settings enableAutomaticFileSync === false
if(this.plugin.settings.enableAutomaticFileSync === false){
continue;
}
// Синхронизируем только если инициализация это потребовала,
// ИЛИ если это основной файл, открытый в редакторе (на всякий случай)
if (isUpdateNeeded || (fileToInit === file && view.getMode() === 'source')) {
// Если основной файл в редакторе, лучше вызвать syncEditor для него
if (fileToInit === file && view.getMode() === 'source') {
await this.syncController.syncEditor(view.editor, view);
} else {
await this.syncController.syncFile(fileToInit);
}
}
} catch (error) {
console.error(`Error processing file ${fileToInit.path} during initial load:`, error);
}
}
}
// Вспомогательная рекурсивная функция для поиска всех вложенных embeds
private async findEmbedsRecursive(file: TFile, visited: Set<TFile>) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache?.embeds) return;
for (const embed of cache.embeds) {
const embedFile = this.app.metadataCache.getFirstLinkpathDest(embed.link, file.path);
if (!embedFile) continue;
if (embedFile instanceof TFile && embedFile.extension === 'md') {
// Если файл еще не посещали, добавляем и ищем его внедрения
if (!visited.has(embedFile)) {
visited.add(embedFile);
await this.findEmbedsRecursive(embedFile, visited);
}
}
}
}
}

View file

@ -1,50 +1,83 @@
import { Plugin, TFile } from "obsidian";
import { CheckboxUtils } from "./checkboxUtils";
import { CheckboxSyncPluginSettingTab } from "./CheckboxSyncPluginSettingTab";
import { CheckboxSyncPluginSettings } from "./types";
import CheckboxManager from "./CheckboxManager";
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 DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
xOnlyMode: true,
};
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 {
settings: CheckboxSyncPluginSettings;
private _settings: CheckboxSyncPluginSettings;
checkboxManager: CheckboxManager;
checkboxUtils: CheckboxUtils;
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.checkboxUtils = new CheckboxUtils(this.settings);
this.checkboxManager = new CheckboxManager(this);
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.addSettingTab(new CheckboxSyncPluginSettingTab(this.app, this));
this.fileLoadEventHandler.registerEvents();
this.fileChangeEventHandler.registerEvents();
}
//запуск плагина при открытии файла
this.registerEvent(
this.app.workspace.on("file-open", async (file) => await this.checkboxManager.syncFile(file))
);
//запуск плагина при модификации файла(для обработки в режиме просмотра)
this.registerEvent(
this.app.vault.on("modify", async (file) => await this.checkboxManager.syncFile(file))
);
//запуск плагина при изменении в режиме редактора
this.registerEvent(
this.app.workspace.on("editor-change", async (editor) => await this.checkboxManager.syncEditor(editor))
);
async loadSettings() {
this._settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.setDebugFlag(this.settings.consoleEnabled);
}
}
get settings(): Readonly<CheckboxSyncPluginSettings> {
return this._settings;
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
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);
async saveSettings() {
await this.saveData(this.settings);
this.checkboxUtils.updateSettings(this.settings);
const activeFile = this.app.workspace.getActiveFile();
await this.checkboxManager.syncFile(activeFile);
}
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,3 +1,32 @@
export interface CheckboxSyncPluginSettings {
xOnlyMode: boolean;
tabSize: number,
enableAutomaticParentState: boolean;
enableAutomaticChildState: boolean;
checkedSymbols: string[];
uncheckedSymbols: string[];
ignoreSymbols: string[];
unknownSymbolPolicy: CheckboxState;
enableAutomaticFileSync: boolean;
consoleEnabled: boolean;
pathGlobs: string[];
}
export enum CheckboxState {
Checked = 'checked',
Unchecked = 'unchecked',
Ignore = 'ignore',
NoCheckbox = "noCheckbox"
}
export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {
tabSize: 4,
enableAutomaticParentState: true,
enableAutomaticChildState: true,
checkedSymbols: ['x'],
uncheckedSymbols: [' '],
ignoreSymbols: [],
unknownSymbolPolicy: CheckboxState.Checked,
enableAutomaticFileSync: false,
consoleEnabled: false,
pathGlobs: [],
};

View file

@ -0,0 +1,316 @@
import { Mutex } from "async-mutex";
import { App, Notice, PluginSettingTab } from "obsidian";
import CheckboxSyncPlugin from "../main";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "../types";
import { ErrorDisplay } from "./ErrorDisplay";
import { SettingGroup } from "./SettingGroup";
import { ISettingsControlActions, SettingsControls } from "./SettingsControls";
import { CheckedSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/CheckedSymbolsSettingComponent";
import { IgnoreSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/IgnoreSymbolsSettingComponent";
import { UncheckedSymbolsSettingComponent } from "./components/checkboxSymbolConfiguration/UncheckedSymbolsSettingComponent";
import { UnknownPolicySettingComponent } from "./components/checkboxSymbolConfiguration/UnknownPolicySettingComponent";
import { EnableChildSyncSettingComponent } from "./components/synchronizationBehavior/EnableChildSyncSettingComponent";
import { EnableFileSyncSettingComponent } from "./components/synchronizationBehavior/EnableFileSyncSettingComponent";
import { EnableParentSyncSettingComponent } from "./components/synchronizationBehavior/EnableParentSyncSettingComponent";
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;
private settingGroups: SettingGroup[] = []
private errorDisplay: ErrorDisplay;
private settingsControls: SettingsControls;
private isDirty: boolean = false;
private actionMutex = new Mutex();
constructor(app: App, plugin: CheckboxSyncPlugin) {
super(app, plugin);
this.plugin = plugin;
this.initializeSettingGroups();
}
// Метод для создания и конфигурации групп и компонентов
private initializeSettingGroups(): void {
// Создаем экземпляры наших компонентов
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 parentToggleComp = new EnableParentSyncSettingComponent();
const childrenToggleComp = new EnableChildSyncSettingComponent();
const automaticFileSyncToggleComp = new EnableFileSyncSettingComponent();
const behaviorGroup = new SettingGroup(
"Synchronization Behavior",
[parentToggleComp, childrenToggleComp, automaticFileSyncToggleComp]
);
const pathGlobsComp = new PathGlobsSettingComponent();
const filteringGroup = new SettingGroup(
"File Scope & Filtering",
[pathGlobsComp],
"Define rules to include or exclude files/folders from plugin processing."
);
const consoleLog = new EnableConsoleLogSettingComponent();
const devGroup = new SettingGroup(
"Dev",
[consoleLog]
);
// Сохраняем созданную группу (или группы) в поле класса
this.settingGroups = [
symbolGroup,
behaviorGroup,
filteringGroup,
devGroup
];
const changeListener = () => this.settingChanged();
for (const group of this.settingGroups) {
for (const component of group.components) {
// Устанавливаем общий listener для всех компонентов
component.setChangeListener(changeListener);
}
}
}
private settingChanged() {
this.isDirty = true;
this.settingsControls.setApplyState({ disabled: false, cta: true });
}
private settingSaved() {
this.isDirty = false;
this.settingsControls.setApplyState({ disabled: true, cta: false });
}
display(): void {
this.isDirty = false;
const containerEl = this.containerEl;
containerEl.empty();
containerEl.createEl('h2', { text: 'Checkbox Sync Settings' });
for (const group of this.settingGroups) {
group.render(containerEl, this.plugin.settings);
}
// --- Область для вывода ошибок ---
this.errorDisplay = new ErrorDisplay(containerEl);
// Создаем экземпляр 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);
// Устанавливаем начальное состояние кнопки Apply
// (isDirty здесь всегда false при первом вызове display)
this.settingsControls.setApplyState({ disabled: !this.isDirty, cta: this.isDirty });
// Начальное состояние кнопки Reset Defaults (всегда включена)
this.settingsControls.setResetDefaultsState({ disabled: false });
}
private async applyChanges() {
await this.actionMutex.runExclusive(async () => {
this.settingsControls.setApplyState({ disabled: true, text: 'Applying...', cta: false });
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 })
}
});
}
/**
* Resets the settings UI elements to reflect the currently saved plugin settings.
*/
private resetInputsToSavedSettings() {
const settings = this.plugin.settings;
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);
}
}
}
}
// Очищаем ошибку и сбрасываем состояние "грязный"
this.errorDisplay.clear();
this.settingSaved(); // Используем существующий метод для сброса isDirty и состояния кнопки Apply
}
/**
* 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 defaultsCopy = { ...DEFAULT_SETTINGS }; // Работаем с копией
await this.plugin.updateSettings(settings => {
// Полностью перезаписываем текущие настройки дефолтными
Object.assign(settings, defaultsCopy);
});
// После успешного сохранения обновляем UI и состояние
this.resetInputsToSavedSettings(); // Обновит UI по новым settings и сбросит isDirty/кнопку Apply
new Notice("Settings reset to defaults and applied.", 3000);
} catch (error: any) {
console.error("Error resetting settings to default:", error);
const message = error.message || "Unknown error";
this.errorDisplay.displayMessage(`Error resetting to defaults: ${message}`);
} finally {
// Восстанавливаем кнопку Reset
this.settingsControls.setResetDefaultsState({ disabled: false });
}
});
}
/**
* 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();
}
};
// Колбэк для кнопки "Discard"
const discardCallback = () => {
this.isDirty = false; // Считаем изменения отмененными
};
// Открываем модалку Save/Discard и ЖДЕМ ее закрытия
const saveModal = new SaveConfirmModal(this.app, saveCallback, discardCallback);
saveModal.open();
}
// Продолжаем стандартное закрытие
super.hide();
}
/**
* Reads UI values, validates them, and calls plugin.updateSettings.
* Throws an error if validation or saving fails.
* Does NOT interact with UI feedback (buttons, notices, error display).
*/
private async validateAndSaveSettings(): Promise<{ success: boolean; errors: ValidationError[] }> {
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;
}
}
}

67
src/ui/ErrorDisplay.ts Normal file
View file

@ -0,0 +1,67 @@
import { ValidationError } from "./validation/types";
/**
* Отвечает за создание, стилизацию и обновление
* элемента для отображения ошибок валидации настроек.
*/
export class ErrorDisplay {
private errorElement: HTMLElement;
/**
* Создает и стилизует элемент для отображения ошибок внутри контейнера.
* @param container - Родительский HTML-элемент.
*/
constructor(container: HTMLElement) {
this.errorElement = container.createDiv({ cls: 'checkbox-sync-settings-error' });
this.applyStyles();
}
/**
* Применяет стили к элементу ошибок.
*/
private applyStyles(): void {
// Используем стандартные CSS переменные Obsidian для цвета ошибки
this.errorElement.style.color = 'var(--text-error)';
this.errorElement.style.marginTop = '10px';
this.errorElement.style.marginBottom = '10px';
this.errorElement.style.minHeight = '1.5em'; // Резервируем место
this.errorElement.style.whiteSpace = 'pre-wrap'; // Для переноса длинных ошибок
this.errorElement.style.userSelect = 'text'; // Позволяет выделять текст ошибки
}
/**
* Отображает список ошибок валидации.
* @param errors - Массив объектов ValidationError.
*/
public displayErrors(errors: ValidationError[]): void {
if (errors && errors.length > 0) {
const errorMessage = errors
.map(e => `${e.field ? `[${e.field}]: ` : ''}${e.message}`)
.join('\n');
this.errorElement.setText(errorMessage); // Используем setText для безопасности
} else {
this.clear(); // Если массив пуст или null/undefined, очищаем
}
}
/**
* Отображает одно общее сообщение об ошибке.
* @param message - Строка с сообщением.
*/
public displayMessage(message: string): void {
if (message) {
// Добавляем значок ошибки для консистентности
this.errorElement.setText(`${message}`);
} else {
this.clear();
}
}
/**
* Очищает элемент отображения ошибок.
*/
public clear(): void {
this.errorElement.setText('');
}
}

56
src/ui/SettingGroup.ts Normal file
View file

@ -0,0 +1,56 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { ISettingComponent } from "./interfaces/ISettingComponent";
/**
* Представляет группу связанных настроек в UI.
*/
export class SettingGroup {
title: string;
description?: string;
components: ISettingComponent[];
/**
* Создает экземпляр группы настроек.
* @param title - Заголовок группы (отображается как h3).
* @param components - Массив компонентов настроек, входящих в эту группу.
* @param description - Опциональное описание группы (отображается как p).
*/
constructor(title: string, components: ISettingComponent[], description?: string) {
this.title = title;
this.components = components;
this.description = description;
}
/**
* Рендерит заголовок группы и все ее компоненты.
* @param container - HTML-элемент, куда рендерить группу.
* @param currentSettings - Объект текущих настроек плагина.
*/
render(container: HTMLElement, currentSettings: CheckboxSyncPluginSettings): void {
container.createEl('h3', { text: this.title });
if (this.description) {
container.createEl('p', { text: this.description, cls: 'setting-item-description' });
}
// Рендерим каждый компонент, передавая ему текущее значение
for (const component of this.components) {
try {
const key = component.getSettingKey();
// Проверяем, существует ли ключ в настройках, чтобы избежать ошибок
if (key in currentSettings) {
component.render(container, currentSettings[key]);
} else {
console.warn(`Setting key "${key}" not found in current settings for component in group "${this.title}". Rendering with default or undefined value might occur.`);
// Можно передать undefined или значение по умолчанию, если getSettingKey гарантированно работает
// component.render(container, component.getDefaultValue());
component.render(container, undefined); // Или рендерить с undefined
}
} catch (error) {
console.error(`Error rendering component for key "${component.getSettingKey()}" in group "${this.title}":`, error);
// Можно добавить placeholder или сообщение об ошибке в UI
container.createDiv({ text: `Error rendering setting: ${component.getSettingKey()}`, cls: 'checkbox-sync-settings-error' });
}
}
}
}

122
src/ui/SettingsControls.ts Normal file
View file

@ -0,0 +1,122 @@
import { ButtonComponent, Setting } from 'obsidian';
/**
* Интерфейс для действий, которые должны выполнять кнопки управления.
*/
export interface ISettingsControlActions {
onApply: () => Promise<void> | void;
onReset: () => Promise<void> | void;
onResetDefaults: () => Promise<void> | void;
}
/**
* Параметры для обновления состояния кнопки Apply.
*/
export interface ApplyButtonStateOptions {
disabled: boolean;
cta?: boolean; // Является ли кнопка основной (call to action)
text?: string; // Опциональный текст кнопки
}
/**
* Параметры для обновления состояния кнопки Reset Defaults.
*/
export interface ResetDefaultsButtonStateOptions {
disabled: boolean;
text?: string; // Опциональный текст кнопки
}
/**
* Отвечает за создание и управление кнопками
* "Apply Changes", "Reset changes", "Reset to defaults" на вкладке настроек.
*/
export class SettingsControls {
private applyButton: ButtonComponent;
private resetButton: ButtonComponent; // Кнопка "Reset changes"
private resetDefaultsButton: ButtonComponent;
private readonly defaultApplyText = "Apply Changes";
private readonly defaultResetDefaultsText = "Reset to defaults";
constructor(container: HTMLElement, private actions: ISettingsControlActions) {
const buttonGroup = new Setting(container)
.setClass('checkbox-sync-button-group');
// Кнопка "Reset changes"
buttonGroup.addButton(button => {
this.resetButton = button; // Сохраняем ссылку (хотя она пока не используется)
button
.setButtonText("Reset changes")
.setTooltip("Revert changes to the last applied settings")
.onClick(async () => {
// Просто вызываем переданный колбэк
await this.actions.onReset();
});
});
// Кнопка "Reset to defaults"
buttonGroup.addButton(button => {
this.resetDefaultsButton = button;
button
.setButtonText(this.defaultResetDefaultsText)
.setTooltip("Reset all settings to default values and apply immediately")
.onClick(async () => {
// Вызываем асинхронный колбэк (который внутри покажет ConfirmModal)
await this.actions.onResetDefaults();
});
});
// Кнопка "Apply Changes" (последняя для акцента)
buttonGroup.addButton(button => {
this.applyButton = button;
button
.setButtonText(this.defaultApplyText)
.setDisabled(true) // По умолчанию выключена
.onClick(async () => {
// Вызываем асинхронный колбэк
await this.actions.onApply();
});
});
}
/**
* Обновляет состояние кнопки "Apply Changes".
* @param options Параметры состояния.
*/
public setApplyState(options: ApplyButtonStateOptions): void {
if (!this.applyButton) return;
this.applyButton.setDisabled(options.disabled);
if (options.text !== undefined) {
this.applyButton.setButtonText(options.text);
} else {
// Возвращаем текст по умолчанию, если новый не задан
this.applyButton.setButtonText(this.defaultApplyText);
}
if (options.cta === true && !options.disabled) {
this.applyButton.setCta();
} else {
this.applyButton.removeCta();
}
}
/**
* Обновляет состояние кнопки "Reset to defaults".
* @param options Параметры состояния.
*/
public setResetDefaultsState(options: ResetDefaultsButtonStateOptions): void {
if (!this.resetDefaultsButton) return;
this.resetDefaultsButton.setDisabled(options.disabled);
if (options.text !== undefined) {
this.resetDefaultsButton.setButtonText(options.text);
} else {
// Возвращаем текст по умолчанию
this.resetDefaultsButton.setButtonText(this.defaultResetDefaultsText);
}
}
}

View file

@ -0,0 +1,40 @@
import { Setting } from 'obsidian';
import { CheckboxSyncPluginSettings } from '../../types';
import { ISettingComponent } from '../interfaces/ISettingComponent';
import { ValidationError } from '../validation/types';
// Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам
export abstract class BaseSettingComponent implements ISettingComponent {
protected setting: Setting; // Инициализируется в render конкретного компонента
protected onChangeCallback: () => void = () => { };
// Конструктор теперь пустой или с минимальными общими зависимостями
constructor() { }
// --- Методы для реализации наследниками ---
abstract getSettingKey(): keyof CheckboxSyncPluginSettings;
// render теперь должен сам создавать Setting и сохранять ссылку в this.setting
abstract render(container: HTMLElement, currentValue: any): void;
abstract getValueFromUi(): any;
abstract setValueInUi(value: any): void;
abstract validate(): ValidationError | null;
// --- Общая реализация ---
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,130 @@
import { TextComponent } from "obsidian";
import { ValidationError } from "../validation/types";
import { validateJsonStringArray } from "../validation/validators";
import { BaseSettingComponent } from "./BaseSettingComponent";
/**
* Абстрактный базовый класс для компонентов настроек,
* которые управляют массивом строк через текстовое поле с JSON.
*/
export abstract class BaseTextArraySettingComponent extends BaseSettingComponent {
/** Ссылка на текстовое поле ввода */
protected textInput: TextComponent;
constructor() {
super();
}
/**
* Форматирует массив строк в JSON строку для отображения в UI.
* @param symbols Массив строк.
* @returns Форматированная JSON строка.
*/
protected arrayToJsonString(symbols: string[] | undefined): string {
if (!symbols) {
return '[]';
}
try {
return JSON.stringify(symbols);
} catch (e) {
console.error(`[${this.getSettingKey()}] Error stringifying array to JSON:`, e);
return '[]'; // Возвращаем безопасное значение
}
}
/**
* Получает JSON строку из UI и парсит ее в массив строк.
* @returns Распарсенный массив строк.
* @throws Error если компонент не отрисован или если строка не является валидным JSON.
*/
getValueFromUi(): string[] {
if (this.textInput) {
const rawJson = this.textInput.getValue();
const trimmedValue = rawJson.trim();
// Считаем пустую строку или '[]' валидным представлением пустого массива
if (trimmedValue === '' || trimmedValue === '[]') {
return [];
}
try {
const parsed = JSON.parse(trimmedValue);
// Дополнительная проверка, что результат парсинга - массив
if (!Array.isArray(parsed)) {
// Эта ошибка также будет поймана validateJsonStringArray,
// но для надежности можно оставить.
throw new Error("Input must be a valid JSON array.");
}
// Предполагаем, что содержимое массива будет проверено валидатором.
// Возвращаем как string[] для TypeScript.
return parsed as string[];
} catch (e: any) {
// Оборачиваем ошибку парсинга
throw new Error(`Invalid JSON format: ${e.message}`);
}
}
// Если компонент не был отрисован
throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`);
}
/**
* Устанавливает значение (массив строк) в UI, предварительно форматируя его в JSON.
* @param value Массив строк для установки.
* @throws Error если компонент не отрисован.
*/
setValueInUi(value: any): void {
if (this.textInput) {
// Убедимся, что на вход пришел массив, прежде чем его форматировать
if (!Array.isArray(value)) {
console.warn(`[${this.getSettingKey()}] Attempted to set non-array value in TextArray component. Value:`, value);
// Можно установить пустой массив или выбросить ошибку?
// Установим пустой массив для безопасности UI.
this.textInput.setValue('[]');
return;
}
const jsonStringValue = this.arrayToJsonString(value as string[]);
this.textInput.setValue(jsonStringValue);
} else {
// Если компонент не был отрисован
throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`);
}
}
/**
* Выполняет базовую валидацию: проверяет, что строка в UI является
* валидным JSON-массивом строк по одному символу без дубликатов.
* Не проверяет на пустоту массива.
* @returns Объект ValidationError при ошибке, иначе null.
*/
validate(): ValidationError | null {
let rawValue: string;
try {
if (this.textInput) {
rawValue = this.textInput.getValue();
} else {
// Если UI нет, валидация невозможна
throw new Error("UI component not available for validation.");
}
// Вызываем внешний валидатор для проверки формата JSON и содержимого
const jsonError = validateJsonStringArray(rawValue);
if (jsonError) {
// Добавляем ключ поля к ошибке
return {
field: this.getSettingKey(),
message: jsonError.message
};
}
} catch (error: any) {
// Ловим ошибки доступа к UI
return {
field: this.getSettingKey(),
message: error instanceof Error ? error.message : String(error)
};
}
// Базовая валидация JSON и содержимого прошла успешно
return null;
}
}

View file

@ -0,0 +1,76 @@
import { Setting } from "obsidian";
import { CheckboxSyncPluginSettings } from "src/types";
import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent";
import { ValidationError } from "src/ui/validation/types";
import { validateNotEmptyArray } from "src/ui/validation/validators";
// ИЗМЕНЕНИЕ: Наследуемся от BaseTextArraySettingComponent
export class CheckedSymbolsSettingComponent extends BaseTextArraySettingComponent {
constructor() {
super(); // Вызываем конструктор родителя
}
// --- Реализация специфичных для этого компонента методов ---
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'checkedSymbols';
}
render(container: HTMLElement, currentValue: any): void {
// Используем унаследованный arrayToJsonString
const jsonStringValue = this.arrayToJsonString(currentValue as string[]);
this.setting = new Setting(container)
// Уникальные тексты для этого компонента
.setName("Checked Symbols")
.setDesc("Enter symbols as a JSON array of single-character strings. Example: [\"x\", \"✓\", \" \"]")
.addText(text => {
// Используем унаследованное поле textInput
this.textInput = text;
text
.setPlaceholder("e.g., [\"x\", \" \"]") // Уникальный плейсхолдер?
.setValue(jsonStringValue)
// Используем унаследованный onChangeCallback
.onChange(this.onChangeCallback);
});
}
// --- Переопределение validate для добавления проверки на пустоту ---
validate(): ValidationError | null {
// 1. Выполняем базовую валидацию JSON из родительского класса
const baseError = super.validate(); // Вызов BaseTextArraySettingComponent.validate()
if (baseError) {
// Если базовый JSON невалиден, сразу возвращаем ошибку
return baseError;
}
// 2. Если базовый JSON валиден, получаем распарсенное значение
let parsedValue: string[];
try {
// Вызов getValueFromUi из BaseTextArraySettingComponent
// Теперь это безопасно, так как super.validate() гарантирует валидный JSON
parsedValue = this.getValueFromUi();
} catch (error: any) {
// На всякий случай ловим ошибку, хотя не должны сюда попасть после super.validate()
return {
field: this.getSettingKey(),
message: error instanceof Error ? error.message : String(error)
};
}
// 3. Выполняем дополнительную проверку на непустой массив
const notEmptyError = validateNotEmptyArray(parsedValue);
if (notEmptyError) {
// Возвращаем ошибку "непустого массива", добавляя ключ поля
return {
field: this.getSettingKey(),
message: notEmptyError.message
};
}
// Все проверки (базовая + специфичная) пройдены
return null;
}
}

View file

@ -0,0 +1,38 @@
import { Setting } from "obsidian";
import { CheckboxSyncPluginSettings } from "src/types";
import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent";
export class IgnoreSymbolsSettingComponent extends BaseTextArraySettingComponent {
constructor() {
super();
}
// --- Реализация специфичных методов ---
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'ignoreSymbols';
}
render(container: HTMLElement, currentValue: any): void {
const jsonStringValue = this.arrayToJsonString(currentValue as string[]);
this.setting = new Setting(container)
.setName("Ignore Symbols")
.setDesc("Checkboxes with these symbols will be ignored during automatic parent/child state updates. Example: [\"-\", \"~\"]")
.addText(text => {
this.textInput = text;
text
.setPlaceholder("e.g., [\"-\", \"~\"]")
.setValue(jsonStringValue)
.onChange(this.onChangeCallback);
});
}
// validate() НЕ переопределяем. Используется базовая реализация
// из BaseTextArraySettingComponent, которая вызывает только
// validateJsonStringArray (проверка формата JSON и содержимого).
// Пустой массив здесь допустим.
// getValueFromUi, setValueInUi, arrayToJsonString унаследованы
}

View file

@ -0,0 +1,64 @@
import { Setting } from "obsidian";
import { CheckboxSyncPluginSettings } from "src/types";
import { BaseTextArraySettingComponent } from "src/ui/basedClasses/BaseTextArraySettingComponent";
import { ValidationError } from "src/ui/validation/types";
import { validateNotEmptyArray } from "src/ui/validation/validators";
export class UncheckedSymbolsSettingComponent extends BaseTextArraySettingComponent {
constructor() {
super();
}
// --- Реализация специфичных методов ---
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'uncheckedSymbols';
}
render(container: HTMLElement, currentValue: any): void {
const jsonStringValue = this.arrayToJsonString(currentValue as string[]);
this.setting = new Setting(container)
.setName("Unchecked Symbols")
.setDesc("Enter symbols as a JSON array of single-character strings. Example: [\" \", \"?\", \",\"]")
.addText(text => {
this.textInput = text;
text
.setPlaceholder("e.g., [\" \", \"?\", \",\"]")
.setValue(jsonStringValue)
.onChange(this.onChangeCallback);
});
}
// Переопределяем validate, чтобы добавить проверку на пустоту
validate(): ValidationError | null {
// 1. Базовая валидация JSON из родителя
const baseError = super.validate();
if (baseError) {
return baseError;
}
// 2. Получаем значение (теперь безопасно)
let parsedValue: string[];
try {
parsedValue = this.getValueFromUi();
} catch (error: any) {
return { field: this.getSettingKey(), message: error.message };
}
// 3. Дополнительная проверка на непустой массив
const notEmptyError = validateNotEmptyArray(parsedValue);
if (notEmptyError) {
return {
field: this.getSettingKey(),
message: notEmptyError.message
};
}
return null; // Все проверки пройдены
}
// getValueFromUi, setValueInUi, arrayToJsonString унаследованы
}

View file

@ -0,0 +1,72 @@
import { DropdownComponent, Setting } from "obsidian";
import { CheckboxSyncPluginSettings, CheckboxState } from "src/types";
import { BaseSettingComponent } from "src/ui/basedClasses/BaseSettingComponent";
import { ValidationError } from "src/ui/validation/types";
import { validateIsCheckboxState } from "src/ui/validation/validators";
export class UnknownPolicySettingComponent extends BaseSettingComponent {
private dropdownComponent: DropdownComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'unknownSymbolPolicy';
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
.setName('Unknown Symbol Policy')
.setDesc('How to treat symbols not in Checked, Unchecked or Ignore lists.')
.addDropdown(dropdown => {
this.dropdownComponent = dropdown;
dropdown
.addOption(CheckboxState.Checked, 'Treat as Checked')
.addOption(CheckboxState.Unchecked, 'Treat as Unchecked')
.addOption(CheckboxState.Ignore, 'Ignore')
.setValue(currentValue as string) // Dropdown работает со строками
.onChange(this.onChangeCallback);
});
}
getValueFromUi(): CheckboxState {
if (this.dropdownComponent) {
// Значение из dropdown - строка, кастуем к CheckboxState
return this.dropdownComponent.getValue() as CheckboxState;
}
throw new Error(`[${this.getSettingKey()}] Cannot get value from UI before component is rendered.`);
}
setValueInUi(value: any): void {
if (this.dropdownComponent) {
this.dropdownComponent.setValue(value as string);
} else {
throw new Error(`[${this.getSettingKey()}] Cannot set value before component is rendered.`);
}
}
validate(): ValidationError | null {
let valueFromUi: CheckboxState;
try {
valueFromUi = this.getValueFromUi();
} catch (error) {
return {
field: this.getSettingKey(),
message: error instanceof Error ? error.message : String(error)
};
}
// Вызываем внешний валидатор
const validationResult = validateIsCheckboxState(valueFromUi);
if (validationResult) {
return {
field: this.getSettingKey(),
message: validationResult.message
};
}
return null;
}
}

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

@ -0,0 +1,58 @@
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 EnableChildSyncSettingComponent extends BaseSettingComponent {
private toggleComponent: ToggleComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'enableAutomaticChildState';
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
.setName('Update child checkbox state automatically')
.setDesc('If enabled, changing the state of a parent checkbox will automatically update the state of all its direct and nested children. If disabled, changing a parent checkbox will not affect its children.')
.addToggle(toggle => {
this.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,72 @@
// src/settings/components/EnableFileSyncSettingComponent.ts
import { Setting, ToggleComponent } 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 EnableFileSyncSettingComponent extends BaseSettingComponent {
private toggleComponent: ToggleComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
// ИЗМЕНЕНИЕ: Другой ключ
return 'enableAutomaticFileSync';
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
// ИЗМЕНЕНИЕ: Другие тексты
.setName("Enable automatic file synchronization")
.setDesc("If enabled (requires restart or settings reload), automatically syncs checkbox states when files are loaded/opened and after settings changes. If disabled (default), sync only occurs when you manually change a checkbox.")
.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 {
let valueFromUi: boolean;
try {
valueFromUi = this.getValueFromUi(); // Получаем значение
} catch (error) {
// Если getValueFromUi выбросил ошибку (маловероятно до рендера)
return {
field: this.getSettingKey(),
message: error instanceof Error ? error.message : String(error)
};
}
const validationResult = validateValueIsBoolean(valueFromUi); // Используем внешний валидатор
if (validationResult) {
return {
field: this.getSettingKey(),
message: validationResult.message
};
}
return null;
}
}

View file

@ -0,0 +1,72 @@
import { Setting, ToggleComponent } 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 EnableParentSyncSettingComponent extends BaseSettingComponent {
private toggleComponent: ToggleComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'enableAutomaticParentState'; // Ключ этой настройки
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
.setName('Update parent checkbox state automatically')
.setDesc('If enabled, the state of a parent checkbox will be automatically updated based on the state of its children (all children checked = parent checked). If disabled, only manually changing a parent will affect its children.')
.addToggle(toggle => {
this.toggleComponent = toggle;
// Используем as boolean, т.к. знаем тип для этой настройки
toggle
.setValue(currentValue as boolean)
.onChange(this.onChangeCallback); // Просто передаем колбэк
});
}
getValueFromUi(): boolean {
if (this.toggleComponent) {
return this.toggleComponent.getValue();
}
// Если toggleComponent все еще не инициализирован (render не вызывался?)
// выбрасываем ошибку, сигнализируя о неправильном использовании
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()}] Attempted to set value before component is rendered.`);
}
}
validate(): ValidationError | null {
let valueFromUi: boolean;
try {
valueFromUi = this.getValueFromUi();
} catch (error) {
console.error(`Error getting value from UI for ${this.getSettingKey()}:`, error);
return {
field: this.getSettingKey(),
message: `Internal error getting value from UI: ${error instanceof Error ? error.message : String(error)}`
};
}
// Вызываем внешнюю чистую функцию валидации
const validationResult = validateValueIsBoolean(valueFromUi);
if (validationResult) {
return {
field: this.getSettingKey(),
message: validationResult.message
};
}
return null;
}
}

View file

@ -0,0 +1,52 @@
import { CheckboxSyncPluginSettings } from "src/types";
import { ValidationError } from "../validation/types";
/**
* Интерфейс для компонента, отвечающего за одну настройку в UI.
*/
export interface ISettingComponent {
/**
* Получает ключ настройки, за которую отвечает этот компонент.
* @returns Ключ из CheckboxSyncPluginSettings.
*/
getSettingKey(): keyof CheckboxSyncPluginSettings;
/**
* Рендерит UI-элементы для этой настройки в указанный контейнер.
* @param container - HTML-элемент, куда добавлять настройку.
* @param currentValue - Текущее сохраненное значение настройки.
*/
render(container: HTMLElement, currentValue: any): void;
/**
* Считывает значение из UI-элементов компонента.
* Может включать парсинг (например, JSON).
* @returns Текущее значение из UI.
* @throws Error если значение в UI некорректно для извлечения (например, невалидный JSON).
*/
getValueFromUi(): any;
/**
* Устанавливает значение в UI-элементы компонента.
* Может включать форматирование (например, JSON.stringify).
* @param value - Значение для установки в UI.
*/
setValueInUi(value: any): void;
/**
* Выполняет *индивидуальную* валидацию значения.
* **Важно:** Этот метод должен быть тестируемым и не зависеть от Obsidian API.
* Он принимает чистое значение и возвращает ошибку или null.
* @param value - Чистое значение для валидации (обычно результат getValueFromUi).
* @returns Объект ValidationError, если значение невалидно, иначе null.
*/
validate(): ValidationError | null;
/**
* Устанавливает колбэк, который будет вызван при изменении значения в UI.
* Используется для управления флагом isDirty.
* @param listener - Функция обратного вызова.
*/
setChangeListener(listener: () => void): void;
}

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

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

View file

@ -0,0 +1,78 @@
// src/settings/validation/SettingsValidator.ts
import { CheckboxSyncPluginSettings } from '../../types'; // Путь к типам
import { ValidationError } from './types'; // Путь к типам ошибок
/**
* Отвечает за валидацию настроек, требующую проверки нескольких полей (перекрестная валидация).
* **Важно:** Логика внутри методов не должна зависеть от Obsidian API для тестируемости.
*/
export class SettingsValidator {
/**
* Выполняет перекрестную валидацию предоставленных данных настроек.
* @param settingsData - Объект с текущими значениями настроек (или их частью).
* @returns Массив объектов ValidationError, если найдены ошибки, иначе пустой массив.
*/
public validate(settingsData: Partial<CheckboxSyncPluginSettings>): ValidationError[] {
const errors: ValidationError[] = [];
const checked = settingsData.checkedSymbols ?? [];
const unchecked = settingsData.uncheckedSymbols ?? [];
const ignore = settingsData.ignoreSymbols ?? [];
// --- Проверка пересечений ---
// Пересечение Checked и Unchecked
const intersection1 = this.findIntersection(checked, unchecked);
if (intersection1.length > 0) {
errors.push({
message: `Symbols found in both Checked and Unchecked lists: ${this.formatSymbolsForError(intersection1)}`
});
}
// Пересечение Checked и Ignore
const intersection2 = this.findIntersection(checked, ignore);
if (intersection2.length > 0) {
errors.push({
message: `Symbols found in both Checked and Ignore lists: ${this.formatSymbolsForError(intersection2)}`
});
}
// Пересечение Unchecked и Ignore
const intersection3 = this.findIntersection(unchecked, ignore);
if (intersection3.length > 0) {
errors.push({
message: `Symbols found in both Unchecked and Ignore lists: ${this.formatSymbolsForError(intersection3)}`
});
}
return errors;
}
/**
* Находит пересечение двух массивов строк.
* @param listA Первый массив.
* @param listB Второй массив.
* @returns Массив строк, присутствующих в обоих списках.
*/
private findIntersection(listA: string[], listB: string[]): string[] {
if (!listA || !listB) return []; // Защита от null/undefined
const setB = new Set(listB);
return listA.filter(symbol => setB.has(symbol));
}
/**
* Форматирует массив символов для вывода в сообщении об ошибке (как JSON).
* @param symbols Массив символов.
* @returns Строка JSON.
*/
private formatSymbolsForError(symbols: string[]): string {
try {
return JSON.stringify(symbols);
} catch (e) {
// На случай очень странных ошибок
return symbols.join(', ');
}
}
}

View file

@ -0,0 +1,8 @@
import { CheckboxSyncPluginSettings } from "src/types";
export interface ValidationError {
/** Ключ поля настройки, с которым связана ошибка (опционально). */
field?: keyof CheckboxSyncPluginSettings;
/** Сообщение об ошибке, понятное пользователю. */
message: string;
}

View file

@ -0,0 +1,95 @@
// src/settings/validation/validators.ts
import { CheckboxState } from "src/types";
/**
* Проверяет, является ли предоставленное значение булевым типом (boolean).
* Эта функция не зависит от контекста Obsidian или конкретного компонента.
*
* @param value - Значение любого типа для проверки.
* @returns Объект с сообщением об ошибке, если значение не является boolean, иначе null.
*/
export function validateValueIsBoolean(value: any): { message: string } | null {
if (typeof value !== 'boolean') {
return { message: 'Value must be a boolean.' };
}
return null;
}
/**
* Проверяет, является ли значение допустимым состоянием CheckboxState.
* @param value Значение для проверки.
* @returns Объект с сообщением об ошибке, если значение недопустимо, иначе null.
*/
export function validateIsCheckboxState(value: any): { message: string } | null {
// Получаем все возможные значения из enum CheckboxState
const validStates = Object.values(CheckboxState);
if (validStates.includes(value as CheckboxState)) {
return null; // Значение допустимо
} else {
return { message: `Invalid checkbox state selected. Must be one of: ${validStates.join(', ')}.` };
}
}
/**
* Проверяет, является ли строка валидным JSON-массивом строк,
* где каждая строка состоит ровно из одного символа.
* Также проверяет на уникальность символов.
* @param rawJson Строка для проверки.
* @returns Объект с сообщением об ошибке, если невалидно, иначе null.
*/
export function validateJsonStringArray(rawJson: string | null | undefined): { message: string } | null {
if (rawJson == null || typeof rawJson !== 'string') {
return { message: "Input must be a string." }; // Или можно вернуть null, если пустая строка допустима как пустой массив? Зависит от требований.
}
const trimmedValue = rawJson.trim();
// Считаем пустую строку валидным представлением пустого массива []
if (trimmedValue === '' || trimmedValue === '[]') {
return null;
}
let parsed: any;
try {
parsed = JSON.parse(trimmedValue);
} catch (e: any) {
return { message: `Invalid JSON format: ${e.message}` };
}
if (!Array.isArray(parsed)) {
return { message: "Invalid format: Input must be a valid JSON array (e.g., [\"x\", \" \"])." };
}
const symbols = new Set<string>();
for (let i = 0; i < parsed.length; i++) {
const element = parsed[i];
if (typeof element !== 'string') {
return { message: `Invalid content: Array element at index ${i} is not a string.` };
}
if (element.length !== 1) {
return { message: `Invalid content: Element "${element}" at index ${i} must be a single character.` };
}
if (symbols.has(element)) {
return { message: `Invalid content: Duplicate symbol "${element}" found.` };
}
symbols.add(element);
}
return null; // Все проверки пройдены
}
/**
* Проверяет, является ли предоставленный массив непустым.
* @param arr Массив для проверки.
* @returns Объект с сообщением об ошибке, если массив пуст или не является массивом, иначе null.
*/
export function validateNotEmptyArray(arr: any): { message: string } | null {
// Добавим проверку, что это действительно массив
if (!Array.isArray(arr)) {
return { message: 'Value must be an array.' }; // Ошибка типа данных
}
if (arr.length === 0) {
return { message: 'The list cannot be empty.' };
}
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"],
}