mirror of
https://github.com/groldsf/obsidian_check_plugin.git
synced 2026-07-22 05:37:48 +00:00
Compare commits
120 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b0154ca6 | ||
|
|
c6b111ac4c | ||
|
|
32dcf4eff0 | ||
|
|
e701cd6828 | ||
|
|
9755034870 | ||
|
|
b685655e51 | ||
|
|
235f1cb942 | ||
|
|
0276070445 | ||
|
|
cf6b3d4b80 | ||
|
|
c64f943ca8 | ||
|
|
93bb79bb3e | ||
|
|
6e40414083 | ||
|
|
2be5325eaf | ||
|
|
0e5b863a01 | ||
|
|
96727c61f5 | ||
|
|
a7f33e676d | ||
|
|
b0d58d6447 | ||
|
|
273495e9bb | ||
|
|
93597b2626 | ||
|
|
0b797bed5a | ||
|
|
a851cc8428 | ||
|
|
5115988918 | ||
|
|
b18acc10b1 | ||
|
|
392acb7e17 | ||
|
|
9398dc67ec | ||
|
|
a7cfbd70df | ||
|
|
e5f3318809 | ||
|
|
683b358053 | ||
|
|
8ac4b60509 | ||
|
|
f3f0685bdc | ||
|
|
a69f23159a | ||
|
|
77653389f3 | ||
|
|
775940a664 | ||
|
|
a453a07b18 | ||
|
|
e9715b71ad | ||
|
|
4bcb4a168a | ||
|
|
88809f104b | ||
|
|
595bb8194e | ||
|
|
d23be52814 | ||
|
|
bbbef6f9c8 | ||
|
|
76677067fa | ||
|
|
9c26ad2c27 | ||
|
|
4f70f30987 | ||
|
|
da1168d343 | ||
|
|
2809d15aa7 | ||
|
|
9458bb0407 | ||
|
|
846c266e99 | ||
|
|
d5f26a340b | ||
|
|
3011e52514 | ||
|
|
28ebd1ddf0 | ||
|
|
4f9c5ab5e3 | ||
|
|
4b654709af | ||
|
|
71246acc85 | ||
|
|
0ac47fa4df | ||
|
|
9e8b315bff | ||
|
|
acc7dd8d8a | ||
|
|
72c174c89f | ||
|
|
9764ce2962 | ||
|
|
eab303a9b7 | ||
|
|
560b184f14 | ||
|
|
e65dd50548 | ||
|
|
252c0ff8b7 | ||
|
|
a694453dcf | ||
|
|
6756a4a8a7 | ||
|
|
89f3b56871 | ||
|
|
ae8740b3f9 | ||
|
|
6014663ac0 | ||
|
|
b025aabea7 | ||
|
|
eef7023804 | ||
|
|
eb5e52ca1f | ||
|
|
cdcf3a9db3 | ||
|
|
400ce20193 | ||
|
|
d4e23856c1 | ||
|
|
7c78611f1c | ||
|
|
840a50ae41 | ||
|
|
0f4b53f8dd | ||
|
|
5074bf6464 | ||
|
|
d1a76ae87c | ||
|
|
ef380a57bc | ||
|
|
ef0b3017d7 | ||
|
|
c7e2e3835c | ||
|
|
8e089baee4 | ||
|
|
b4a789f04b | ||
|
|
e992b94c0b | ||
|
|
46dcb29109 | ||
|
|
d905f92969 | ||
|
|
78bc89c129 | ||
|
|
5a18eb2db8 | ||
|
|
17a5ad0938 | ||
|
|
51588d4a85 | ||
|
|
fd5653cafb | ||
|
|
ade67c54ba | ||
|
|
7068d32440 | ||
|
|
63dac704f5 | ||
|
|
01ed090ad6 | ||
|
|
f567160324 | ||
|
|
2a0a12bce0 | ||
|
|
03ee45cc1c | ||
|
|
e044691186 | ||
|
|
975cac157b | ||
|
|
25578447ea | ||
|
|
7b41faa6f8 | ||
|
|
9a9028d363 | ||
|
|
3c38362331 | ||
|
|
3d1ea00009 | ||
|
|
c751add5fe | ||
|
|
198adf253f | ||
|
|
32a5fe4229 | ||
|
|
d3e7b14593 | ||
|
|
13ab4da8e7 | ||
|
|
b9a9d7b73c | ||
|
|
1f00148d53 | ||
|
|
ac25654f65 | ||
|
|
89e4089cbc | ||
|
|
c9f50f1894 | ||
|
|
5649eacab0 | ||
|
|
6decbc8e78 | ||
|
|
bb609c2685 | ||
|
|
e2e6c47920 | ||
|
|
06b85267d8 |
87 changed files with 7483 additions and 630 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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```)
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
57
.github/pull_request_template.md
vendored
Normal 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. -->
|
||||
58
.github/workflows/tg_notify.yml
vendored
58
.github/workflows/tg_notify.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ data.json
|
|||
.DS_Store
|
||||
|
||||
coverage/
|
||||
test-report.html
|
||||
31
CODE_OF_CONDUCT.md
Normal file
31
CODE_OF_CONDUCT.md
Normal 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
163
README.md
|
|
@ -1,148 +1,47 @@
|
|||
|
||||
# Checkbox Sync
|
||||
# Checkbox Sync for Obsidian
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/groldsf/obsidian_check_plugin/releases)
|
||||
[](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:
|
||||
|
||||

|
||||
|
||||
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', считаются выполненными. Когда отключен, любой символ внутри чекбокса (кроме пробела) считается отметкой о выполнении.
|
||||
|
||||
### Использование
|
||||
|
||||
После активации плагина, он автоматически отслеживает чекбоксы в ваших заметках. Пример использования:
|
||||
|
||||

|
||||
|
||||
В данном примере, как только оба дочерних чекбокса будут отмечены, плагин автоматически отметит родительский чекбокс. Если хотя бы один дочерний чекбокс не отмечен, родительский сбросится.
|
||||
### Установка
|
||||
|
||||
#### Ручная установка
|
||||
|
||||
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
13
SECURITY.md
Normal 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.
|
||||
189
__tests__/FileFilter.test.ts
Normal file
189
__tests__/FileFilter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
234
__tests__/TextSyncPipeline.test.ts
Normal file
234
__tests__/TextSyncPipeline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
932
__tests__/checkboxUtils.base.test.ts
Normal file
932
__tests__/checkboxUtils.base.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
383
__tests__/checkboxUtils.plainLists.test.ts
Normal file
383
__tests__/checkboxUtils.plainLists.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
355
__tests__/core/CheckboxUtils2.test.ts
Normal file
355
__tests__/core/CheckboxUtils2.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
35
__tests__/fakes/InMemoryFilePathStateHolder.ts
Normal file
35
__tests__/fakes/InMemoryFilePathStateHolder.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
78
__tests__/ui/components/validation/SettingsValidator.test.ts
Normal file
78
__tests__/ui/components/validation/SettingsValidator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
173
__tests__/ui/components/validation/validators.test.ts
Normal file
173
__tests__/ui/components/validation/validators.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
5
docs/Integration_with_Tasks_plugin.md
Normal file
5
docs/Integration_with_Tasks_plugin.md
Normal 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
17
docs/_config.yml
Normal 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
BIN
docs/assets/showcase.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 377 KiB |
BIN
docs/assets/showcase2.gif
Normal file
BIN
docs/assets/showcase2.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 983 KiB |
79
docs/changelog.md
Normal file
79
docs/changelog.md
Normal 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
40
docs/contributing.md
Normal 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
56
docs/index.md
Normal 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
38
docs/installation.md
Normal 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
24
docs/roadmap.md
Normal 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
140
docs/settings.md
Normal 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
62
docs/usage.md
Normal 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.
|
||||
78
esbuild-debug-wrap-plugin.mjs
Normal file
78
esbuild-debug-wrap-plugin.mjs
Normal 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;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -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
BIN
img/showcase2.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 983 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
1113
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
54
src/FileFilter.ts
Normal 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
84
src/FileStateHolder.ts
Normal 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
3
src/GlobalVars.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class GlobalVars{
|
||||
static APP:any;
|
||||
}
|
||||
16
src/IFilePathStateHolder.ts
Normal file
16
src/IFilePathStateHolder.ts
Normal 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
89
src/SyncController.ts
Normal 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
40
src/TextSyncPipeline.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
40
src/core/CheckboxUtils2.ts
Normal file
40
src/core/CheckboxUtils2.ts
Normal 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
3
src/core/Regexp.ts
Normal 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
248
src/core/checkboxUtils.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
5
src/core/interface/CheckboxProcess.ts
Normal file
5
src/core/interface/CheckboxProcess.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Context } from "../model/Context";
|
||||
|
||||
export interface CheckboxProcess {
|
||||
process(context: Context): void;
|
||||
}
|
||||
3
src/core/interface/ICheckboxUtils.ts
Normal file
3
src/core/interface/ICheckboxUtils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface ICheckboxUtils {
|
||||
syncText(text: string, textBefore: string | undefined): string;
|
||||
}
|
||||
75
src/core/model/Context.ts
Normal file
75
src/core/model/Context.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
156
src/core/model/ContextFactory.ts
Normal file
156
src/core/model/ContextFactory.ts
Normal 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
12
src/core/model/Line.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { CheckboxState } from "src/types";
|
||||
|
||||
export interface Line {
|
||||
getIndent(): number;
|
||||
|
||||
getText(): string;
|
||||
|
||||
toResultText(): string;
|
||||
|
||||
getState(): CheckboxState;
|
||||
|
||||
}
|
||||
63
src/core/model/TreeNode.ts
Normal file
63
src/core/model/TreeNode.ts
Normal 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
32
src/core/model/View.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
44
src/core/model/line/AbstractLine.ts
Normal file
44
src/core/model/line/AbstractLine.ts
Normal 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;
|
||||
}
|
||||
142
src/core/model/line/CheckboxLine.ts
Normal file
142
src/core/model/line/CheckboxLine.ts
Normal 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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
43
src/core/model/line/ListLine.ts
Normal file
43
src/core/model/line/ListLine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/core/model/line/TextLine.ts
Normal file
26
src/core/model/line/TextLine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/core/process/PropagateStateToChildrenProcess.ts
Normal file
52
src/core/process/PropagateStateToChildrenProcess.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/core/process/PropagateStateToParentProcess.ts
Normal file
71
src/core/process/PropagateStateToParentProcess.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
45
src/events/FileChangeEventHandler.ts
Normal file
45
src/events/FileChangeEventHandler.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
123
src/events/FileLoadEventHandler.ts
Normal file
123
src/events/FileLoadEventHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/main.ts
107
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
31
src/types.ts
31
src/types.ts
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
316
src/ui/CheckboxSyncPluginSettingTab.ts
Normal file
316
src/ui/CheckboxSyncPluginSettingTab.ts
Normal 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
67
src/ui/ErrorDisplay.ts
Normal 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
56
src/ui/SettingGroup.ts
Normal 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
122
src/ui/SettingsControls.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/ui/basedClasses/BaseSettingComponent.ts
Normal file
40
src/ui/basedClasses/BaseSettingComponent.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
130
src/ui/basedClasses/BaseTextArraySettingComponent.ts
Normal file
130
src/ui/basedClasses/BaseTextArraySettingComponent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 унаследованы
|
||||
}
|
||||
|
|
@ -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 унаследованы
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
57
src/ui/components/devGroup/EnableDebug.ts
Normal file
57
src/ui/components/devGroup/EnableDebug.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
122
src/ui/components/pathGroup/PathGlobsSettingComponent.ts
Normal file
122
src/ui/components/pathGroup/PathGlobsSettingComponent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
52
src/ui/interfaces/ISettingComponent.ts
Normal file
52
src/ui/interfaces/ISettingComponent.ts
Normal 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
126
src/ui/modals.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
78
src/ui/validation/SettingsValidator.ts
Normal file
78
src/ui/validation/SettingsValidator.ts
Normal 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(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ui/validation/types.ts
Normal file
8
src/ui/validation/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { CheckboxSyncPluginSettings } from "src/types";
|
||||
|
||||
export interface ValidationError {
|
||||
/** Ключ поля настройки, с которым связана ошибка (опционально). */
|
||||
field?: keyof CheckboxSyncPluginSettings;
|
||||
/** Сообщение об ошибке, понятное пользователю. */
|
||||
message: string;
|
||||
}
|
||||
95
src/ui/validation/validators.ts
Normal file
95
src/ui/validation/validators.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue