diff --git a/docs/changelog.md b/docs/changelog.md index a740e670..e6ca5609 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [Unreleased] + +- RTL support https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/335 +- Fixed notes selection when all notes are reviewed. [`#548`](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/548) +- Feat 878 Refactor code to support diff methods of storing the scheduling info, and diff SR algorithms https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/1006 + #### [1.12.4](https://github.com/st3v3nmw/obsidian-spaced-repetition/compare/1.12.3...1.12.4) - chore: fix package manager issue in CI [`#939`](https://github.com/st3v3nmw/obsidian-spaced-repetition/pull/939) diff --git a/docs/en/flashcards.md b/docs/en/flashcards.md index afabfdc2..8bfc5b1e 100644 --- a/docs/en/flashcards.md +++ b/docs/en/flashcards.md @@ -154,6 +154,24 @@ The plugin will automatically search for folders that contain flashcards & use t This is an alternative to the tagging option and can be enabled in settings. +## RTL Support + +There are two ways that the plugin can be used with RTL languages, such as Arabic, Hebrew, Persian (Farsi). + +If all cards are in a RTL language, then simply enable the global Obsidian option `Editor → Right-to-left (RTL)`. + +If all cards within a single note have the same LTR/RTL direction, then frontmatter can be used to specify the text direction. For example: + +``` +--- +direction: rtl +--- +``` + +This is the same way text direction is specified to the `RTL Support` plugin. + +Note that there is no current support for cards with different text directions within the same note. + ## Reviewing Once done creating cards, click on the flashcards button on the left ribbon to start reviewing the flashcards. After a card is reviewed, a HTML comment is added containing the next review day, the interval, and the card's ease. diff --git a/docs/zh/algorithms.md b/docs/zh/algorithms.md new file mode 100644 index 00000000..81552691 --- /dev/null +++ b/docs/zh/algorithms.md @@ -0,0 +1,30 @@ +# 算法 + +## SM-2 + +!!! 警告 + + 该条目长时间未更新, + 请注意阅读 [源代码](https://github.com/st3v3nmw/obsidian-spaced-repetition/blob/master/src/scheduling.ts). + +(除 PageRanks 之外,卡片复习采用相同规划算法) + +- 该算法为 [Anki](https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html) 所采用的基于 [SM-2 算法](https://www.supermemo.com/en/archives1990-2015/english/ol/sm2) 的变种。 +- 使用三级打分制,即在复习阶段自评对某个概念的掌握程度为`困难`,`记得`或`简单`。 +- 初始熟练度会根据链接笔记的平均熟练度、当前笔记的重要性和基本熟练度进行加权(使用 最大外链因子)。 + - `当存在外链时: 初始熟练度 = (1 - 链接加权) * 基础熟练度 + 链接加权 * 外链平均熟练度` + - `链接加权 = 最大外链因子 * min(1.0, log(外链数目 + 0.5) / log(64))` (以自适应不同情况) + - 不同概念/笔记的优先级由 PageRank 算法设定(笔记之间存在轻重缓急) + - 大多数情况下基础概念/笔记具有更高优先级 +- 当用户对某个概念/笔记的自评为: + - 简单, 熟练度增加 `20` 复习间隔更新为 `原复习间隔 * 更新后熟练度 / 100 * 1.3` (1.3 是简单奖励) + - 记得, 熟练度不变,复习间隔更新为 `原复习间隔 * old_ease / 100` + - 困难, 熟练度降低 `20`,复习间隔更新为 `原复习间隔 * 0.5` + - `0.5` 可在设置中更改 + - `最小熟练度 = 130` + - 当复习间隔不小于 `8` 天时 + - `间隔 += 随机取值({-扰动, 0, +扰动})` + - 设定 `扰动 = 向上取整(0.05 * 间隔)` + - [Anki 文档](https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html): + > "[...] Anki 还会加入少量的随机扰动,以防止同时出现且评级相同的卡片获得相同的复习周期,导致其它们是在同一天被复习。" +- 复习规划信息将被存储于笔记的yaml front matter部分 diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md new file mode 100644 index 00000000..bc89ca07 --- /dev/null +++ b/docs/zh/contributing.md @@ -0,0 +1,156 @@ +# Contributing + +First off, thanks for wanting to contribute to the Spaced Repetition plugin! + +## Bug Reports & Feature Requests + +- Check the [roadmap](https://github.com/st3v3nmw/obsidian-spaced-repetition/projects/2/) for upcoming features & fixes. +- Raise an issue [here](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/) if you have a feature request or a bug report. +- Visit the [discussions](https://github.com/st3v3nmw/obsidian-spaced-repetition/discussions/) section for Q&A help, feedback, and general discussion. + +## Translating + +### Steps + +To help translate the plugin to your language: + +1. Fork the [repository](https://github.com/st3v3nmw/obsidian-spaced-repetition). +2. Copy the entries from `src/lang/locale/en.ts` to the proper file in `src/lang/locale/` (i.e. `fr.ts` for French, or `sw.ts` for Swahili). The locale codes are [IETF language tags](https://en.wikipedia.org/wiki/IETF_language_tag). +3. Translate, +4. Then open a pull request, + +### Example + +Sample `en.ts` file: + +```typescript +// English + +export default { + EASY: "Easy", + SHOW_ANSWER: "Show Answer", + DAYS_STR_IVL: "${interval} days", + CHECK_ALGORITHM_WIKI: + 'For more information, check the algorithm implementation.', +}; +``` + +Equivalent `sw.ts` file: + +```typescript +// Swahili + +export default { + EASY: "Rahisi", + SHOW_ANSWER: "Onyesha Jibu", + DAYS_STR_IVL: "Siku ${interval}", + CHECK_ALGORITHM_WIKI: + 'Kwa habari zaidi, angalia utekelezaji wa algorithm.', +}; +``` + +A part of that last one is uhh, Google translated, I have a working understanding of Swahili but not enough to write computerese lol. + +Please note that: + +1. Only the strings(templates) on the right of the key should be translated. +2. Text inside `${}` isn't translated. This is used to replace variables in code. For instance, if interval = 4, it becomes `4 days` in English & `Siku 4` in Swahili. Quite nifty if you ask me. + +## Code + +1. Make your changes. +2. Run `pnpm dev` to test the changes inside Obsidian. +3. You could create symbolic links between the build files and the Obsidian vault, example: + + ```bash + # remove existing files in the Obsidian vault + rm ~/notes/.obsidian/plugins/obsidian-spaced-repetition/main.js ~/notes/.obsidian/plugins/obsidian-spaced-repetition/manifest.json ~/notes/.obsidian/plugins/obsidian-spaced-repetition/styles.css + # use absolute paths + ln -s /home/stephen/obsidian-spaced-repetition/build/main.js /home/stephen/notes/.obsidian/plugins/obsidian-spaced-repetition + ln -s /home/stephen/obsidian-spaced-repetition/manifest.json /home/stephen/notes/.obsidian/plugins/obsidian-spaced-repetition + ln -s /home/stephen/obsidian-spaced-repetition/styles.css /home/stephen/notes/.obsidian/plugins/obsidian-spaced-repetition + ``` + + - This can be coupled with the [Hot Reload plugin](https://github.com/pjeby/hot-reload) + +4. Document the "user-facing" changes e.g. new feature, UI change, etc. +5. If your "business logic" is properly decoupled from Obsidian APIs, write some unit tests. + - This project uses [jest](https://jestjs.io/), tests are stored in `tests/`. + - `pnpm test` +6. Add your change to the `[Unreleased]` section of the changelog (`docs/changelog.md`). + - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), TL;DR: + - `Added` for new features. + - `Changed` for changes in existing functionality. + - `Deprecated` for soon-to-be removed features. + - `Removed` for now removed features. + - `Fixed` for any bug fixes. + - `Security` in case of vulnerabilities. + - You can also append a link to your GitHub profile, example: + - `Make flashcard text selectable [@st3v3nmw](https://github.com/st3v3nmw)` +7. Before pushing your changes, run the linter: `pnpm lint` + - Format the code in case any warnings are raised: `pnpm format` +8. Open the pull request. + +## Documentation + +The documentation consists of Markdown files which [MkDocs](https://www.mkdocs.org/) converts to static web pages. +Specifically, this project uses [MkDocs Material](https://squidfunk.github.io/mkdocs-material/getting-started/). + +These files reside in `docs/` in the respective language's folder. For instance, English docs are located in `docs/en/`. + +The docs are served on [https://www.stephenmwangi.com/obsidian-spaced-repetition/](https://www.stephenmwangi.com/obsidian-spaced-repetition/). + +For small changes, you can simply open an pull request for merging (against the `master` branch). +The changes will be live once a new [release](https://github.com/st3v3nmw/obsidian-spaced-repetition/releases) is made. + +For larger diffs, it's important that you check how your docs look like as explained below. + +### Viewing Docs Locally + +#### Initial Setup + +1. Create a virtual environment: `python3 -m venv venv` +2. Activate it: `. venv/bin/activate` +3. Install the required dependencies: `pip install -r requirements.txt` + +#### Viewing + +1. Activate the virtual environment: `. venv/bin/activate` +2. Serve the docs: `mkdocs serve` +3. View your documentation locally on [http://127.0.0.1:8000/obsidian-spaced-repetition/](http://127.0.0.1:8000/obsidian-spaced-repetition/), any changes you make will reflect on the browser instantly. + +### Translating Documentation + +1. Create a folder for your language in `docs/` if it doesn't exist. Use the language codes provided [here](https://squidfunk.github.io/mkdocs-material/setup/changing-the-language/#site-language). +2. Add the code from (1) to the MkDocs configuration (`mkdocs.yml` - `plugins.i18n.languages`). +3. Copy the files from the English (`en`) folder into the new folder. +4. Translate then open a pull request. + +## Maintenance + +### Releases + +Example using `v1.9.2`: + +1. Create a new branch: `git switch -c release-v1.9.2` +2. Bump the plugin version in `manifest.json` and `package.json` (following [Semantic Versioning](https://semver.org/spec/v2.0.0.html)). + - Semantic Versioning TL;DR, given a version number `MAJOR.MINOR.PATCH`, increment the: + - `MAJOR` version when you make incompatible API changes + - `MINOR` version when you add functionality in a backwards compatible manner + - `PATCH` version when you make backwards compatible bug fixes + - If the new version uses new Obsidian APIs, update `minAppVersion` and `versions.json` to reflect this. +3. Run `pnpm changelog` to update the CHANGELOG. +4. Commit and push the changes: + + ```bash + git add . + git commit -m "Bump version to v1.9.2" + git push --set-upstream origin release-v1.9.2 + ``` + +5. Open and merge the PR into `master`. +6. Locally, switch back to `master` and pull the changes: `git switch master && git pull` +7. Create a git tag with the version: `git tag 1.9.2` +8. Push the tag: `git push --tags`.
You're all set! [This GitHub action](https://github.com/st3v3nmw/obsidian-spaced-repetition/blob/master/.github/workflows/release.yml) should pick it up, create a release, publish it, and update the live documentation. + +[^1]: Check the Obsidian Tasks project which has [excellent contribution guidelines](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md). diff --git a/docs/zh/flashcards.md b/docs/zh/flashcards.md new file mode 100644 index 00000000..273293f8 --- /dev/null +++ b/docs/zh/flashcards.md @@ -0,0 +1,240 @@ +# 卡片 + +## 新建卡片 + +[Piotr Wozniak的知识标准化二十守则](https://supermemo.guru/wiki/20_rules_of_knowledge_formulation) 是创建复习卡片时的良好入门指南。 + +### 单行基础卡片 (Remnote风格) + +问题和答案以 `::` 分隔(可在设置中更改)。 + +```markdown +这是问题::这是答案 +``` + +### 单行双向卡片 + +创建 `正:::反` 与其反向卡片 `反:::正`. + +问题和答案以 `:::` 分隔(可在设置中更改)。 + +```markdown +这是问题:::这是答案 +``` + +注意:初次复习时插件会同时展示正向和反向卡片。 +如果打开 **将关联卡片隐藏至下一天?** 将仅展示正向卡片。 + +### 多行基础卡片 + +卡片的正反面以 `?` 分隔(可在设置中更改)。 + +```markdown +多行卡片的正面 +? +多行卡片的反面 +``` + +只要正反面字段都在 `?` 的作用域内,卡片内容可以跨越多行: + +```markdown +顾名思义 +多行卡片的内容 +可以跨越多行 +? +这也包括 +卡片的反面 +``` + +### 多行双向卡片 + +创建 `正??反` 与其反向卡片 `反??正`. + +卡片的正反面以 `??` 分隔(可在设置中更改)。 + +```markdown +多行卡片的正面 +?? +多行卡片的反面 +``` + +只要正反面字段都在 `??` 的作用域内,卡片内容可以跨越多行: + +```markdown +顾名思义 +多行卡片的内容 +可以跨越多行 +?? +这也包括 +卡片的反面 +``` + +注意:其隐藏机制同单行双向卡片 + +### 填空卡片 + +你可以轻松使用 `==高亮==` ,`**加粗**` ,或 `{{花括号}}` 创建挖空卡片. + +该特性可在设置中开关。 + +暂不支持 Anki 风格的 `{{c1:This text}} would {{c2:generate}} {{c1:2 cards}}` 挖空语法。该特性正在 [计划中](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/93/)。 + +## 卡组 + +![Screenshot from 2021-06-05 19-28-24](https://user-images.githubusercontent.com/43380836/120922211-78603400-c6d0-11eb-9d09-bdd5df1c9112.png) + +卡组名称右边的绿色和蓝色数字分别表示到期卡片和新卡片数目。 + +### 使用 Obsidian 标签 + +1. 在设置中设定制卡标签 (默认为 `#flashcards`)。 +2. 将您想要制卡的笔记打上该标签。 + +#### 标签层级 + +注意 `#flashcards` 可以匹配嵌套标签例如 `#flashcards/subdeck/subdeck`. + +#### 单个文件包含多个标签 + +单一文件中可以包含不同卡组的多个卡片内容。 + +这是因为一个标签的作用域直到下一个标签出现才会结束。 + +例如: + +```markdown +#flashcards/deckA +Question1 (in deckA)::Answer1 +Question2 (also in deckA)::Answer2 +Question3 (also in deckA)::Answer3 + +#flashcards/deckB +Question4 (in deckB)::Answer4 +Question5 (also in deckB)::Answer5 + +#flashcards/deckC +Question6 (in deckC)::Answer6 +``` + +#### 多个卡组包含同一卡片 + +通常情况下一张卡片只会出现在一个卡组。然而某些时候,一张卡片无法被恰当地归入单一卡组的层级结构中。 + +这种情况下,卡片可以被标记为归属为多个卡组。比如下面这张卡片属于三个卡组。 + +```markdown +#flashcards/language/words #flashcards/trivia #flashcards/learned-from-tv +A group of cats is called a::clowder +``` + +注意,在上面的例子中所有标签必须位于一行并以空格隔开。 + +#### 作用于特定问答的卡片 + +位于卡片内容同一行开头处的标签是「仅限当前问答」的。 + +例如: + +```markdown +#flashcards/deckA +Question1 (in deckA)::Answer1 +Question2 (also in deckA)::Answer2 +Question3 (also in deckA)::Answer3 + +#flashcards/deckB Question4 (in deckB)::Answer4 + +Question6 (in deckA)::Answer6 +``` + +此处 `Question6` 将出现在 `deckA` 但不会出现于 `deckB` 因 `deckB` 是仅作用于 `Question4` 的标签。 + +### 使用目录结构 + +插件将自动遍历目录结构并依次创建卡组和子卡组,例如 `Folder/sub-folder/sub-sub-folder` ⇔ `Deck/sub-deck/sub-sub-deck`。 + +这是使用标签指定卡组的替代方案,可以在设置中打开。 + +## 复习 + +制卡完成后即可在左边栏中点击图标开始复习。当一张卡片被复习后,将会被附上一个包含下一次复习时间、复习间隔和熟练度的HTML注释。 + +``` + +``` + +HTML注释在笔记预览页面中不可见。对于单行卡片,你可以在设置中选择让这个标签位于同一行还是另起一行。放置在同一行可以防止破坏markdown的列表渲染效果。 + +注意您可以按 `S` 跳过一张卡片(大小写不敏感)。 + +!!! 提示 + + 如果您在移动设备上遇到了悬浮框尺寸的问题,进入设置并将 _Flashcard Height Percentage_ 和 _Flashcard Width Percentage_ + 设为 100% 以适应屏幕。 + +### 快速复习 + +你可以在快速复习中使用如下快捷键: + +- `Space/Enter` => 显示答案 +- `0` => 重置进度 (等价于 Anki 中的 `Again`) +- `1` => 标记为 `Hard` +- `2` 或 `Space` => 标记为 `Good` +- `3` => 标记为 `Easy` + +### 上下文 + +如用于制卡的部分位于笔记标题之下,则卡片中会附加一个上下文标题。 + +例如: + +```markdown +#flashcards + +# Trivia + +## Capitals + +### Africa + +Kenya::Nairobi + +### North America + +Canada::Ottawa +``` + +卡片 `Kenya::Nairobi` 将会被附上 `Trivia > Capitals > Africa` 作为上下文标题而卡片 `Canada::Ottawa` 将会被附上 `Trivia > Capitals > North America` 作为上下文标题。 + +### 删除卡片 + +要删除一个卡片,只需删除复习规划标签和卡片相关文本。 + +### 忽略卡片 + +你可以使用诸如 ` -->` 的HTML标签来将其从复习队列中移除。你可以随时移除该标签。 + +## 集中复习 + +当前仅支持使用 集中复习此笔记中的卡片 命令。将复习所有卡组中来自该笔记的卡片。 + +## 数据统计 + +统计页面可以使用 `View Statistics` 命令打开。 + +### 预估 + +计算将要到期的卡片数量。 + + + +### 复习间隔 + +统计卡片再次出现的时间间隔。 + +### 熟练度 + +统计卡片熟练度。 + +### 卡片类型 + +统计卡片类型:新卡片,较新卡片, 熟悉卡片(复习间隔超过一个月)。 diff --git a/docs/zh/index.md b/docs/zh/index.md new file mode 100644 index 00000000..d27ab167 --- /dev/null +++ b/docs/zh/index.md @@ -0,0 +1,53 @@ +# Obsidian Spaced Repetition + + + +Fight the forgetting curve & note aging by reviewing flashcards & notes using spaced repetition on Obsidian.md + +- 阅读 [文档](https://www.stephenmwangi.com/obsidian-spaced-repetition/). +- 查看新特性和故障修复 [规划](https://github.com/st3v3nmw/obsidian-spaced-repetition/projects/2/) +- 如果您有新建议或故障报告,请提出 [请求](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/) +- 访问 [讨论](https://github.com/st3v3nmw/obsidian-spaced-repetition/discussions/) 板块以获取问答帮助,意见反馈 +- 感谢Obsidian社区 😄 的贡献,本插件已支持 _Arabic / العربية, Chinese (Simplified) / 简体中文, Chinese (Traditional) / 繁體中文, Czech / čeština, German / Deutsch, Italian / Italiano, Korean / 한국어, Japanese / 日本語, Polish / Polski, Portuguese (Brazil) / Português do Brasil, Spanish / Español, and Russian / русский_ + - 如果您愿意提供翻译上的帮助,请阅读 [翻译指南](https://www.stephenmwangi.com/obsidian-spaced-repetition/contributing/#translating_1). + +## 视频演示 + + + +## 安装 + +在Obsidian搜索社区插件-`Spaced Repetition`. + +### 手动安装 + +在您的Obsidian仓库中的 `.obsidian/plugins` 下创建 `obsidian-spaced-repetition` 目录。将 [最新发布的软件包](https://github.com/st3v3nmw/obsidian-spaced-repetition/releases) 解压并移动 `main.js`, `manifest.json` 和 `styles.css` 到该目录下。 + +## 相关资源 + +### YouTube 教程 + +#### 卡片 + +- [PRODUCTIVELY Learning New Things Using Obsidian by @FromSergio](https://youtu.be/DwSNZEW6jCU) + +#### 笔记 + +##### 渐进式写作 + +- [Obsidian: inbox review with spaced repetition by @aviskase](https://youtu.be/zG5r7QIY_TM) +- [Разгребатель инбокса заметок как у Andy Matuschak в Obsidian by @YuliyaBagriy_ru](https://youtu.be/CF6SSHB74cs) + +### 间隔重复系统 + +- [How to Remember Anything Forever-Ish by Nicky Case](https://ncase.me/remember/) +- [Spaced Repetition for Efficient Learning by Gwern](https://www.gwern.net/Spaced-repetition/) +- [20 rules of knowledge formulation by Dr. Piotr Wozniak](https://supermemo.guru/wiki/20_rules_of_knowledge_formulation) + +### 赞助 + +Buy Me a Coffee at ko-fi.com + +JetBrains Logo (Main) logo. diff --git a/docs/zh/notes.md b/docs/zh/notes.md new file mode 100644 index 00000000..fb9ad808 --- /dev/null +++ b/docs/zh/notes.md @@ -0,0 +1,71 @@ +# 笔记 + +- 笔记应当具有原子性:说清楚**一个**概念; +- 笔记之间应当高度关联; +- 先理解,后复习; +- 善用 [费曼学习法](https://fs.blog/2021/02/feynman-learning-technique/) + +## 开始使用 + +为需要复习的笔记添加 `#review` 标签。你可以在插件设置中修改此默认标签(也可以使用多个标签) + +## 新笔记 + +新笔记将展示在右栏的 `新` (复习序列)中,如图: + + + +## 复习 + +打开笔记即可复习。在菜单中选择 `复习: 简单`,`复习: 记得` 或 `复习: 较难`。 选择 `简单`,`记得` 还是 `较难` 取决于你对复习材料的理解程度。 + + + +在文件上右击可以调出相同选项: + + + +笔记将被添加到复习队列中: + + + +### 快速复习 + +我们提供快速进入复习模式的命令。你可以在 `设置 -> 快捷键` 中定制快捷键。这可以使您直接开始复习。 + +### 复习设置 + +可供定制的选项包括: + +- 随机打开笔记或按照优先级排序 +- 在复习完成后是否自动打开下一个笔记 + +## 复习计划 + +位于状态栏底部的 `复习: N 卡片已到期` 显示您今天需要复习的卡片数目(今日卡片 + 逾期卡片)。点击可打开一张卡片开始复习。 + +您也可以使用 `打开一个笔记开始复习` 命令。 + +## 复习序列 + +- 每日复习条目将按照优先级排序 (PageRank) + +## 渐进式写作 + +阅读 `@aviskase` 的 [介绍](https://github.com/st3v3nmw/obsidian-spaced-repetition/issues/15) + +视频资源: + +- 英文: [Obsidian: inbox review with spaced repetition](https://youtu.be/zG5r7QIY_TM) +- 俄文: [Yuliya Bagriy - Разгребатель инбокса заметок как у Andy Matuschak в Obsidian](https://www.youtube.com/watch?v=CF6SSHB74cs) + +### 概要 + +Andy Matuschak 在 [写作素材库中引入间隔重复系统](https://notes.andymatuschak.org/z7iCjRziX6V6unNWL81yc2dJicpRw2Cpp9MfQ). + +简而言之,可以进行四种操作 (此处 `x < y`): + +- 跳过笔记 (增加 `x` 天的复习间隔) == 标记为 `记得` +- 已阅,觉得有用 (降低复习间隔) == 标记为 `较难` +- 已阅,觉得没用 (增加 `y` 天的复习间隔) == 标记为 `简单` +- 转换为 evergreen 笔记 (中止使用间隔重复系统) diff --git a/mkdocs.yml b/mkdocs.yml index 375c0f56..6537cdbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ plugins: docs_structure: folder languages: en: English + zh: 简体中文 default_language: en markdown_extensions: diff --git a/src/NoteFileLoader.ts b/src/NoteFileLoader.ts index 252e863b..f63e0670 100644 --- a/src/NoteFileLoader.ts +++ b/src/NoteFileLoader.ts @@ -4,6 +4,7 @@ import { Question } from "./Question"; import { TopicPath } from "./TopicPath"; import { NoteQuestionParser } from "./NoteQuestionParser"; import { SRSettings } from "./settings"; +import { TextDirection } from "./util/TextDirection"; export class NoteFileLoader { fileText: string; @@ -16,7 +17,11 @@ export class NoteFileLoader { this.settings = settings; } - async load(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { + async load( + noteFile: ISRFile, + defaultTextDirection: TextDirection, + folderTopicPath: TopicPath, + ): Promise { this.noteFile = noteFile; const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); @@ -24,6 +29,7 @@ export class NoteFileLoader { const onlyKeepQuestionsWithTopicPath: boolean = true; const questionList: Question[] = await questionParser.createQuestionList( noteFile, + defaultTextDirection, folderTopicPath, onlyKeepQuestionsWithTopicPath, ); diff --git a/src/NoteParser.ts b/src/NoteParser.ts index 15b7fdbe..f6368954 100644 --- a/src/NoteParser.ts +++ b/src/NoteParser.ts @@ -3,6 +3,7 @@ import { ISRFile } from "./SRFile"; import { Note } from "./Note"; import { SRSettings } from "./settings"; import { TopicPath } from "./TopicPath"; +import { TextDirection } from "./util/TextDirection"; export class NoteParser { settings: SRSettings; @@ -12,9 +13,18 @@ export class NoteParser { this.settings = settings; } - async parse(noteFile: ISRFile, folderTopicPath: TopicPath): Promise { + async parse( + noteFile: ISRFile, + defaultTextDirection: TextDirection, + folderTopicPath: TopicPath, + ): Promise { const questionParser: NoteQuestionParser = new NoteQuestionParser(this.settings); - const questions = await questionParser.createQuestionList(noteFile, folderTopicPath, true); + const questions = await questionParser.createQuestionList( + noteFile, + defaultTextDirection, + folderTopicPath, + true, + ); const result: Note = new Note(noteFile, questions); return result; diff --git a/src/NoteQuestionParser.ts b/src/NoteQuestionParser.ts index c9b545f8..b53c8208 100644 --- a/src/NoteQuestionParser.ts +++ b/src/NoteQuestionParser.ts @@ -9,6 +9,7 @@ import { SRSettings, SettingsUtil } from "./settings"; import { ISRFile, frontmatterTagPseudoLineNum } from "./SRFile"; import { TopicPath, TopicPathList } from "./TopicPath"; import { splitNoteIntoFrontmatterAndContent, splitTextIntoLineArray } from "./util/utils"; +import { TextDirection } from "./util/TextDirection"; export class NoteQuestionParser { settings: SRSettings; @@ -41,6 +42,7 @@ export class NoteQuestionParser { async createQuestionList( noteFile: ISRFile, + defaultTextDirection: TextDirection, folderTopicPath: TopicPath, onlyKeepQuestionsWithTopicPath: boolean, ): Promise { @@ -65,8 +67,11 @@ export class NoteQuestionParser { [this.frontmatterText, this.contentText] = splitNoteIntoFrontmatterAndContent(noteText); // Create the question list + let textDirection: TextDirection = noteFile.getTextDirection(); + if (textDirection == TextDirection.Unspecified) textDirection = defaultTextDirection; this.questionList = this.doCreateQuestionList( noteText, + textDirection, folderTopicPath, this.tagCacheList, ); @@ -90,6 +95,7 @@ export class NoteQuestionParser { private doCreateQuestionList( noteText: string, + textDirection: TextDirection, folderTopicPath: TopicPath, tagCacheList: TagCache[], ): Question[] { @@ -101,7 +107,7 @@ export class NoteQuestionParser { const result: Question[] = []; const parsedQuestionInfoList: ParsedQuestionInfo[] = this.parseQuestions(); for (const parsedQuestionInfo of parsedQuestionInfoList) { - const question: Question = this.createQuestionObject(parsedQuestionInfo); + const question: Question = this.createQuestionObject(parsedQuestionInfo, textDirection); // Each rawCardText can turn into multiple CardFrontBack's (e.g. CardType.Cloze, CardType.SingleLineReversed) const cardFrontBackList: CardFrontBack[] = CardFrontBackUtil.expand( @@ -148,7 +154,10 @@ export class NoteQuestionParser { return result; } - private createQuestionObject(parsedQuestionInfo: ParsedQuestionInfo): Question { + private createQuestionObject( + parsedQuestionInfo: ParsedQuestionInfo, + textDirection: TextDirection, + ): Question { const questionContext: string[] = this.noteFile.getQuestionContext( parsedQuestionInfo.firstLineNum, ); @@ -156,6 +165,7 @@ export class NoteQuestionParser { this.settings, parsedQuestionInfo, null, // We haven't worked out the TopicPathList yet + textDirection, questionContext, ); return result; diff --git a/src/OsrCore.ts b/src/OsrCore.ts index 4a804d70..ebdb2ad6 100644 --- a/src/OsrCore.ts +++ b/src/OsrCore.ts @@ -18,12 +18,14 @@ import { ReviewResponse } from "./algorithms/base/RepetitionItem"; import { IOsrVaultNoteLinkInfoFinder } from "./algorithms/osr/ObsidianVaultNoteLinkInfoFinder"; import { CardDueDateHistogram, NoteDueDateHistogram } from "./DueDateHistogram"; import { globalDateProvider } from "./util/DateProvider"; +import { TextDirection } from "./util/TextDirection"; export interface IOsrVaultEvents { dataChanged: () => void; } export class OsrCore { + public defaultTextDirection: TextDirection; protected settings: SRSettings; private dataChangedHandler: () => void; protected osrNoteGraph: OsrNoteGraph; @@ -222,7 +224,7 @@ export class OsrCore { async loadNote(noteFile: ISRFile, topicPath: TopicPath): Promise { const loader: NoteFileLoader = new NoteFileLoader(this.settings); - const note: Note = await loader.load(noteFile, topicPath); + const note: Note = await loader.load(noteFile, this.defaultTextDirection, topicPath); if (note.hasChanged) { await note.writeNoteFile(this.settings); } diff --git a/src/Question.ts b/src/Question.ts index 2c634235..57656017 100644 --- a/src/Question.ts +++ b/src/Question.ts @@ -7,6 +7,7 @@ import { ParsedQuestionInfo } from "./parser"; import { SRSettings } from "./settings"; import { TopicPath, TopicPathList, TopicPathWithWs } from "./TopicPath"; import { MultiLineTextFinder } from "./util/MultiLineTextFinder"; +import { TextDirection } from "./util/TextDirection"; import { cyrb53, stringTrimStart } from "./util/utils"; export enum CardType { @@ -83,6 +84,9 @@ export class QuestionText { // The question text, e.g. "Q1::A1" with leading/trailing whitespace as described above actualQuestion: string; + // Either LTR or RTL + textDirection: TextDirection; + // The block identifier (optional), e.g. "^quote-of-the-day" // Format of block identifiers: // https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note @@ -98,11 +102,13 @@ export class QuestionText { original: string, topicPathWithWs: TopicPathWithWs, actualQuestion: string, + textDirection: TextDirection, blockId: string, ) { this.original = original; this.topicPathWithWs = topicPathWithWs; this.actualQuestion = actualQuestion; + this.textDirection = textDirection; this.obsidianBlockId = blockId; // The hash is generated based on the topic and question, explicitly not the schedule or obsidian block ID @@ -113,10 +119,14 @@ export class QuestionText { return this.actualQuestion.endsWith("```"); } - static create(original: string, settings: SRSettings): QuestionText { + static create( + original: string, + textDirection: TextDirection, + settings: SRSettings, + ): QuestionText { const [topicPathWithWs, actualQuestion, blockId] = this.splitText(original, settings); - return new QuestionText(original, topicPathWithWs, actualQuestion, blockId); + return new QuestionText(original, topicPathWithWs, actualQuestion, textDirection, blockId); } static splitText(original: string, settings: SRSettings): [TopicPathWithWs, string, string] { @@ -245,7 +255,12 @@ export class Question { let newText = MultiLineTextFinder.findAndReplace(noteText, originalText, replacementText); if (newText) { - this.questionText = QuestionText.create(replacementText, settings); + // Don't support changing the textDirection setting + this.questionText = QuestionText.create( + replacementText, + this.questionText.textDirection, + settings, + ); } else { console.error( `updateQuestionText: Text not found: ${originalText.substring( @@ -274,10 +289,15 @@ export class Question { settings: SRSettings, parsedQuestionInfo: ParsedQuestionInfo, noteTopicPathList: TopicPathList, + textDirection: TextDirection, context: string[], ): Question { const hasEditLaterTag = parsedQuestionInfo.text.includes(settings.editLaterTag); - const questionText: QuestionText = QuestionText.create(parsedQuestionInfo.text, settings); + const questionText: QuestionText = QuestionText.create( + parsedQuestionInfo.text, + textDirection, + settings, + ); let topicPathList: TopicPathList = noteTopicPathList; if (questionText.topicPathWithWs) { diff --git a/src/SRFile.ts b/src/SRFile.ts index 067683a6..3d37a8c2 100644 --- a/src/SRFile.ts +++ b/src/SRFile.ts @@ -2,11 +2,12 @@ import { MetadataCache, TFile, Vault, - HeadingCache, getAllTags as ObsidianGetAllTags, + HeadingCache, TagCache, FrontMatterCache, } from "obsidian"; +import { TextDirection } from "./util/TextDirection"; import { parseObsidianFrontmatterTag } from "./util/utils"; // NOTE: Line numbers are zero based @@ -18,6 +19,7 @@ export interface ISRFile { getAllTagsFromCache(): string[]; getAllTagsFromText(): TagCache[]; getQuestionContext(cardLine: number): string[]; + getTextDirection(): TextDirection; read(): Promise; write(content: string): Promise; } @@ -131,6 +133,22 @@ export class SrTFile implements ISRFile { return result; } + getTextDirection(): TextDirection { + let result: TextDirection = TextDirection.Unspecified; + const fileCache = this.metadataCache.getFileCache(this.file); + const frontMatter = fileCache?.frontmatter; + if (frontMatter && frontMatter?.direction) { + // Don't know why the try/catch is needed; but copied from Obsidian RTL plug-in getFrontMatterDirection() + try { + const str: string = (frontMatter.direction + "").toLowerCase(); + result = str == "rtl" ? TextDirection.Rtl : TextDirection.Ltr; + } catch (error) { + // continue regardless of error + } + } + return result; + } + async read(): Promise { return await this.vault.read(this.file); } diff --git a/src/gui/EditModal.tsx b/src/gui/EditModal.tsx index 1feb19d5..35c9a7cd 100644 --- a/src/gui/EditModal.tsx +++ b/src/gui/EditModal.tsx @@ -1,5 +1,6 @@ import { App, Modal } from "obsidian"; import { t } from "src/lang/helpers"; +import { TextDirection } from "src/util/TextDirection"; // from https://github.com/chhoumann/quickadd/blob/bce0b4cdac44b867854d6233796e3406dfd163c6/src/gui/GenericInputPrompt/GenericInputPrompt.ts#L5 export class FlashcardEditModal extends Modal { @@ -17,17 +18,23 @@ export class FlashcardEditModal extends Modal { private rejectPromise: (reason?: any) => void; private didSaveChanges = false; private readonly modalText: string; - - public static Prompt(app: App, placeholder: string): Promise { - const newPromptModal = new FlashcardEditModal(app, placeholder); + private textDirection: TextDirection; + + public static Prompt( + app: App, + placeholder: string, + textDirection: TextDirection, + ): Promise { + const newPromptModal = new FlashcardEditModal(app, placeholder, textDirection); return newPromptModal.waitForClose; } - constructor(app: App, existingText: string) { + constructor(app: App, existingText: string, textDirection: TextDirection) { super(app); this.modalText = existingText; this.changedText = existingText; + this.textDirection = textDirection; this.waitForClose = new Promise((resolve, reject) => { this.resolvePromise = resolve; @@ -56,6 +63,9 @@ export class FlashcardEditModal extends Modal { this.textArea.addClass("sr-input"); this.textArea.setText(this.modalText ?? ""); this.textArea.addEventListener("keydown", this.saveOnEnterCallback); + if (this.textDirection == TextDirection.Rtl) { + this.textArea.setAttribute("dir", "rtl"); + } this._createResponse(this.contentEl); } diff --git a/src/gui/FlashcardModal.tsx b/src/gui/FlashcardModal.tsx index 9ff4b372..ddadb7d3 100644 --- a/src/gui/FlashcardModal.tsx +++ b/src/gui/FlashcardModal.tsx @@ -48,7 +48,9 @@ export class FlashcardModal extends Modal { // Setup base containers this.modalEl.style.height = this.settings.flashcardHeightPercentage + "%"; + this.modalEl.style.maxHeight = this.settings.flashcardHeightPercentage + "%"; this.modalEl.style.width = this.settings.flashcardWidthPercentage + "%"; + this.modalEl.style.maxWidth = this.settings.flashcardWidthPercentage + "%"; this.modalEl.setAttribute("id", "sr-modal"); this.contentEl.addClass("sr-modal-content"); @@ -118,7 +120,11 @@ export class FlashcardModal extends Modal { // Just the question/answer text; without any preceding topic tag const textPrompt = currentQ.questionText.actualQuestion; - const editModal = FlashcardEditModal.Prompt(this.app, textPrompt); + const editModal = FlashcardEditModal.Prompt( + this.app, + textPrompt, + currentQ.questionText.textDirection, + ); editModal .then(async (modifiedCardText) => { this.reviewSequencer.updateCurrentQuestionText(modifiedCardText); diff --git a/src/gui/FlashcardReviewView.tsx b/src/gui/FlashcardReviewView.tsx index a64e57ee..f081d3be 100644 --- a/src/gui/FlashcardReviewView.tsx +++ b/src/gui/FlashcardReviewView.tsx @@ -137,7 +137,13 @@ export class FlashcardReviewView { this.plugin, this._currentNote.filePath, ); - await wrapper.renderMarkdownWrapper(this._currentCard.front, this.content); + await wrapper.renderMarkdownWrapper( + this._currentCard.front, + this.content, + this._currentQuestion.questionText.textDirection, + ); + // Set scroll position back to top + this.content.scrollTop = 0; // Setup response buttons this._resetResponseButtons(); @@ -291,7 +297,11 @@ export class FlashcardReviewView { this.plugin, this._currentNote.filePath, ); - wrapper.renderMarkdownWrapper(this._currentCard.back, this.content); + wrapper.renderMarkdownWrapper( + this._currentCard.back, + this.content, + this._currentQuestion.questionText.textDirection, + ); // Show response buttons this.answerButton.addClass("sr-is-hidden"); @@ -340,9 +350,17 @@ export class FlashcardReviewView { private _formatQuestionContextText(questionContext: string[]): string { const separator: string = " > "; let result = this._currentNote.file.basename; - if (questionContext.length > 0) { - result += separator + questionContext.join(separator); - } + questionContext.forEach((context) => { + // Check for links trim [[ ]] + if (context.startsWith("[[") && context.endsWith("]]")) { + context = context.replace("[[", "").replace("]]", ""); + // Use replacement text if any + if (context.contains("|")) { + context = context.split("|")[1]; + } + } + result += separator + context; + }); return result + separator + "..."; } diff --git a/src/gui/ReviewQueueListView.ts b/src/gui/ReviewQueueListView.ts index 09e26ab9..7b1affb3 100644 --- a/src/gui/ReviewQueueListView.ts +++ b/src/gui/ReviewQueueListView.ts @@ -54,8 +54,8 @@ export class ReviewQueueListView extends ItemView { public redraw(): void { const activeFile: TFile | null = this.app.workspace.getActiveFile(); - const rootEl: HTMLElement = createDiv("nav-folder mod-root"); - const childrenEl: HTMLElement = rootEl.createDiv("nav-folder-children"); + const rootEl: HTMLElement = createDiv(); + const childrenEl: HTMLElement = rootEl; for (const [deckKey, deck] of this.noteReviewQueue.reviewDecks) { const deckCollapsed = !deck.activeFolders.has(deck.deckName); @@ -66,7 +66,7 @@ export class ReviewQueueListView extends ItemView { deckCollapsed, false, deck, - ).getElementsByClassName("nav-folder-children")[0] as HTMLElement; + ).getElementsByClassName("tree-item-children")[0] as HTMLElement; if (deck.newNotes.length > 0) { const newNotesFolderEl: HTMLElement = this.createRightPaneFolder( @@ -161,11 +161,11 @@ export class ReviewQueueListView extends ItemView { hidden: boolean, deck: NoteReviewDeck, ): HTMLElement { - const folderEl: HTMLDivElement = parentEl.createDiv("nav-folder"); - const folderTitleEl: HTMLDivElement = folderEl.createDiv("nav-folder-title"); - const childrenEl: HTMLDivElement = folderEl.createDiv("nav-folder-children"); + const folderEl: HTMLDivElement = parentEl.createDiv("tree-item"); + const folderTitleEl: HTMLDivElement = folderEl.createDiv("tree-item-self"); + const childrenEl: HTMLDivElement = folderEl.createDiv("tree-item-children"); const collapseIconEl: HTMLDivElement = folderTitleEl.createDiv( - "nav-folder-collapse-indicator collapse-icon", + "tree-item-collapse-indicator collapse-icon", ); collapseIconEl.innerHTML = COLLAPSE_ICON; @@ -173,7 +173,7 @@ export class ReviewQueueListView extends ItemView { (collapseIconEl.childNodes[0] as HTMLElement).style.transform = "rotate(-90deg)"; } - folderTitleEl.createDiv("nav-folder-title-content").setText(folderTitle); + folderTitleEl.createDiv("tree-item-content").setText(folderTitle); if (hidden) { folderEl.style.display = "none"; @@ -205,18 +205,18 @@ export class ReviewQueueListView extends ItemView { deck: NoteReviewDeck, ): void { const navFileEl: HTMLElement = folderEl - .getElementsByClassName("nav-folder-children")[0] - .createDiv("nav-file"); + .getElementsByClassName("tree-item-children")[0] + .createDiv("tree-item"); if (hidden) { navFileEl.style.display = "none"; } - const navFileTitle: HTMLElement = navFileEl.createDiv("nav-file-title"); + const navFileTitle: HTMLElement = navFileEl.createDiv("tree-item-self"); if (fileElActive) { navFileTitle.addClass("is-active"); } - navFileTitle.createDiv("nav-file-title-content").setText(file.basename); + navFileTitle.createDiv("tree-item-content").setText(file.basename); navFileTitle.addEventListener( "click", async (event: MouseEvent) => { @@ -244,7 +244,7 @@ export class ReviewQueueListView extends ItemView { } private changeFolderIconToExpanded(folderEl: HTMLElement): void { - const collapseIconEl = folderEl.find("div.nav-folder-collapse-indicator"); + const collapseIconEl = folderEl.find("div.tree-item-collapse-indicator"); (collapseIconEl.childNodes[0] as HTMLElement).style.transform = ""; } } diff --git a/src/main.ts b/src/main.ts index f303a749..fe4df048 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,8 @@ import { Note } from "./Note"; import { NoteFileLoader } from "./NoteFileLoader"; import { ISRFile, SrTFile as SrTFile } from "./SRFile"; import { QuestionPostponementList } from "./QuestionPostponementList"; +import { TextDirection } from "./util/TextDirection"; +import { convertToStringOrEmpty } from "./util/utils"; import { ReviewResponse } from "./algorithms/base/RepetitionItem"; import { SrsAlgorithm } from "./algorithms/base/SrsAlgorithm"; import { DataStore } from "./dataStore/base/DataStore"; @@ -50,7 +52,7 @@ export default class SRPlugin extends Plugin { private nextNoteReviewHandler: NextNoteReviewHandler; async onload(): Promise { - console.log("onload: Branch: feat-878-support-multiple-sched, Date: 2024-07-16"); + console.log("onload: Branch: feat-878-support-multiple-sched, Date: 2024-07-24"); await this.loadPluginData(); this.initLogicClasses(); @@ -350,6 +352,7 @@ export default class SRPlugin extends Plugin { } const now = window.moment(Date.now()); + this.osrAppCore.defaultTextDirection = this.getObsidianRtlSetting(); await this.osrAppCore.loadVault(); @@ -386,13 +389,24 @@ export default class SRPlugin extends Plugin { this.data.settings, ); - const note: Note = await loader.load(this.createSrTFile(noteFile), folderTopicPath); + const note: Note = await loader.load( + this.createSrTFile(noteFile), + this.getObsidianRtlSetting(), + folderTopicPath, + ); if (note.hasChanged) { note.writeNoteFile(this.data.settings); } return note; } + private getObsidianRtlSetting(): TextDirection { + // Get the direction with Obsidian's own setting + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v: any = (this.app.vault as any).getConfig("rightToLeft"); + return convertToStringOrEmpty(v) == "true" ? TextDirection.Rtl : TextDirection.Ltr; + } + async saveNoteReviewResponse(note: TFile, response: ReviewResponse): Promise { const noteSrTFile: ISRFile = this.createSrTFile(note); diff --git a/src/util/RenderMarkdownWrapper.ts b/src/util/RenderMarkdownWrapper.ts index 8b82f763..8422b8c6 100644 --- a/src/util/RenderMarkdownWrapper.ts +++ b/src/util/RenderMarkdownWrapper.ts @@ -6,6 +6,7 @@ import { NON_LETTER_SYMBOLS_REGEX, } from "../constants"; import SRPlugin from "../main"; +import { TextDirection } from "./TextDirection"; export class RenderMarkdownWrapper { private app: App; @@ -23,13 +24,19 @@ export class RenderMarkdownWrapper { async renderMarkdownWrapper( markdownString: string, containerEl: HTMLElement, + textDirection: TextDirection, recursiveDepth = 0, ): Promise { if (recursiveDepth > 4) return; - MarkdownRenderer.renderMarkdown(markdownString, containerEl, this.notePath, this.plugin); + let el: HTMLElement; + if (textDirection == TextDirection.Rtl) { + el = containerEl.createDiv(); + el.setAttribute("dir", "rtl"); + } else el = containerEl; + MarkdownRenderer.render(this.app, markdownString, el, this.notePath, this.plugin); - containerEl.findAll(".internal-embed").forEach((el) => { + el.findAll(".internal-embed").forEach((el) => { const link = this.parseLink(el.getAttribute("src")); // file does not exist, display dead link @@ -145,6 +152,9 @@ export class RenderMarkdownWrapper { blockText = text; } - this.renderMarkdownWrapper(blockText, el, recursiveDepth + 1); + // We are operating here within the parent container. + // It already has the rtl div if necessary. + // We don't need another rtl div, so we can set direction to Unspecified + this.renderMarkdownWrapper(blockText, el, TextDirection.Unspecified, recursiveDepth + 1); } } diff --git a/src/util/TextDirection.ts b/src/util/TextDirection.ts new file mode 100644 index 00000000..a49807cf --- /dev/null +++ b/src/util/TextDirection.ts @@ -0,0 +1,5 @@ +export enum TextDirection { + Unspecified, + Ltr, + Rtl, +} diff --git a/src/util/utils.ts b/src/util/utils.ts index 5a2d32c6..dfa413cd 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,6 +1,7 @@ import moment from "moment"; import { Moment } from "moment"; -import { PREFERRED_DATE_FORMAT, YAML_FRONT_MATTER_REGEX } from "src/constants"; +import { normalize, sep } from "path"; +import { PREFERRED_DATE_FORMAT } from "src/constants"; type Hex = number; @@ -96,6 +97,47 @@ export function stringTrimStart(str: string): [string, string] { return [ws, trimmed]; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function convertToStringOrEmpty(v: any): string { + let result: string = ""; + if (v != null && v != undefined) { + result = v + ""; + } + return result; +} + +/** + * Checks a path is equal or a subpath of the other rootPath + * + * @param toCheck The path to check it is equal or a subpath of path. + * @param rootPath The ref path to check the other is equal to or a subpath of this. + * @tutorial + * rootPath = "root/sub/sub2" + * if toCheck = "notRoot/..." -> false + * if toCheck = "root" -> true + * if toCheck = "root/sub" -> true + * if toCheck = "root/s" -> false + */ +export function isEqualOrSubPath(toCheck: string, rootPath: string): boolean { + const rootPathSections = normalize(rootPath.toLowerCase()) + .replaceAll(/(\\|\/)/g, sep) + .split(sep) + .filter((p) => p !== ""); + const pathSections = normalize(toCheck.toLowerCase()) + .replaceAll(/(\\|\/)/g, sep) + .split(sep) + .filter((p) => p !== ""); + if (pathSections.length < rootPathSections.length) { + return false; + } + for (let i = 0; i < rootPathSections.length; i++) { + if (rootPathSections[i] !== pathSections[i]) { + return false; + } + } + return true; +} + // // This returns [frontmatter, content] // @@ -109,32 +151,32 @@ export function stringTrimStart(str: string): [string, string] { // e.g. for calls to getQuestionContext(cardLine: number) // export function splitNoteIntoFrontmatterAndContent(str: string): [string, string] { - let frontmatter: string = ""; - let content: string = ""; - let frontmatterEndLineNum: number = null; - if (YAML_FRONT_MATTER_REGEX.test) { - const lines: string[] = splitTextIntoLineArray(str); - - // The end "---" marker must be on the third line (index 2) or later - for (let i = 2; i < lines.length; i++) { - if (lines[i] == "---") { - frontmatterEndLineNum = i; - break; - } + const lines = splitTextIntoLineArray(str); + let lineIndex = 0; + let hasFrontmatter = false; + do { + // Starts file with '---' + if (lineIndex === 0 && lines[lineIndex] === "---") { + hasFrontmatter = true; } - - if (frontmatterEndLineNum) { - const frontmatterStartLineNum: number = 0; - const frontmatterLines: string[] = []; - for (let i = frontmatterStartLineNum; i <= frontmatterEndLineNum; i++) { - frontmatterLines.push(lines[i]); - lines[i] = ""; - } - frontmatter = frontmatterLines.join("\n"); - content = lines.join("\n"); + // Line is end of front matter + else if (hasFrontmatter && lines[lineIndex] === "---") { + hasFrontmatter = false; + lineIndex++; + } + if (hasFrontmatter) { + lineIndex++; } + } while (hasFrontmatter && lineIndex < lines.length); + // No end of Frontmatter found + if (hasFrontmatter) { + lineIndex = 0; } - if (frontmatter.length == 0) content = str; + + const frontmatter: string = lines.slice(0, lineIndex).join("\n"); + const emptyLines: string[] = lineIndex > 0 ? Array(lineIndex).join(".").split(".") : []; + const content: string = emptyLines.concat(lines.slice(lineIndex)).join("\n"); + return [frontmatter, content]; } diff --git a/styles.css b/styles.css index af8e7a1d..f532c01c 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,40 @@ -.is-mobile #sr-modal { - --top-space: calc(var(--safe-area-inset-top) + var(--header-height) + var(--size-4-2)); - width: 100vw !important; - height: calc(100vh - var(--top-space)) !important; - margin-top: var(--top-space); +@media only screen and (orientation: landscape) { + .is-mobile .sr-flashcard { + flex-direction: row; + } + + .is-mobile .sr-header { + flex-direction: column; + flex: 0 1 0; + } + + .is-mobile .sr-content { + flex: 1 0 0; + } + + .is-mobile .sr-response { + flex-direction: column; + flex: 0 1 0; + } + + .is-mobile .sr-controls { + flex-direction: column; + } + + .is-mobile .sr-title { + display: none; + } + + .is-mobile .sr-response-button { + writing-mode: vertical-lr; + } +} + +#sr-modal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } #sr-modal .modal-title { diff --git a/tests/unit/Note.test.ts b/tests/unit/Note.test.ts index 8c3abed4..984999a4 100644 --- a/tests/unit/Note.test.ts +++ b/tests/unit/Note.test.ts @@ -5,6 +5,7 @@ import { Note } from "src/Note"; import { Question } from "src/Question"; import { DEFAULT_SETTINGS } from "src/settings"; import { NoteFileLoader } from "src/NoteFileLoader"; +import { TextDirection } from "src/util/TextDirection"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; import { NoteEaseList } from "src/NoteEaseList"; import { unitTestSetup_StandardDataStoreAlgorithm } from "./helpers/UnitTestSetup"; @@ -25,7 +26,7 @@ Q3::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(noteText); let folderTopicPath = TopicPath.emptyPath; - let note: Note = await parser.parse(file, folderTopicPath); + let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath); let deck: Deck = Deck.emptyDeck; note.appendCardsToDeck(deck); let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"])); @@ -43,7 +44,7 @@ Q3:::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(noteText); let folderTopicPath = TopicPath.emptyPath; - let note: Note = await parser.parse(file, folderTopicPath); + let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath); let deck: Deck = Deck.emptyDeck; note.appendCardsToDeck(deck); let subdeck: Deck = deck.getDeck(new TopicPath(["flashcards", "test"])); @@ -65,7 +66,7 @@ Q3:::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(originalText); - let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath); await note.writeNoteFile(DEFAULT_SETTINGS); let updatedText: string = file.content; diff --git a/tests/unit/NoteFileLoader.test.ts b/tests/unit/NoteFileLoader.test.ts index b9a646d3..83dbd2d0 100644 --- a/tests/unit/NoteFileLoader.test.ts +++ b/tests/unit/NoteFileLoader.test.ts @@ -2,6 +2,7 @@ import { Note } from "src/Note"; import { NoteFileLoader } from "src/NoteFileLoader"; import { TopicPath } from "src/TopicPath"; import { DEFAULT_SETTINGS } from "src/settings"; +import { TextDirection } from "src/util/TextDirection"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; import { NoteEaseList } from "src/NoteEaseList"; import { unitTestSetup_StandardDataStoreAlgorithm } from "./helpers/UnitTestSetup"; @@ -22,7 +23,7 @@ Q3:::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(noteText); - let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath); expect(note.hasChanged).toEqual(false); }); @@ -35,7 +36,7 @@ Q3:::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(noteText); - let note: Note = await noteFileLoader.load(file, TopicPath.emptyPath); + let note: Note = await noteFileLoader.load(file, TextDirection.Ltr, TopicPath.emptyPath); expect(note.hasChanged).toEqual(true); }); }); diff --git a/tests/unit/NoteParser.test.ts b/tests/unit/NoteParser.test.ts index a867f9df..5808e837 100644 --- a/tests/unit/NoteParser.test.ts +++ b/tests/unit/NoteParser.test.ts @@ -4,6 +4,7 @@ import { Note } from "src/Note"; import { Question } from "src/Question"; import { DEFAULT_SETTINGS } from "src/settings"; import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; +import { TextDirection } from "src/util/TextDirection"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; import { unitTestSetup_StandardDataStoreAlgorithm } from "./helpers/UnitTestSetup"; import { NoteEaseList } from "src/NoteEaseList"; @@ -24,7 +25,7 @@ Q3::A3 `; let file: UnitTestSRFile = new UnitTestSRFile(noteText); let folderTopicPath = TopicPath.emptyPath; - let note: Note = await parser.parse(file, folderTopicPath); + let note: Note = await parser.parse(file, TextDirection.Ltr, folderTopicPath); let questionList = note.questionList; expect(questionList.length).toEqual(3); }); diff --git a/tests/unit/NoteQuestionParser.test.ts b/tests/unit/NoteQuestionParser.test.ts index 6efdd918..6edb3770 100644 --- a/tests/unit/NoteQuestionParser.test.ts +++ b/tests/unit/NoteQuestionParser.test.ts @@ -6,6 +6,7 @@ import { TopicPath, TopicPathList } from "src/TopicPath"; import { createTest_NoteQuestionParser } from "./SampleItems"; import { ISRFile, frontmatterTagPseudoLineNum } from "src/SRFile"; import { setupStaticDateProvider_20230906 } from "src/util/DateProvider"; +import { TextDirection } from "src/util/TextDirection"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; import { RepItemScheduleInfo } from "src/algorithms/base/RepItemScheduleInfo"; import { RepItemScheduleInfo_Osr } from "src/algorithms/osr/RepItemScheduleInfo_Osr"; @@ -32,7 +33,12 @@ describe("No flashcard questions", () => { let noteFile: ISRFile = new UnitTestSRFile(noteText); expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toEqual([]); }); @@ -42,7 +48,12 @@ describe("No flashcard questions", () => { let noteFile: ISRFile = new UnitTestSRFile(noteText); expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toEqual([]); }); }); @@ -74,7 +85,12 @@ A::B }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -110,7 +126,12 @@ A::B }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -132,7 +153,12 @@ A::B }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -161,7 +187,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); }); @@ -200,7 +231,12 @@ A::B ^d7cee0 }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -243,7 +279,12 @@ A::B ^d7cee0 }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -283,7 +324,12 @@ A::B ^d7cee0 }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -322,7 +368,12 @@ A::B ^d7cee0 }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); }); @@ -337,6 +388,7 @@ Q2::A2 let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -354,6 +406,7 @@ Q3::A3 let folderTopicPath: TopicPath = new TopicPath(["flashcards", "science"]); let questionList: Question[] = await parser_ConvertFoldersToDecks.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -380,6 +433,7 @@ Q3::A3 let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -409,6 +463,7 @@ Multiline answer2 let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, TopicPath.emptyPath, true, ); @@ -455,6 +510,7 @@ describe("Handling tags within note", () => { let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); let questionList: Question[] = await parser2.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -472,6 +528,7 @@ Q1::A1 let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); let questionList: Question[] = await parser2.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -491,6 +548,7 @@ Q1::A1 let folderTopicPath: TopicPath = new TopicPath(["folder", "subfolder"]); let questionList: Question[] = await parser2.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -514,6 +572,7 @@ Q1::A1 let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -551,7 +610,12 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -566,6 +630,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -588,6 +653,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -609,6 +675,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -632,6 +699,7 @@ Stop trying ==to milk the crowd== for sympathy. // доить толпу let folderTopicPath: TopicPath = TopicPath.emptyPath; let questionList: Question[] = await parserWithDefaultSettings.createQuestionList( noteFile, + TextDirection.Ltr, folderTopicPath, true, ); @@ -673,7 +741,12 @@ What year was the Taliban Emirate founded?::1996 #flashcards }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); }); @@ -710,7 +783,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -741,7 +819,12 @@ In computer-science, a *heap* is a tree-based data-structure, that satisfies the }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); @@ -781,7 +864,12 @@ A::B }, ]; expect( - await parserWithDefaultSettings.createQuestionList(noteFile, folderTopicPath, true), + await parserWithDefaultSettings.createQuestionList( + noteFile, + TextDirection.Ltr, + folderTopicPath, + true, + ), ).toMatchObject(expected); }); }); diff --git a/tests/unit/Question.test.ts b/tests/unit/Question.test.ts index 8ba2928f..e6c65c9b 100644 --- a/tests/unit/Question.test.ts +++ b/tests/unit/Question.test.ts @@ -1,6 +1,7 @@ import { TopicPath } from "src/TopicPath"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; import { Question, QuestionText } from "src/Question"; +import { TextDirection } from "src/util/TextDirection"; let settings_cardCommentOnSameLine: SRSettings = { ...DEFAULT_SETTINGS }; settings_cardCommentOnSameLine.cardCommentOnSameLine = true; @@ -13,7 +14,7 @@ describe("Question", () => { "```\nprint('Hello World!')\nprint('Howdy?')\nlambda x: x[0]\n```"; let question: Question = new Question({ - questionText: new QuestionText(text, null, text, null), + questionText: new QuestionText(text, null, text, TextDirection.Ltr, null), }); expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n"); @@ -24,7 +25,7 @@ describe("Question", () => { let text: string = "Q1::A1"; let question: Question = new Question({ - questionText: new QuestionText(text, null, text, null), + questionText: new QuestionText(text, null, text, TextDirection.Ltr, null), }); expect(question.getHtmlCommentSeparator(DEFAULT_SETTINGS)).toEqual("\n"); diff --git a/tests/unit/SampleItems.ts b/tests/unit/SampleItems.ts index da071b5b..b928032c 100644 --- a/tests/unit/SampleItems.ts +++ b/tests/unit/SampleItems.ts @@ -7,6 +7,7 @@ import { CardType, Question } from "src/Question"; import { CardFrontBack, CardFrontBackUtil } from "src/QuestionType"; import { DEFAULT_SETTINGS, SRSettings } from "src/settings"; import { TopicPath } from "src/TopicPath"; +import { TextDirection } from "src/util/TextDirection"; import { UnitTestSRFile } from "./helpers/UnitTestSRFile"; import { CardOrder, DeckOrder, DeckTreeIterator } from "src/DeckTreeIterator"; @@ -70,7 +71,7 @@ Q3::A3`; ): Promise { let deck: Deck = new Deck("Root", null); let noteParser: NoteParser = createTest_NoteParser(); - let note: Note = await noteParser.parse(file, folderTopicPath); + let note: Note = await noteParser.parse(file, TextDirection.Ltr, folderTopicPath); note.appendCardsToDeck(deck); return deck; } diff --git a/tests/unit/helpers/UnitTestSRFile.ts b/tests/unit/helpers/UnitTestSRFile.ts index af404ff5..3c8af9f0 100644 --- a/tests/unit/helpers/UnitTestSRFile.ts +++ b/tests/unit/helpers/UnitTestSRFile.ts @@ -1,11 +1,11 @@ import * as fs from "fs"; import { unitTest_BasicFrontmatterParser, - unitTest_BasicFrontmatterParserEx, unitTest_GetAllTagsFromTextEx, } from "./UnitTestHelper"; import { TFile, TagCache } from "obsidian"; import { ISRFile } from "src/SRFile"; +import { TextDirection } from "src/util/TextDirection"; export class UnitTestSRFile implements ISRFile { content: string; @@ -45,6 +45,10 @@ export class UnitTestSRFile implements ISRFile { return []; } + getTextDirection(): TextDirection { + return TextDirection.Unspecified; + } + async read(): Promise { return this.content; } diff --git a/tests/unit/util/utils.test.ts b/tests/unit/util/utils.test.ts index 9424617a..f39931e1 100644 --- a/tests/unit/util/utils.test.ts +++ b/tests/unit/util/utils.test.ts @@ -1,8 +1,10 @@ import { YAML_FRONT_MATTER_REGEX } from "src/constants"; import { - splitNoteIntoFrontmatterAndContent, + convertToStringOrEmpty, findLineIndexOfSearchStringIgnoringWs, + isEqualOrSubPath, literalStringReplace, + splitNoteIntoFrontmatterAndContent, } from "src/util/utils"; describe("literalStringReplace", () => { @@ -78,6 +80,28 @@ $$\\huge F_g=\\frac {G m_1 m_2}{d^2}$$ }); }); +describe("convertToStringOrEmpty", () => { + test("undefined returns empty string", () => { + expect(convertToStringOrEmpty(undefined)).toEqual(""); + }); + + test("null returns empty string", () => { + expect(convertToStringOrEmpty(null)).toEqual(""); + }); + + test("empty string returns empty string", () => { + expect(convertToStringOrEmpty("")).toEqual(""); + }); + + test("string returned unchanged", () => { + expect(convertToStringOrEmpty("Hello")).toEqual("Hello"); + }); + + test("number is converted to string", () => { + expect(convertToStringOrEmpty(5)).toEqual("5"); + }); +}); + function createTestStr1(sep: string): string { return `---${sep}sr-due: 2024-08-10${sep}sr-interval: 273${sep}sr-ease: 309${sep}---`; } @@ -177,6 +201,176 @@ ${content}`; ${content}`; expect(c).toEqual(expectedContent); }); + + test("With frontmatter and content (Horizontal line)", () => { + const frontmatter: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws + - flashcards/datascience +---`; + const frontmatterBlankedOut: string = ` + + + + + + +`; + const content: string = `#flashcards/science/chemistry + + +--- +# Questions +--- + + +Chemistry Question from file underelephant 4A::goodby + + + +Chemistry Question from file underdog 4B::goodby + + + +--- + +Chemistry Question from file underdog 4C::goodby + + + +This single {{question}} turns into {{3 separate}} {{cards}} + + + +---`; + + const text: string = `${frontmatter} +${content}`; + const expectedContent: string = `${frontmatterBlankedOut} +${content}`; + + const [f, c] = splitNoteIntoFrontmatterAndContent(text); + expect(f).toEqual(frontmatter); + expect(c).toEqual(expectedContent); + }); + + test("With frontmatter and content (Horizontal line newLine)", () => { + const frontmatter: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws + - flashcards/datascience +---`; + const frontmatterBlankedOut: string = ` + + + + + + +`; + const content: string = `#flashcards/science/chemistry + + +--- +# Questions +--- + + +Chemistry Question from file underelephant 4A::goodby + + + +Chemistry Question from file underdog 4B::goodby + + + +--- + +Chemistry Question from file underdog 4C::goodby + + + +This single {{question}} turns into {{3 separate}} {{cards}} + + + +--- +`; + + const text: string = `${frontmatter} +${content}`; + const expectedContent: string = `${frontmatterBlankedOut} +${content}`; + + const [f, c] = splitNoteIntoFrontmatterAndContent(text); + expect(f).toEqual(frontmatter); + expect(c).toEqual(expectedContent); + }); + + test("With frontmatter and content (Horizontal line codeblock)", () => { + const frontmatter: string = `--- +sr-due: 2024-01-17 +sr-interval: 16 +sr-ease: 278 +tags: + - flashcards/aws + - flashcards/datascience +---`; + const frontmatterBlankedOut: string = ` + + + + + + +`; + const content: string = [ + "```", + "---", + "```", + "#flashcards/science/chemistry", + "# Questions", + " ", + "", + "Chemistry Question from file underelephant 4A::goodby", + "", + "", + "", + "Chemistry Question from file underdog 4B::goodby", + "", + "", + "```", + "---", + "```", + "", + "Chemistry Question from file underdog 4C::goodby", + "", + "", + "", + "This single {{question}} turns into {{3 separate}} {{cards}}", + "", + "", + "", + "```", + "---", + "```", + ].join("\n"); + + const text: string = `${frontmatter} +${content}`; + const expectedContent: string = `${frontmatterBlankedOut} +${content}`; + + const [f, c] = splitNoteIntoFrontmatterAndContent(text); + expect(f).toEqual(frontmatter); + expect(c).toEqual(expectedContent); + }); }); describe("findLineIndexOfSearchStringIgnoringWs", () => { @@ -243,3 +437,113 @@ describe("findLineIndexOfSearchStringIgnoringWs", () => { expect(findLineIndexOfSearchStringIgnoringWs(lines, "??")).toEqual(2); }); }); + +describe("isEqualOrSubPath", () => { + const winSep = "\\"; + const linSep = "/"; + const root = "root"; + const sub_1 = "plugins"; + const sub_2 = "obsidian-spaced-repetition"; + const sub_3 = "data"; + const noMatch = "notRoot"; + const caseMatch = "Root"; + + describe("Windows", () => { + const sep = winSep; + const rootPath = root + sep + sub_1; + + test("Upper and lower case letters", () => { + expect(isEqualOrSubPath(caseMatch, root)).toBe(true); + expect(isEqualOrSubPath(caseMatch.toUpperCase(), root)).toBe(true); + }); + + test("Seperator auto correction", () => { + expect(isEqualOrSubPath(root + winSep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + winSep + sub_1 + winSep, rootPath)).toBe(true); + + expect(isEqualOrSubPath(root + linSep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + linSep + sub_1 + linSep, rootPath)).toBe(true); + }); + + test("Differnent path", () => { + expect(isEqualOrSubPath(noMatch, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep + sub_1, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep + sub_1 + sep + sub_2, rootPath)).toBe(false); + }); + + test("Partially Match path", () => { + expect(isEqualOrSubPath("roo", rootPath)).toBe(false); + expect(isEqualOrSubPath("roo" + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + "plug", rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + "plug" + sep, rootPath)).toBe(false); + }); + + test("Same path", () => { + expect(isEqualOrSubPath(rootPath, rootPath)).toBe(true); + }); + + test("Subpath", () => { + expect(isEqualOrSubPath(root, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(rootPath, rootPath + sep)).toBe(true); + expect(isEqualOrSubPath(rootPath + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2 + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2 + sep + sub_3, rootPath)).toBe( + true, + ); + }); + }); + describe("Linux", () => { + const sep = linSep; + const rootPath = root + sep + sub_1; + + test("Upper and lower case letters", () => { + expect(isEqualOrSubPath(caseMatch, root)).toBe(true); + expect(isEqualOrSubPath(caseMatch.toUpperCase(), root)).toBe(true); + }); + + test("Seperator auto correction", () => { + expect(isEqualOrSubPath(root + winSep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + winSep + sub_1 + winSep, rootPath)).toBe(true); + + expect(isEqualOrSubPath(root + linSep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + linSep + sub_1 + linSep, rootPath)).toBe(true); + }); + + test("Differnent path", () => { + expect(isEqualOrSubPath(noMatch, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep + sub_1, rootPath)).toBe(false); + expect(isEqualOrSubPath(noMatch + sep + sub_1 + sep + sub_2, rootPath)).toBe(false); + }); + + test("Partially Match path", () => { + expect(isEqualOrSubPath("roo", rootPath)).toBe(false); + expect(isEqualOrSubPath("roo" + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + "plug", rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + "plug" + sep, rootPath)).toBe(false); + }); + + test("Same path", () => { + expect(isEqualOrSubPath(rootPath, rootPath)).toBe(true); + }); + + test("Subpath", () => { + expect(isEqualOrSubPath(root, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep, rootPath)).toBe(false); + expect(isEqualOrSubPath(root + sep + sub_1, rootPath)).toBe(true); + expect(isEqualOrSubPath(rootPath, rootPath + sep)).toBe(true); + expect(isEqualOrSubPath(rootPath + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2 + sep, rootPath)).toBe(true); + expect(isEqualOrSubPath(root + sep + sub_1 + sep + sub_2 + sep + sub_3, rootPath)).toBe( + true, + ); + }); + }); +});