Compare commits

...

13 commits

Author SHA1 Message Date
Dusk
71e36c25c4 chore(release): prepare 1.0.4 scorecard fixes
- replace builtin-modules with Node builtinModules in the build config

- avoid column-gap warnings in release CSS
2026-05-13 20:42:25 +08:00
Dusk
0dc2d7cb5d Release 1.0.3 2026-05-11 21:13:50 +08:00
Dusk
befe28cd77 test(settings): remove global timer shim
中文: 将设置页测试的 activeWindow 计时器改为测试专用控制器,避免裸全局计时器引用和 Obsidian 规则误判。

English: Replace the settings tab test timer shim with a local activeWindow timer controller so the test no longer relies on bare global timer references.
2026-05-04 15:20:19 +08:00
Dusk
13b5685fe5 fix(review): satisfy Obsidian review bot lint
中文: 修复 Obsidian Review Bot 指出的英文文案 sentence case 问题,并调整设置页测试中的计时器类型,避免禁用 prefer-active-window-timers 规则。

English: Fix the remaining Obsidian Review Bot sentence-case findings and update the settings tab test timer shim so no prefer-active-window-timers disable comment is needed.
2026-05-03 20:00:24 +08:00
Dusk
387f977dca Update built plugin bundle 2026-05-03 19:13:38 +08:00
Dusk
0f8d5eefcb Fix review bot lint issues 2026-05-03 19:12:56 +08:00
Dusk
80d6020512 chore: trigger Obsidian plugin rescan 2026-05-03 09:37:57 +08:00
Dusk
a898aa80e9 chore(release): bump version to 1.0.2
- bump package.json/manifest.json to 1.0.2

- add versions.json compatibility entry for 1.0.2

- include latest scope-row inline hint layout adjustments
2026-04-30 21:24:51 +08:00
Dusk
488bd3d91f feat(deck): add folder-level deck mode overrides
在 include 模式下允许选中文件夹改用与全局 folderDeckMode 相反的映射规则

Add include-scope folder overrides that apply the opposite global folderDeckMode mapping

取消勾选文件夹时会清理该路径及其子树的覆盖记录,并把规则接入设置页与 deck fingerprint

Clears override records for deselected folders and descendants, and wires the rules into the settings UI and deck fingerprint
2026-04-30 19:18:15 +08:00
Dusk
3e5ce2c1d2 chore(release): bump plugin version to 1.0.1
中文: 将插件版本升至 1.0.1,恢复使用 Obsidian getLanguage() 作为语言来源,并把最低 Obsidian 版本同步提高到 1.8.7。

English: Bumps the plugin to 1.0.1, restores Obsidian getLanguage() as the locale source, and aligns the minimum Obsidian version with API usage.
2026-04-30 10:37:37 +08:00
Dusk
6fb8583feb Fix Obsidian review issues 2026-04-30 10:15:44 +08:00
Dusk
b4e6e53c61 chore: align plugin metadata for review 2026-04-29 21:22:47 +08:00
Dusk
85eb77d20d docs: mention example release files 2026-04-29 21:07:44 +08:00
47 changed files with 5078 additions and 689 deletions

View file

@ -7,3 +7,18 @@ When syncing this plugin into the user's Obsidian vault, use this exact plugin d
`/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync` `/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync`
Copy only the built plugin files (`main.js`, `manifest.json`, `styles.css`) into that directory. Do not delete or overwrite `data.json`. Copy only the built plugin files (`main.js`, `manifest.json`, `styles.css`) into that directory. Do not delete or overwrite `data.json`.
## GitHub Release Assets
When creating or updating a GitHub release, upload the runtime plugin assets:
- `main.js`
- `manifest.json`
- `styles.css`
Also upload the example files from `dist/`:
- `dist/dead-sea-example.md`
- `dist/dead-sea-example.apkg`
The local Chinese-named example files duplicate the English `dead-sea-example.*` files. The English names are the ones referenced by `README.md`; keep only those two example assets attached to every release that includes downloadable assets.

View file

@ -26,7 +26,7 @@ Anki Heading Sync 是一个 Obsidian 桌面端插件,用来批量地制作 Ank
### 使用要求 ### 使用要求
- Obsidian 桌面端 1.5.0 或更高版本 - Obsidian 桌面端 1.8.7 或更高版本
- Anki 桌面端 - Anki 桌面端
- Anki 中已安装并启用 AnkiConnect - Anki 中已安装并启用 AnkiConnect
@ -48,6 +48,15 @@ Anki Heading Sync 是一个 Obsidian 桌面端插件,用来批量地制作 Ank
- 如果 release 中包含 `styles.css`,也一起复制 - 如果 release 中包含 `styles.css`,也一起复制
4. 回到 Obsidian启用 Anki Heading Sync。 4. 回到 Obsidian启用 Anki Heading Sync。
### 示例文件
Release 中还提供了两个示例文件:
- `dead-sea-example.md`Obsidian 里的示例源文件,可以放进你的 vault 中查看标题制卡写法。
- `dead-sea-example.apkg`Anki 示例牌组,可以导入 Anki用来获得示例卡片、笔记类型和多级列表问答题模板。
如果你想使用多级列表形式的问答题,建议先导入 `dead-sea-example.apkg`。导入后,再在插件设置里把 QA Group 对应到这个多级列表笔记类型和字段。
### AnkiConnect 设置 ### AnkiConnect 设置
1. 在桌面端打开 Anki。 1. 在桌面端打开 Anki。
@ -139,7 +148,7 @@ Currently, the plugin mainly supports four card types:
### Requirements ### Requirements
- Obsidian desktop 1.5.0 or newer - Obsidian desktop 1.8.7 or newer
- Anki desktop - Anki desktop
- AnkiConnect enabled in Anki - AnkiConnect enabled in Anki
@ -161,6 +170,15 @@ Once the plugin is accepted into the Obsidian Community Plugins directory, insta
- `styles.css` when present - `styles.css` when present
4. Enable Anki Heading Sync in Obsidian. 4. Enable Anki Heading Sync in Obsidian.
### Example files
The release also includes two example files:
- `dead-sea-example.md`: an Obsidian Markdown source example that you can place in your vault to inspect the heading-based card format.
- `dead-sea-example.apkg`: a sample Anki deck that you can import into Anki to get example cards, note types, and the multi-level list Q&A template.
If you want to use multi-level list Q&A cards, import `dead-sea-example.apkg` first. After importing it, map the QA Group route in the plugin settings to the imported multi-level list note type and fields.
### AnkiConnect setup ### AnkiConnect setup
1. Open Anki on your desktop. 1. Open Anki on your desktop.

View file

@ -0,0 +1,67 @@
# 文件夹级牌组模式覆盖决策
## 设置结构
- 新增 `PluginSettings.alternateFolderDeckModeFolders: string[]`
- 默认值为 `[]`
- 缺失字段按旧数据迁移到 `[]`
- 校验规则与 `includeFolders` / `excludeFolders` 保持一致:必须是数组,且成员必须是字符串。
- 不额外引入全局路径规范化或去重 helper保持与现有配置层行为一致。
## UI 放置位置
- 在 `PluginSettingTab.renderFolderNode()` 内,仅对 include 模式且当前行已选中的文件夹渲染覆盖控件。
- 新控件追加在文件夹标签后,不替换现有运行范围 checkbox。
- 保留现有 dataset`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`。
- 新增 dataset 供测试使用:
- `folderDeckModeOverride`
- `folderDeckModeOverrideHint`
## 圆形 checkbox 行为
- 控件只读写 `alternateFolderDeckModeFolders`
- 不改动 include 勾选状态。
- 点击时阻止冒泡,避免触发展开按钮或运行范围 checkbox 的副作用。
- 使用原生 checkboxCSS 改成圆形外观并保留 `aria-label` / `title`
## 提示文案
- 全局 `folder-and-file` 时,启用提示显示:`本文件夹单独采用「文件夹」作为牌组名`。
- 全局 `folder` 时,启用提示显示:`本文件夹单独采用「文件夹及文件名」作为牌组名`。
- 全局 `off` 时不显示控件和提示,但保留已保存的覆盖列表。
## 路径匹配规则
- 覆盖匹配使用规范:`filePath === folderPath || filePath.startsWith(folderPath + "/")`。
- 父文件夹覆盖对子孙路径全部生效。
- 不支持“子文件夹反向恢复全局模式”的三态语义,本次仍是二元开关。
## 覆盖模式解析
- 全局 `folder-and-file` 时,命中覆盖文件夹改用 `folder`
- 全局 `folder` 时,命中覆盖文件夹改用 `folder-and-file`
- 全局 `off` 时,忽略覆盖列表。
## include 取消勾选清理
- 当 include 模式取消勾选某个文件夹时,清理该路径及所有后代路径在 `alternateFolderDeckModeFolders` 中的记录。
- 该清理仅影响覆盖列表,不影响其他 include 选择。
- exclude/all 模式下不显示控件,也不触发这套清理逻辑。
## 牌组解析接入点
- 在 `FolderDeckMappingService` 中增加“基于覆盖列表求实际 mode”的能力。
- `DeckResolutionService.resolve()` 接收完整 `PluginSettings`,从而统一处理默认 deck、全局 mode 与覆盖列表。
- 普通卡片通过 `RenderConfigService` 传入完整 settings。
- QA Group 继续复用同一个 `DeckResolutionService`,保持优先级一致。
## 指纹变更
- `createDeckRulesFingerprint()` 纳入 `alternateFolderDeckModeFolders`
- 指纹版本升级到下一版。
- 为减少纯顺序变动引起的无意义重算,指纹内部对覆盖文件夹列表做排序后再哈希;这只影响 fingerprint不改变实际设置保存顺序。
## 与原计划的偏差
- 偏差 1配置层不新增全局路径规范化/去重。原因:现有 `includeFolders` / `excludeFolders` 没有这样做,新字段若单独增强会造成配置行为不一致。
- 偏差 2DeckResolution 直接改为接收完整 `settings`。原因:当前普通卡片和 QA Group 都通过该服务决策 deck把覆盖规则聚合在这里最小且一致避免在多个调用点重复计算“反向 mode”。

View file

@ -0,0 +1,65 @@
# 文件夹级牌组模式覆盖 gap report
## 当前运行范围文件树结构
- 运行范围卡片由 `src/presentation/settings/PluginSettingTab.ts``renderScopeCard()` 渲染。
- scope 模式切换保存在 `PluginSettings.scopeMode`,文件夹选择保存在 `includeFolders` / `excludeFolders`
- 文件树数据来自插件接口 `listFolderTree()`,在设置页内通过 `ensureFolderTreeLoaded()` 懒加载。
- 文件树选择状态由 `src/presentation/settings/FolderScopeTree.ts``buildFolderTreeSelection()``toggleFolderTreeSelection()` 计算。
- 单行 UI 由 `renderFolderNode()` 负责,现有 dataset 包括:`folderRow`、`folderToggle`、`folderPath`、`folderPathLabel`、`folderChildren`。
- 现有文件夹行是三列 grid展开按钮、运行范围 checkbox、文件夹标签。
## 当前文件夹牌组映射流程
- 全局设置字段是 `PluginSettings.folderDeckMode`,取值 `off | folder | folder-and-file`
- 普通卡片路径在 `src/application/services/RenderConfigService.ts` 中通过 `DeckResolutionService.resolve(card, defaultDeck, folderDeckMode)` 决定最终 deck。
- QA Group 路径在 `src/application/services/QaGroupSyncService.ts` 中也直接调用同一个 `DeckResolutionService.resolve(...)`
- `src/domain/manual-sync/services/DeckResolutionService.ts` 当前优先顺序是:
1. `card.deckHint`
2. `FolderDeckMappingService.mapFilePathToDeck(filePath, folderDeckMode)`
3. `defaultDeck`
- `src/domain/manual-sync/services/FolderDeckMappingService.ts` 只按 `filePath``mode` 计算 deck不知道运行范围或文件夹覆盖。
## 当前牌组优先级实现
- 文件级显式 deck hint 仍然是最高优先级,这一层已经在 `DeckResolutionService.resolve()` 最前面短路返回。
- 文件夹映射层完全依赖 `FolderDeckMappingService`
- 只有当前两层都没有产出 deck 时才回退到默认牌组并附加 `deck_fallback_default` warning。
## 覆盖设置应存放的位置
- 新字段应加入 `src/application/config/PluginSettings.ts``PluginSettings` 接口与 `DEFAULT_SETTINGS`
- 加载/保存路径通过 `mergePluginSettings()``normalizePluginSettings()` 进入 `DataJsonPluginConfigRepository`
- 现有 include/exclude 文件夹列表只做“数组 + 字符串”校验,没有统一去重或路径规范化 helper新字段应沿用同样约定避免引入与仓库现状不一致的新归一化语义。
## UI 应插入的位置
- 只应插入到 `renderFolderNode()` 的 include 模式行内。
- 行内现有顺序是 toggle、scope checkbox、label新增控件应追加在 label 后侧,并保持现有 dataset 不变。
- 仅当 `scopeMode === "include"`、当前行已选中、且全局 `folderDeckMode !== "off"` 时显示。
- 取消 include 勾选的清理逻辑不应放在 UI 层分散处理,而应合并进 `updateFolderSelection()`,这样任何同一路径的取消动作都能统一清理子树覆盖记录。
## 指纹更新位置
- deck 规则指纹在 `src/application/services/FileIndexerService.ts``createDeckRulesFingerprint()` 中生成。
- `indexVault()` 通过比较 `existingFileState.deckRulesFingerprint !== deckRulesFingerprint` 强制重读;因此覆盖规则必须进入该指纹。
- 当前版本号为 `deck-rules-v4`,实现覆盖后需要升级版本。
## 预计修改的测试文件
- `src/application/config/PluginSettings.test.ts`
- `src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts`
- `src/presentation/settings/PluginSettingTab.test.ts`
- `src/domain/manual-sync/services/FolderDeckMappingService.test.ts`
- `src/domain/manual-sync/services/DeckResolutionService.test.ts`
- `src/application/services/FileIndexerService.test.ts`
- 可能还要更新 `src/test-support/manualSyncFakes.ts` 的默认设置工厂,以便新字段默认值在各类测试夹具中可用。
## 风险点
- `toggleFolderTreeSelection()` 会压缩选择结果;覆盖清理必须基于“被取消的目标路径及其后代”,不能误删其他分支。
- 父文件夹覆盖应对子孙路径生效,但不能把 `notes` 误匹配到 `notes2`;应复用仓库里现有的 `path === folder || path.startsWith(folder + "/")` 规则。
- `DeckResolutionService.resolve()` 当前签名被多处调用,若扩展参数需要同步更新 `RenderConfigService`、`QaGroupSyncService` 和直接单测。
- 作用范围树的测试大量依赖现有 dataset新增控件不能破坏 `folderRow`、`folderToggle`、`folderPath`、`folderPathLabel` 的查找方式。
- 设置页行布局当前是固定 grid新增圆形 checkbox 后需要补 CSS避免标签挤压或破坏缩进对齐。
- 当全局 `folderDeckMode``off` 时必须忽略覆盖列表,但不能清空已保存数据。

View file

@ -35,7 +35,7 @@ Repository evidence was verified against these files and modules:
- The GitHub repository currently shows no published releases, so the first submission release still needs to be created. - The GitHub repository currently shows no published releases, so the first submission release still needs to be created.
- No exact `anki-heading-sync` ID match and no exact `Anki Heading Sync` name match were found in the current upstream Obsidian community-plugins.json. - No exact `anki-heading-sync` ID match and no exact `Anki Heading Sync` name match were found in the current upstream Obsidian community-plugins.json.
- manifest.json contains the expected release-facing fields: `id`, `name`, `version`, `minAppVersion`, `description`, `author`, `authorUrl`, and `isDesktopOnly`. - manifest.json contains the expected release-facing fields: `id`, `name`, `version`, `minAppVersion`, `description`, `author`, `authorUrl`, and `isDesktopOnly`.
- versions.json currently maps `1.0.0` to `1.5.0`, which matches manifest.json version and minAppVersion. - versions.json currently maps `1.0.1` to `1.8.7`, which matches manifest.json version and minAppVersion.
- package.json defines the release-relevant commands: `lint`, `test`, `build`, and `build:obsidian`. - package.json defines the release-relevant commands: `lint`, `test`, `build`, and `build:obsidian`.
- package-lock.json is present, and no yarn.lock was found. - package-lock.json is present, and no yarn.lock was found.
- LICENSE is present and uses MIT. - LICENSE is present and uses MIT.
@ -87,7 +87,7 @@ Based on the current Obsidian docs, the repository must satisfy these release co
## 4. What still needs fixing ## 4. What still needs fixing
- A GitHub release for version `1.0.0` still needs to be created manually, with assets attached. - A GitHub release for version `1.0.1` still needs to be created manually, with assets attached.
- Manual QA in real Obsidian and real Anki is still required before submission. - Manual QA in real Obsidian and real Anki is still required before submission.
- The actual submission PR to obsidianmd/obsidian-releases still needs to be opened. - The actual submission PR to obsidianmd/obsidian-releases still needs to be opened.
@ -137,7 +137,7 @@ Run these checks in a real desktop Obsidian vault with real Anki running:
- Run `npm test`. - Run `npm test`.
- Run `npm run build`. - Run `npm run build`.
- Verify dist/plugin contains `main.js`, `manifest.json`, and `styles.css` when present. - Verify dist/plugin contains `main.js`, `manifest.json`, and `styles.css` when present.
- Create a GitHub release tag exactly matching `manifest.json.version`, currently `1.0.0`. - Create a GitHub release tag exactly matching `manifest.json.version`, currently `1.0.1`.
- Upload these assets to the release: - Upload these assets to the release:
- main.js - main.js
- manifest.json - manifest.json
@ -180,5 +180,5 @@ Repository-level release preparation is close to complete, but submission should
- local lint, test, and build validation remain green on the release branch - local lint, test, and build validation remain green on the release branch
- manual QA is completed in real Obsidian and real Anki - manual QA is completed in real Obsidian and real Anki
- the GitHub release for version `1.0.0` exists with the correct assets - the GitHub release for version `1.0.1` exists with the correct assets
- the submission PR to obsidianmd/obsidian-releases is opened with the correct metadata - the submission PR to obsidianmd/obsidian-releases is opened with the correct metadata

View file

@ -1,12 +1,13 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "node:process"; import process from "node:process";
import builtins from "builtin-modules"; import { builtinModules } from "node:module";
import { copyFile } from "node:fs/promises"; import { copyFile } from "node:fs/promises";
const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`; const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`;
const production = process.argv[2] === "production"; const production = process.argv[2] === "production";
const outfile = "dist/plugin/main.js"; const outfile = "dist/plugin/main.js";
const builtins = [...new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))];
const context = await esbuild.context({ const context = await esbuild.context({
banner: { js: banner }, banner: { js: banner },

View file

@ -1,5 +1,6 @@
import js from "@eslint/js"; import js from "@eslint/js";
import globals from "globals"; import globals from "globals";
import obsidianmd from "eslint-plugin-obsidianmd";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
@ -8,6 +9,7 @@ export default tseslint.config(
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...obsidianmd.configs.recommendedWithLocalesEn,
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
languageOptions: { languageOptions: {
@ -24,7 +26,9 @@ export default tseslint.config(
"error", "error",
{ "prefer": "type-imports" } { "prefer": "type-imports" }
], ],
"@typescript-eslint/no-explicit-any": "error" "@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/require-await": "error",
"require-await": "off"
}, },
}, },
); );

56
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,10 @@
{ {
"id": "anki-heading-sync", "id": "anki-heading-sync",
"name": "Anki Heading Sync", "name": "Anki Heading Sync",
"version": "1.0.0", "version": "1.0.4",
"minAppVersion": "1.5.0", "minAppVersion": "1.8.7",
"description": "Focused heading-based Obsidian to Anki sync plugin.", "description": "Focused heading-based Markdown to Anki sync plugin.",
"author": "Dusk", "author": "Dusk",
"authorUrl": "https://github.com/panAtGitHub/AnkiHeadingSync", "authorUrl": "https://github.com/panAtGitHub",
"isDesktopOnly": true "isDesktopOnly": true
} }

2986
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "obsidian-anki-heading-sync", "name": "obsidian-anki-heading-sync",
"version": "1.0.0", "version": "1.0.4",
"description": "Focused Obsidian to Anki heading-based sync plugin.", "description": "Focused heading-based Markdown to Anki sync plugin.",
"author": "Dusk", "author": "Dusk",
"main": "dist/plugin/main.js", "main": "dist/plugin/main.js",
"dependencies": { "dependencies": {
@ -26,9 +26,9 @@
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.46.1", "@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1", "@typescript-eslint/parser": "^8.46.1",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.2.9",
"globals": "^16.4.0", "globals": "^16.4.0",
"obsidian": "1.12.3", "obsidian": "1.12.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",

View file

@ -76,6 +76,8 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file"); expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file");
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list"); expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block"); expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
expect(DEFAULT_SETTINGS.alternateFolderDeckModeFolders).toEqual([]);
expect(DEFAULT_SETTINGS.standaloneParentDeckFolders).toEqual([]);
expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL); expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line"); expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
@ -111,6 +113,74 @@ describe("PluginSettings", () => {
}); });
}); });
it("defaults alternate folder deck mode override folders to an empty array when missing", () => {
const settings = mergePluginSettings({
defaultDeck: "Default",
});
expect(settings.alternateFolderDeckModeFolders).toEqual([]);
});
it("defaults standalone parent deck folders to an empty array when missing", () => {
const settings = mergePluginSettings({
defaultDeck: "Default",
});
expect(settings.standaloneParentDeckFolders).toEqual([]);
});
it("preserves alternate folder deck mode override folders using the current folder-list convention", () => {
const settings = mergePluginSettings({
alternateFolderDeckModeFolders: ["notes", "notes/sub", "notes"],
});
expect(settings.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub", "notes"]);
});
it("preserves standalone parent deck folders using the current folder-list convention", () => {
const settings = mergePluginSettings({
standaloneParentDeckFolders: ["notes/sub", "notes/other", "notes/sub"],
});
expect(settings.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other", "notes/sub"]);
});
it("rejects non-array alternate folder deck mode override folders", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
alternateFolderDeckModeFolders: "notes" as never,
});
}, "errors.settings.alternateFolderDeckModeFoldersArray");
});
it("rejects non-string alternate folder deck mode override folder entries", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
alternateFolderDeckModeFolders: ["notes", 1] as never,
});
}, "errors.settings.alternateFolderDeckModeFoldersStrings");
});
it("rejects non-array standalone parent deck folders", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: "notes/sub" as never,
});
}, "errors.settings.standaloneParentDeckFoldersArray");
});
it("rejects non-string standalone parent deck folder entries", () => {
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: ["notes/sub", 1] as never,
});
}, "errors.settings.standaloneParentDeckFoldersStrings");
});
it("normalizes cached Anki note types on load and save paths", () => { it("normalizes cached Anki note types on load and save paths", () => {
const settings = mergePluginSettings({ const settings = mergePluginSettings({
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"], ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],

View file

@ -60,6 +60,8 @@ export interface PluginSettings {
scopeMode: ScopeMode; scopeMode: ScopeMode;
includeFolders: string[]; includeFolders: string[];
excludeFolders: string[]; excludeFolders: string[];
alternateFolderDeckModeFolders: string[];
standaloneParentDeckFolders: string[];
addObsidianBacklink: boolean; addObsidianBacklink: boolean;
obsidianBacklinkLabel: string; obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: ObsidianBacklinkPlacement; obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
@ -89,6 +91,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
scopeMode: "include", scopeMode: "include",
includeFolders: [], includeFolders: [],
excludeFolders: [], excludeFolders: [],
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
addObsidianBacklink: true, addObsidianBacklink: true,
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL, obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
obsidianBacklinkPlacement: "answer-last-line", obsidianBacklinkPlacement: "answer-last-line",
@ -144,6 +148,8 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache, ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders, includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders, excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
alternateFolderDeckModeFolders: partialSettings.alternateFolderDeckModeFolders ?? DEFAULT_SETTINGS.alternateFolderDeckModeFolders,
standaloneParentDeckFolders: partialSettings.standaloneParentDeckFolders ?? DEFAULT_SETTINGS.standaloneParentDeckFolders,
}); });
} }
@ -204,6 +210,8 @@ export function validatePluginSettings(settings: PluginSettings): void {
validateFolderList(settings.includeFolders, "include"); validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude"); validateFolderList(settings.excludeFolders, "exclude");
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
validateFolderList(settings.standaloneParentDeckFolders, "standaloneParentDeck");
if (!settings.ankiConnectUrl.trim()) { if (!settings.ankiConnectUrl.trim()) {
throw new PluginUserError("errors.settings.ankiConnectUrlRequired"); throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
@ -256,14 +264,32 @@ function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache):
} }
} }
function validateFolderList(folderList: string[], label: "include" | "exclude"): void { function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode" | "standaloneParentDeck"): void {
if (!Array.isArray(folderList)) { if (!Array.isArray(folderList)) {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray"); switch (label) {
case "include":
throw new PluginUserError("errors.settings.includeFoldersArray");
case "exclude":
throw new PluginUserError("errors.settings.excludeFoldersArray");
case "alternateFolderDeckMode":
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
case "standaloneParentDeck":
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersArray");
}
} }
for (const folder of folderList) { for (const folder of folderList) {
if (typeof folder !== "string") { if (typeof folder !== "string") {
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings"); switch (label) {
case "include":
throw new PluginUserError("errors.settings.includeFoldersStrings");
case "exclude":
throw new PluginUserError("errors.settings.excludeFoldersStrings");
case "alternateFolderDeckMode":
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
case "standaloneParentDeck":
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersStrings");
}
} }
} }
} }

View file

@ -1,15 +1,26 @@
import * as obsidian from "obsidian";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const { getLanguageMock } = vi.hoisted(() => ({
getLanguageMock: vi.fn(() => "en"),
}));
vi.mock("obsidian", () => ({
getLanguage: getLanguageMock,
}));
import { PluginUserError, renderPluginFileFailure, renderPluginFileFailuresInline, renderUnknownUserFacingError, renderUserFacingMessage, renderUserMessage } from "./PluginUserError"; import { PluginUserError, renderPluginFileFailure, renderPluginFileFailuresInline, renderUnknownUserFacingError, renderUserFacingMessage, renderUserMessage } from "./PluginUserError";
function setObsidianLanguage(language: string): void {
getLanguageMock.mockReturnValue(language);
}
describe("PluginUserError", () => { describe("PluginUserError", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("renders plugin-owned errors in English", () => { it("renders plugin-owned errors in English", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en"); setObsidianLanguage("en");
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" }); const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
@ -17,7 +28,7 @@ describe("PluginUserError", () => {
}); });
it("renders plugin-owned errors in Simplified Chinese", () => { it("renders plugin-owned errors in Simplified Chinese", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh"); setObsidianLanguage("zh");
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" }); const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
@ -25,19 +36,19 @@ describe("PluginUserError", () => {
}); });
it("renders scope-not-configured errors in English and Chinese", () => { it("renders scope-not-configured errors in English and Chinese", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en"); setObsidianLanguage("en");
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe( expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
"Run scope is not configured. In include mode, select at least one folder before syncing.", "Run scope is not configured. In include mode, select at least one folder before syncing.",
); );
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh"); setObsidianLanguage("zh");
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe( expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
"运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。", "运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。",
); );
}); });
it("renders write-back failure summaries with localized detail lines", () => { it("renders write-back failure summaries with localized detail lines", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh"); setObsidianLanguage("zh");
const error = new PluginUserError( const error = new PluginUserError(
"errors.writeBack.summary", "errors.writeBack.summary",
@ -58,7 +69,7 @@ describe("PluginUserError", () => {
}); });
it("renders raw and keyed user-facing messages plus inline failure lists", () => { it("renders raw and keyed user-facing messages plus inline failure lists", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en"); setObsidianLanguage("en");
expect(renderUserFacingMessage({ key: "notice.failedSavePluginSettings" })).toBe("Failed to save plugin settings."); expect(renderUserFacingMessage({ key: "notice.failedSavePluginSettings" })).toBe("Failed to save plugin settings.");
expect(renderUserFacingMessage({ rawMessage: "raw failure" })).toBe("raw failure"); expect(renderUserFacingMessage({ rawMessage: "raw failure" })).toBe("raw failure");
@ -70,13 +81,13 @@ describe("PluginUserError", () => {
}); });
it("falls back to raw error messages for unknown errors", () => { it("falls back to raw error messages for unknown errors", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh"); setObsidianLanguage("zh");
expect(renderUnknownUserFacingError(new Error("socket closed"), "notice.vaultSyncFailed")).toBe("socket closed"); expect(renderUnknownUserFacingError(new Error("socket closed"), "notice.vaultSyncFailed")).toBe("socket closed");
}); });
it("falls back to a translated default when the value is not an Error", () => { it("falls back to a translated default when the value is not an Error", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en"); setObsidianLanguage("en");
expect(renderUnknownUserFacingError(null, "notice.vaultSyncFailed")).toBe("Vault sync failed."); expect(renderUnknownUserFacingError(null, "notice.vaultSyncFailed")).toBe("Vault sync failed.");
}); });

View file

@ -11,7 +11,7 @@ import { AnkiBatchExecutor } from "./AnkiBatchExecutor";
class CountingAnkiGateway extends FakeManualSyncAnkiGateway { class CountingAnkiGateway extends FakeManualSyncAnkiGateway {
public getModelDetailsCalls: string[] = []; public getModelDetailsCalls: string[] = [];
override async getModelDetails(modelName: string): Promise<NoteModelDetails> { override getModelDetails(modelName: string): Promise<NoteModelDetails> {
this.getModelDetailsCalls.push(modelName); this.getModelDetailsCalls.push(modelName);
return super.getModelDetails(modelName); return super.getModelDetails(modelName);
} }
@ -50,7 +50,7 @@ describe("AnkiBatchExecutor", () => {
const result = await executor.execute( const result = await executor.execute(
plan, plan,
renderedCards, renderedCards,
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -90,7 +90,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
renderedCards, renderedCards,
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -120,7 +120,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[createCard.card.syncKey, createRenderedSyncCard(createCard)]]), new Map([[createCard.card.syncKey, createRenderedSyncCard(createCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -153,7 +153,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -192,7 +192,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]), new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -228,7 +228,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]), new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -270,7 +270,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]), new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
)).rejects.toThrow("delete old failed"); )).rejects.toThrow("delete old failed");
@ -304,7 +304,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -336,7 +336,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
{ {
...createModule3Settings().noteFieldMappings, ...createModule3Settings().noteFieldMappings,
"basic:New Basic": { "basic:New Basic": {
@ -390,7 +390,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
{ {
...createModule3Settings().noteFieldMappings, ...createModule3Settings().noteFieldMappings,
"cloze:New Cloze": { "cloze:New Cloze": {
@ -439,7 +439,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
createModule3Settings().noteFieldMappings, createModule3Settings().noteFieldMappings,
); );
@ -473,7 +473,7 @@ describe("AnkiBatchExecutor", () => {
warnings: [], warnings: [],
}, },
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
async (plannedCard) => createRenderedSyncCard(plannedCard), renderPlannedCard,
{ {
...createModule3Settings().noteFieldMappings, ...createModule3Settings().noteFieldMappings,
"basic:New Basic": { "basic:New Basic": {
@ -525,6 +525,10 @@ function createRenderedSyncCard(plannedCard: PlannedCard): RenderedSyncCard {
}; };
} }
function renderPlannedCard(plannedCard: PlannedCard): Promise<RenderedSyncCard> {
return Promise.resolve(createRenderedSyncCard(plannedCard));
}
function createIndexedCard(syncKey: string, noteId?: number, cardType: IndexedCard["cardType"] = "basic"): IndexedCard { function createIndexedCard(syncKey: string, noteId?: number, cardType: IndexedCard["cardType"] = "basic"): IndexedCard {
return { return {
noteId, noteId,

View file

@ -10,7 +10,7 @@ export class BatchScheduler {
} }
const batches = chunk(items, batchSize); const batches = chunk(items, batchSize);
const results: TResult[][] = new Array(batches.length); const results: TResult[][] = [];
let nextIndex = 0; let nextIndex = 0;
await Promise.all( await Promise.all(

View file

@ -299,6 +299,144 @@ describe("FileIndexerService", () => {
expect(result.skippedUnchangedFiles).toBe(0); expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]); expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
}); });
it("changes the fingerprint and forces re-read when alternate folder deck mode overrides changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createIndexSettingsForPath("notes/one.md", { folderDeckMode: "folder" });
const newSettings = createIndexSettingsForPath("notes/one.md", {
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes"],
});
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "One",
backlinkHeadingText: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 10,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
const result = await service.indexVault(newSettings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
it("normalizes alternate folder deck mode override ordering inside the fingerprint", () => {
const first = createIndexSettingsForPath("notes/one.md", {
alternateFolderDeckModeFolders: ["notes/sub", "notes"],
});
const second = createIndexSettingsForPath("notes/one.md", {
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
});
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
});
it("changes the fingerprint and forces re-read when standalone parent deck folders changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createIndexSettingsForPath("notes/one.md", { folderDeckMode: "folder" });
const newSettings = createIndexSettingsForPath("notes/one.md", {
folderDeckMode: "folder",
standaloneParentDeckFolders: ["notes"],
});
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "One",
backlinkHeadingText: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 10,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
const result = await service.indexVault(newSettings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
it("normalizes standalone parent deck folder ordering inside the fingerprint", () => {
const first = createIndexSettingsForPath("notes/one.md", {
standaloneParentDeckFolders: ["notes/sub", "notes"],
});
const second = createIndexSettingsForPath("notes/one.md", {
standaloneParentDeckFolders: ["notes", "notes/sub"],
});
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
});
}); });
function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) { function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) {

View file

@ -291,7 +291,7 @@ export function createFileStamp(mtime: number, size: number): string {
return `${mtime}:${size}`; return `${mtime}:${size}`;
} }
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v4"; const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v6";
export function createDeckRulesFingerprint(settings: PluginSettings): string { export function createDeckRulesFingerprint(settings: PluginSettings): string {
return hashString(JSON.stringify({ return hashString(JSON.stringify({
@ -302,6 +302,8 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
fileDeckEnabled: settings.fileDeckEnabled, fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker, fileDeckMarker: settings.fileDeckMarker,
folderDeckMode: settings.folderDeckMode, folderDeckMode: settings.folderDeckMode,
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
standaloneParentDeckFolders: [...settings.standaloneParentDeckFolders].sort(),
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki, syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody, keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
})); }));

View file

@ -69,17 +69,28 @@ describe("ManualSyncService", () => {
expect(result.created).toBe(1); expect(result.created).toBe(1);
expect(result.rewrittenMarkers).toBe(1); expect(result.rewrittenMarkers).toBe(1);
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE); const addedNote = ankiGateway.addedNotes[0];
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({ expect(addedNote?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
: "Concepts", const addedNoteFields = addedNote?.fields;
01: "Alpha", expect(addedNoteFields).toBeDefined();
答案01: expect.stringContaining("First answer"), if (!addedNoteFields) {
02: "Beta", throw new Error("Expected the created note fields to be recorded.");
答案02: expect.stringContaining("Second answer"), }
});
expect(addedNoteFields["题目"]).toBe("Concepts");
expect(addedNoteFields["问题01"]).toBe("Alpha");
expect(addedNoteFields["答案01"]).toContain("First answer");
expect(addedNoteFields["问题02"]).toBe("Beta");
expect(addedNoteFields["答案02"]).toContain("Second answer");
expect(vaultGateway.getFileContent("notes/example.md")).toMatch(/<!--GI:n=9001;i=[^;]+;f=3-->/); expect(vaultGateway.getFileContent("notes/example.md")).toMatch(/<!--GI:n=9001;i=[^;]+;f=3-->/);
expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1); const savedState = stateRepository.savedState;
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1); expect(savedState).not.toBeNull();
if (!savedState) {
throw new Error("Expected plugin state to be saved.");
}
expect(Object.keys(savedState.groupBlocks ?? {})).toHaveLength(1);
expect(savedState.files["notes/example.md"]?.groupIds).toHaveLength(1);
}); });
it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => { it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => {

View file

@ -126,7 +126,7 @@ export class ManualSyncService {
const executionResult = await this.ankiBatchExecutor.execute( const executionResult = await this.ankiBatchExecutor.execute(
plan, plan,
renderedCards, renderedCards,
async (plannedCard) => this.renderer.render(plannedCard, renderContext), (plannedCard) => Promise.resolve(this.renderer.render(plannedCard, renderContext)),
settings.noteFieldMappings, settings.noteFieldMappings,
); );
const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings); const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings);

View file

@ -538,19 +538,20 @@ describe("QaGroupSyncService", () => {
expect(result.resolvedNoteIds.get("notes/example.md\u0000group\u00001\u0000hash-1")).toBe(42); expect(result.resolvedNoteIds.get("notes/example.md\u0000group\u00001\u0000hash-1")).toBe(42);
expect(ankiGateway.addedNotes).toEqual([]); expect(ankiGateway.addedNotes).toEqual([]);
expect(ankiGateway.updatedNotes).toEqual([]); expect(ankiGateway.updatedNotes).toEqual([]);
expect(ankiGateway.updatedNoteModels).toEqual([ expect(ankiGateway.updatedNoteModels).toHaveLength(1);
{ const updatedQaGroupNoteModel = ankiGateway.updatedNoteModels[0];
noteId: 42, expect(updatedQaGroupNoteModel).toBeDefined();
modelName: QA_GROUP_USER_NOTE_TYPE, if (!updatedQaGroupNoteModel) {
fields: expect.objectContaining({ throw new Error("Expected a QA Group note model update.");
: "Concepts", }
01: "Alpha",
答案01: expect.stringContaining("First answer"), expect(updatedQaGroupNoteModel.noteId).toBe(42);
02: "Beta", expect(updatedQaGroupNoteModel.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
答案02: expect.stringContaining("Second answer"), expect(updatedQaGroupNoteModel.fields["题目"]).toBe("Concepts");
}), expect(updatedQaGroupNoteModel.fields["问题01"]).toBe("Alpha");
}, expect(updatedQaGroupNoteModel.fields["答案01"]).toContain("First answer");
]); expect(updatedQaGroupNoteModel.fields["问题02"]).toBe("Beta");
expect(updatedQaGroupNoteModel.fields["答案02"]).toContain("Second answer");
expect(ankiGateway.syncedNoteTags).toEqual([ expect(ankiGateway.syncedNoteTags).toEqual([
{ {
noteId: 42, noteId: 42,
@ -642,16 +643,20 @@ describe("QaGroupSyncService", () => {
})); }));
expect(result.migratedNoteTypes).toBe(1); expect(result.migratedNoteTypes).toBe(1);
expect(ankiGateway.updatedNoteModels[0]?.modelName).toBe("问答题6组"); const updatedNoteModel = ankiGateway.updatedNoteModels[0];
expect(ankiGateway.updatedNoteModels[0]?.fields).toMatchObject({ expect(updatedNoteModel).toBeDefined();
: "Concepts", if (!updatedNoteModel) {
01: "A", throw new Error("Expected a migrated QA Group note model update.");
答案01: expect.stringContaining("1"), }
05: "E",
答案05: expect.stringContaining("5"), expect(updatedNoteModel.modelName).toBe("问答题6组");
06: "", expect(updatedNoteModel.fields["题目"]).toBe("Concepts");
06: "", expect(updatedNoteModel.fields["问题01"]).toBe("A");
}); expect(updatedNoteModel.fields["答案01"]).toContain("1");
expect(updatedNoteModel.fields["问题05"]).toBe("E");
expect(updatedNoteModel.fields["答案05"]).toContain("5");
expect(updatedNoteModel.fields["问题06"]).toBe("");
expect(updatedNoteModel.fields["答案06"]).toBe("");
}); });
it("surfaces a user-facing error when QA Group note type migration fails", async () => { it("surfaces a user-facing error when QA Group note type migration fails", async () => {

View file

@ -138,7 +138,7 @@ export class QaGroupSyncService {
deckHint: block.deckHint, deckHint: block.deckHint,
deckHintSource: block.deckHintSource, deckHintSource: block.deckHintSource,
deckWarnings: block.deckWarnings, deckWarnings: block.deckWarnings,
} as never, settings.defaultDeck, settings.folderDeckMode); } as never, settings);
for (const warning of deckResolution.warnings) { for (const warning of deckResolution.warnings) {
warningMap.set(getDeckResolutionWarningKey(warning), warning); warningMap.set(getDeckResolutionWarningKey(warning), warning);
} }

View file

@ -21,7 +21,7 @@ export class RenderConfigService {
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan { resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
const noteModel = resolveNoteModel(card.cardType, settings); const noteModel = resolveNoteModel(card.cardType, settings);
const deckResolution = this.deckResolutionService.resolve(card, settings.defaultDeck, settings.folderDeckMode); const deckResolution = this.deckResolutionService.resolve(card, settings);
const deck = deckResolution.resolvedDeck.value; const deck = deckResolution.resolvedDeck.value;
const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null; const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
const clozeMode = card.cardType === "cloze" && card.clozeMode === "all" const clozeMode = card.cardType === "cloze" && card.clozeMode === "all"

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { DeckResolutionService } from "./DeckResolutionService"; import { DeckResolutionService } from "./DeckResolutionService";
@ -12,7 +13,7 @@ describe("DeckResolutionService", () => {
filePath: "课程/数学/第一章/导数.md", filePath: "课程/数学/第一章/导数.md",
deckHint: "显式/Deck", deckHint: "显式/Deck",
deckHintSource: "frontmatter", deckHintSource: "frontmatter",
}), "Default", "folder"); }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "显式::Deck", value: "显式::Deck",
@ -24,7 +25,7 @@ describe("DeckResolutionService", () => {
it("uses folder mapping when no explicit deck exists", () => { it("uses folder mapping when no explicit deck exists", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder"); const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章", value: "课程::数学::第一章",
@ -35,7 +36,7 @@ describe("DeckResolutionService", () => {
it("supports folder-and-file mapping mode", () => { it("supports folder-and-file mapping mode", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder-and-file"); const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), createDeckSettings({ folderDeckMode: "folder-and-file" }));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章::导数", value: "课程::数学::第一章::导数",
@ -46,7 +47,10 @@ describe("DeckResolutionService", () => {
it("falls back to default deck for root-level files and emits a warning", () => { it("falls back to default deck for root-level files and emits a warning", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "导数.md" }), " Default/Deck ", "folder-and-file"); const result = service.resolve(createIndexedCard({ filePath: "导数.md" }), createDeckSettings({
defaultDeck: " Default/Deck ",
folderDeckMode: "folder-and-file",
}));
expect(result.resolvedDeck).toEqual({ expect(result.resolvedDeck).toEqual({
value: "Default::Deck", value: "Default::Deck",
@ -58,7 +62,7 @@ describe("DeckResolutionService", () => {
it("warns and falls back to default deck when folder mapping is invalid", () => { it("warns and falls back to default deck when folder mapping is invalid", () => {
const service = new DeckResolutionService(); const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程::非法/导数.md" }), "Default", "folder"); const result = service.resolve(createIndexedCard({ filePath: "课程::非法/导数.md" }), createDeckSettings({ folderDeckMode: "folder" }));
expect(result.resolvedDeck.value).toBe("Default"); expect(result.resolvedDeck.value).toBe("Default");
expect(result.warnings.map((warning) => warning.code)).toEqual([ expect(result.warnings.map((warning) => warning.code)).toEqual([
@ -70,10 +74,50 @@ describe("DeckResolutionService", () => {
filePath: "课程::非法/导数.md", filePath: "课程::非法/导数.md",
deckHint: "显式::Deck", deckHint: "显式::Deck",
deckHintSource: "body", deckHintSource: "body",
}), "Default", "folder").resolvedDeck.value).toBe("显式::Deck"); }), createDeckSettings({ folderDeckMode: "folder" })).resolvedDeck.value).toBe("显式::Deck");
});
it("uses the opposite folder mapping mode when the file path hits an alternate override folder", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "notes/sub/topic.md" }), createDeckSettings({
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes/sub"],
}));
expect(result.resolvedDeck).toEqual({
value: "notes::sub::topic",
source: "folder",
});
});
it("starts folder mapping from a standalone parent deck folder when configured", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "3Resources/Books/BookA/第1章.md" }), createDeckSettings({
folderDeckMode: "folder",
standaloneParentDeckFolders: ["3Resources/Books"],
}));
expect(result.resolvedDeck).toEqual({
value: "Books::BookA",
source: "folder",
});
}); });
}); });
function createDeckSettings(
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">> = {},
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders"> {
return {
defaultDeck: "Default",
folderDeckMode: "folder-and-file",
alternateFolderDeckModeFolders: [],
standaloneParentDeckFolders: [],
...overrides,
};
}
function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard { function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
return { return {
noteId: overrides.noteId, noteId: overrides.noteId,

View file

@ -1,4 +1,4 @@
import type { FolderDeckMode } from "@/application/config/PluginSettings"; import type { PluginSettings } from "@/application/config/PluginSettings";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution"; import type { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
@ -11,7 +11,10 @@ export class DeckResolutionService {
private readonly deckNormalizationService = new DeckNormalizationService(), private readonly deckNormalizationService = new DeckNormalizationService(),
) {} ) {}
resolve(card: IndexedCard, defaultDeck: string, folderDeckMode: FolderDeckMode): DeckResolutionResult { resolve(
card: IndexedCard,
settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">,
): DeckResolutionResult {
if (card.deckHint) { if (card.deckHint) {
return { return {
resolvedDeck: { resolvedDeck: {
@ -22,7 +25,12 @@ export class DeckResolutionService {
}; };
} }
const folderMapping = this.folderDeckMappingService.mapFilePathToDeck(card.filePath, folderDeckMode); const folderMapping = this.folderDeckMappingService.mapFilePathToDeck(
card.filePath,
settings.folderDeckMode,
settings.alternateFolderDeckModeFolders,
settings.standaloneParentDeckFolders,
);
if (folderMapping.deck) { if (folderMapping.deck) {
return { return {
resolvedDeck: { resolvedDeck: {
@ -35,7 +43,7 @@ export class DeckResolutionService {
return { return {
resolvedDeck: { resolvedDeck: {
value: this.deckNormalizationService.normalize(defaultDeck), value: this.deckNormalizationService.normalize(settings.defaultDeck),
source: "default", source: "default",
}, },
warnings: [ warnings: [

View file

@ -20,6 +20,52 @@ describe("FolderDeckMappingService", () => {
}); });
}); });
it("uses the opposite folder deck mode inside alternate override folders", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder", ["notes/sub"]).deck).toBe("notes::sub::topic");
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder-and-file", ["notes/sub"]).deck).toBe("notes::sub");
});
it("applies alternate overrides to descendants without matching sibling prefixes", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "folder", ["notes"]).deck).toBe("notes::sub::topic");
expect(service.mapFilePathToDeck("notes2/sub/topic.md", "folder", ["notes"]).deck).toBe("notes2::sub");
});
it("ignores alternate overrides when folder deck mode is off", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("notes/sub/topic.md", "off", ["notes/sub"])).toEqual({ warnings: [] });
});
it("starts deck paths from a standalone parent deck folder", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder", [], ["3Resources/Books"])).toEqual({
deck: "Books::BookA",
warnings: [],
});
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder-and-file", [], ["3Resources/Books"])).toEqual({
deck: "Books::BookA::第1章",
warnings: [],
});
expect(service.mapFilePathToDeck("3Resources/Books/a.md", "folder", [], ["3Resources/Books"])).toEqual({
deck: "Books",
warnings: [],
});
});
it("prefers the deepest standalone parent deck folder", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("3Resources/Books/Sub/a.md", "folder-and-file", [], ["3Resources", "3Resources/Books"])).toEqual({
deck: "Books::Sub::a",
warnings: [],
});
});
it("returns empty for root-level files", () => { it("returns empty for root-level files", () => {
const service = new FolderDeckMappingService(); const service = new FolderDeckMappingService();

View file

@ -11,24 +11,32 @@ export interface FolderDeckMappingResult {
export class FolderDeckMappingService { export class FolderDeckMappingService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {} constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
mapFilePathToDeck(filePath: string, mode: FolderDeckMode): FolderDeckMappingResult { mapFilePathToDeck(
if (mode === "off") { filePath: string,
mode: FolderDeckMode,
alternateFolderDeckModeFolders: string[] = [],
standaloneParentDeckFolders: string[] = [],
): FolderDeckMappingResult {
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
if (effectiveMode === "off") {
return { warnings: [] }; return { warnings: [] };
} }
const lastSlash = filePath.lastIndexOf("/"); const normalizedFilePath = normalizeFilePath(filePath);
const lastSlash = normalizedFilePath.lastIndexOf("/");
if (lastSlash < 0) { if (lastSlash < 0) {
return { warnings: [] }; return { warnings: [] };
} }
const folderPath = filePath.slice(0, lastSlash); const folderPath = normalizedFilePath.slice(0, lastSlash);
if (!folderPath.trim()) { if (!folderPath.trim()) {
return { warnings: [] }; return { warnings: [] };
} }
const segments = folderPath.split("/").filter(Boolean); const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
if (mode === "folder-and-file") { if (effectiveMode === "folder-and-file") {
const fileName = filePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim(); const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
if (fileName) { if (fileName) {
segments.push(fileName); segments.push(fileName);
} }
@ -49,3 +57,62 @@ export class FolderDeckMappingService {
}; };
} }
} }
function resolveEffectiveFolderDeckMode(filePath: string, mode: FolderDeckMode, alternateFolderDeckModeFolders: string[]): FolderDeckMode {
if (mode === "off") {
return "off";
}
const normalizedFilePath = normalizeFilePath(filePath);
const hasAlternateOverride = alternateFolderDeckModeFolders.some((folderPath) => {
const normalizedFolderPath = normalizeFolderPath(folderPath);
return normalizedFolderPath.length > 0 && isPathInsideFolder(normalizedFilePath, normalizedFolderPath);
});
if (!hasAlternateOverride) {
return mode;
}
return mode === "folder" ? "folder-and-file" : "folder";
}
function normalizeFolderPath(folderPath: string): string {
return folderPath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function resolveDeckSegments(folderPath: string, standaloneParentDeckFolders: string[]): string[] {
const allSegments = folderPath.split("/").filter(Boolean);
const standaloneParentDeckFolder = resolveStandaloneParentDeckFolder(folderPath, standaloneParentDeckFolders);
if (!standaloneParentDeckFolder) {
return allSegments;
}
const standaloneSegments = standaloneParentDeckFolder.split("/").filter(Boolean);
const startIndex = Math.max(standaloneSegments.length - 1, 0);
return allSegments.slice(startIndex);
}
function resolveStandaloneParentDeckFolder(folderPath: string, standaloneParentDeckFolders: string[]): string | undefined {
let matchedFolder: string | undefined;
for (const folder of standaloneParentDeckFolders) {
const normalizedFolderPath = normalizeFolderPath(folder);
if (!normalizedFolderPath || !isPathInsideFolder(folderPath, normalizedFolderPath)) {
continue;
}
if (!matchedFolder || normalizedFolderPath.length > matchedFolder.length) {
matchedFolder = normalizedFolderPath;
}
}
return matchedFolder;
}
function normalizeFilePath(filePath: string): string {
return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
return filePath === folderPath || filePath.startsWith(`${folderPath}/`);
}

View file

@ -10,6 +10,26 @@ vi.mock("obsidian", () => ({
import { AnkiConnectGateway } from "./AnkiConnectGateway"; import { AnkiConnectGateway } from "./AnkiConnectGateway";
function getRequestBody(callIndex: number): unknown {
const maybeCall = requestUrlMock.mock.calls[callIndex] as unknown;
if (!Array.isArray(maybeCall)) {
throw new Error(`Missing request call for index ${callIndex}.`);
}
const [request] = maybeCall as unknown[];
if (!request || typeof request !== "object" || !("body" in request)) {
throw new Error(`Missing request body for call ${callIndex}.`);
}
const requestRecord = request as Record<string, unknown>;
const body = requestRecord["body"];
if (typeof body !== "string") {
throw new Error(`Request body for call ${callIndex} is not a string.`);
}
return JSON.parse(body) as unknown;
}
describe("AnkiConnectGateway", () => { describe("AnkiConnectGateway", () => {
beforeEach(() => { beforeEach(() => {
requestUrlMock.mockReset(); requestUrlMock.mockReset();
@ -27,7 +47,7 @@ describe("AnkiConnectGateway", () => {
const models = await gateway.listNoteModels(); const models = await gateway.listNoteModels();
expect(models).toEqual(["Basic", "Cloze", "Custom Basic"]); expect(models).toEqual(["Basic", "Cloze", "Custom Basic"]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "modelNames", action: "modelNames",
version: 6, version: 6,
params: {}, params: {},
@ -59,7 +79,7 @@ describe("AnkiConnectGateway", () => {
Basic: ["Front", "Back"], Basic: ["Front", "Back"],
Cloze: ["Text", "Extra"], Cloze: ["Text", "Extra"],
}); });
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -118,7 +138,7 @@ describe("AnkiConnectGateway", () => {
const deckNames = await gateway.listDeckNames(); const deckNames = await gateway.listDeckNames();
expect(deckNames).toEqual(["Default", "Scoped::Deck", "Scoped::Deck::Leaf"]); expect(deckNames).toEqual(["Default", "Scoped::Deck", "Scoped::Deck::Leaf"]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "deckNamesAndIds", action: "deckNamesAndIds",
version: 6, version: 6,
params: {}, params: {},
@ -170,14 +190,14 @@ describe("AnkiConnectGateway", () => {
{ noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"], tags: ["tag-a"] }, { noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"], tags: ["tag-a"] },
{ noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"], tags: ["tag-b", "tag-c"] }, { noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"], tags: ["tag-b", "tag-c"] },
]); ]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "notesInfo", action: "notesInfo",
version: 6, version: 6,
params: { params: {
notes: [100, 101, 102], notes: [100, 101, 102],
}, },
}); });
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ expect(getRequestBody(1)).toEqual({
action: "cardsInfo", action: "cardsInfo",
version: 6, version: 6,
params: { params: {
@ -205,7 +225,7 @@ describe("AnkiConnectGateway", () => {
}, },
}); });
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "updateNoteModel", action: "updateNoteModel",
version: 6, version: 6,
params: { params: {
@ -263,7 +283,7 @@ describe("AnkiConnectGateway", () => {
}, },
]); ]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -330,12 +350,12 @@ describe("AnkiConnectGateway", () => {
{ deckName: "Empty", noteCount: 0 }, { deckName: "Empty", noteCount: 0 },
{ deckName: "Busy", noteCount: 3 }, { deckName: "Busy", noteCount: 3 },
]); ]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "deckNamesAndIds", action: "deckNamesAndIds",
version: 6, version: 6,
params: {}, params: {},
}); });
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ expect(getRequestBody(1)).toEqual({
action: "getDeckStats", action: "getDeckStats",
version: 6, version: 6,
params: { params: {
@ -572,7 +592,7 @@ describe("AnkiConnectGateway", () => {
]); ]);
expect(noteIds).toEqual([9001, 9002]); expect(noteIds).toEqual([9001, 9002]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -642,7 +662,7 @@ describe("AnkiConnectGateway", () => {
}, },
]); ]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -659,7 +679,7 @@ describe("AnkiConnectGateway", () => {
], ],
}, },
}); });
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ expect(getRequestBody(1)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -674,7 +694,7 @@ describe("AnkiConnectGateway", () => {
], ],
}, },
}); });
expect(JSON.parse(requestUrlMock.mock.calls[2][0].body)).toEqual({ expect(getRequestBody(2)).toEqual({
action: "multi", action: "multi",
version: 6, version: 6,
params: { params: {
@ -707,7 +727,7 @@ describe("AnkiConnectGateway", () => {
}); });
expect(requestUrlMock).toHaveBeenCalledTimes(1); expect(requestUrlMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "updateNoteFields", action: "updateNoteFields",
version: 6, version: 6,
params: { params: {
@ -738,14 +758,14 @@ describe("AnkiConnectGateway", () => {
await gateway.deleteNotes([1, 2]); await gateway.deleteNotes([1, 2]);
await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]); await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ expect(getRequestBody(0)).toEqual({
action: "deleteNotes", action: "deleteNotes",
version: 6, version: 6,
params: { params: {
notes: [1, 2], notes: [1, 2],
}, },
}); });
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ expect(getRequestBody(1)).toEqual({
action: "deleteDecks", action: "deleteDecks",
version: 6, version: 6,
params: { params: {

View file

@ -6,8 +6,8 @@ export class ObsidianPluginDataStore<TData extends object> implements PluginData
constructor(private readonly plugin: Plugin) {} constructor(private readonly plugin: Plugin) {}
async load(): Promise<TData | null> { async load(): Promise<TData | null> {
const data = await this.plugin.loadData(); const data: unknown = await this.plugin.loadData();
return (data as TData | null) ?? null; return data !== null && typeof data === "object" ? data as TData : null;
} }
async save(data: TData): Promise<void> { async save(data: TData): Promise<void> {

View file

@ -13,7 +13,7 @@ describe("ObsidianVaultGateway", () => {
const gateway = new ObsidianVaultGateway({ const gateway = new ObsidianVaultGateway({
vault: { vault: {
getAbstractFileByPath: () => file, getAbstractFileByPath: () => file,
cachedRead: async () => "# Title\nBody", cachedRead: () => Promise.resolve("# Title\nBody"),
}, },
metadataCache: { metadataCache: {
getFileCache: () => ({ getFileCache: () => ({
@ -43,7 +43,7 @@ describe("ObsidianVaultGateway", () => {
const gateway = new ObsidianVaultGateway({ const gateway = new ObsidianVaultGateway({
vault: { vault: {
getAbstractFileByPath: () => file, getAbstractFileByPath: () => file,
cachedRead: async () => "# Title\nBody", cachedRead: () => Promise.resolve("# Title\nBody"),
}, },
metadataCache: { metadataCache: {
getFileCache: () => null, getFileCache: () => null,
@ -97,10 +97,10 @@ describe("ObsidianVaultGateway", () => {
const gateway = new ObsidianVaultGateway({ const gateway = new ObsidianVaultGateway({
vault: { vault: {
getAbstractFileByPath: () => file, getAbstractFileByPath: () => file,
process: async (targetFile: TFile, updater: (data: string) => string) => { process: (targetFile: TFile, updater: (data: string) => string) => {
processedFile = targetFile; processedFile = targetFile;
writtenContent = updater("# Title\nBody"); writtenContent = updater("# Title\nBody");
return writtenContent; return Promise.resolve(writtenContent);
}, },
}, },
} as never); } as never);
@ -117,7 +117,7 @@ describe("ObsidianVaultGateway", () => {
const gateway = new ObsidianVaultGateway({ const gateway = new ObsidianVaultGateway({
vault: { vault: {
getAbstractFileByPath: () => file, getAbstractFileByPath: () => file,
process: async (_targetFile: TFile, updater: (data: string) => string) => updater("# Title\nChanged"), process: (_targetFile: TFile, updater: (data: string) => string) => Promise.resolve(updater("# Title\nChanged")),
}, },
} as never); } as never);

View file

@ -15,20 +15,20 @@ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "we
export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway { export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway {
constructor(private readonly app: App) {} constructor(private readonly app: App) {}
async listFolderTree(): Promise<FolderTreeNode[]> { listFolderTree(): Promise<FolderTreeNode[]> {
return this.app.vault return Promise.resolve(this.app.vault
.getRoot() .getRoot()
.children.filter((child): child is TFolder => child instanceof TFolder) .children.filter((child): child is TFolder => child instanceof TFolder)
.map((folder) => this.toFolderTreeNode(folder)); .map((folder) => this.toFolderTreeNode(folder)));
} }
async listMarkdownFileRefs(): Promise<MarkdownFileReference[]> { listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
return this.app.vault.getMarkdownFiles().map((file) => ({ return Promise.resolve(this.app.vault.getMarkdownFiles().map((file) => ({
path: file.path, path: file.path,
basename: file.basename, basename: file.basename,
mtime: file.stat.mtime, mtime: file.stat.mtime,
size: file.stat.size, size: file.stat.size,
})); })));
} }
async listMarkdownFiles() { async listMarkdownFiles() {

View file

@ -8,12 +8,13 @@ import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "./DataJ
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> { class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
constructor(private snapshot: PluginDataSnapshot | null = null) {} constructor(private snapshot: PluginDataSnapshot | null = null) {}
async load(): Promise<PluginDataSnapshot | null> { load(): Promise<PluginDataSnapshot | null> {
return this.snapshot; return Promise.resolve(this.snapshot);
} }
async save(data: PluginDataSnapshot): Promise<void> { save(data: PluginDataSnapshot): Promise<void> {
this.snapshot = data; this.snapshot = data;
return Promise.resolve();
} }
} }
@ -62,6 +63,34 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.folderDeckMode).toBe("off"); expect(settings.folderDeckMode).toBe("off");
}); });
it("persists alternate folder deck mode overrides across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
});
const reloaded = await repository.load();
expect(reloaded.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub"]);
});
it("persists standalone parent deck folders across save and reload", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginConfigRepository(store);
await repository.save({
...DEFAULT_SETTINGS,
standaloneParentDeckFolders: ["notes/sub", "notes/other"],
});
const reloaded = await repository.load();
expect(reloaded.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other"]);
});
it("normalizes blank backlink labels on load and save", async () => { it("normalizes blank backlink labels on load and save", async () => {
const store = new InMemoryPluginDataStore({ const store = new InMemoryPluginDataStore({
settings: { settings: {

View file

@ -10,12 +10,13 @@ import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> { class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
constructor(private snapshot: PluginDataSnapshot | null = null) {} constructor(private snapshot: PluginDataSnapshot | null = null) {}
async load(): Promise<PluginDataSnapshot | null> { load(): Promise<PluginDataSnapshot | null> {
return this.snapshot; return Promise.resolve(this.snapshot);
} }
async save(data: PluginDataSnapshot): Promise<void> { save(data: PluginDataSnapshot): Promise<void> {
this.snapshot = data; this.snapshot = data;
return Promise.resolve();
} }
} }

View file

@ -53,7 +53,9 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
return createEmptyPluginState(); return createEmptyPluginState();
} }
const rawCards = pluginState.cards ?? {}; const rawCards = toLegacyCardRecord(pluginState.cards);
const rawFiles = toLegacyFileRecord(pluginState.files);
const rawGroupBlocks = toLegacyGroupBlockRecord(pluginState.groupBlocks);
const cards: Record<string, CardState> = {}; const cards: Record<string, CardState> = {};
for (const rawCard of Object.values(rawCards)) { for (const rawCard of Object.values(rawCards)) {
@ -75,7 +77,7 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
} }
const files: Record<string, FileState> = {}; const files: Record<string, FileState> = {};
for (const [filePath, rawFile] of Object.entries(pluginState.files ?? {})) { for (const [filePath, rawFile] of Object.entries(rawFiles)) {
files[filePath] = { files[filePath] = {
filePath, filePath,
fileHash: typeof rawFile.fileHash === "string" ? rawFile.fileHash : "", fileHash: typeof rawFile.fileHash === "string" ? rawFile.fileHash : "",
@ -83,12 +85,12 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined, deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined,
lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0, lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0,
noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards), noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards),
groupIds: collectMigratedFileGroupIds(rawFile, pluginState.groupBlocks ?? {}), groupIds: collectMigratedFileGroupIds(rawFile, rawGroupBlocks),
}; };
} }
const groupBlocks: Record<string, GroupBlockState> = {}; const groupBlocks: Record<string, GroupBlockState> = {};
for (const [groupId, rawGroupBlock] of Object.entries(pluginState.groupBlocks ?? {})) { for (const [groupId, rawGroupBlock] of Object.entries(rawGroupBlocks)) {
const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId); const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId);
if (!nextGroupBlock) { if (!nextGroupBlock) {
continue; continue;
@ -199,7 +201,7 @@ function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: s
return []; return [];
} }
const nextItem = item as GroupBlockState["items"][number]; const nextItem = item as unknown as Record<string, unknown>;
if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") { if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") {
return []; return [];
} }
@ -307,6 +309,44 @@ function collectMigratedFileGroupIds(
return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId])); return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId]));
} }
function toLegacyCardRecord(cards: PluginState["cards"] | LegacyPluginState["cards"] | undefined): Record<string, LegacyCardState> {
const next: Record<string, LegacyCardState> = {};
const source = cards ?? {};
for (const cardId in source) {
const rawCard = source[cardId];
next[cardId] = rawCard;
}
return next;
}
function toLegacyFileRecord(files: PluginState["files"] | LegacyPluginState["files"] | undefined): Record<string, LegacyFileState> {
const next: Record<string, LegacyFileState> = {};
const source = files ?? {};
for (const filePath in source) {
const rawFile = source[filePath];
next[filePath] = rawFile;
}
return next;
}
function toLegacyGroupBlockRecord(
groupBlocks: PluginState["groupBlocks"] | LegacyPluginState["groupBlocks"] | undefined,
): Record<string, LegacyGroupBlockState> {
const next: Record<string, LegacyGroupBlockState> = {};
const source = groupBlocks ?? {};
for (const groupId in source) {
const rawGroupBlock = source[groupId];
next[groupId] = rawGroupBlock;
}
return next;
}
function sanitizeNoteId(value: unknown): number | undefined { function sanitizeNoteId(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
} }

View file

@ -1,25 +1,17 @@
import * as obsidian from "obsidian"; import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatList, resolvePluginLocale } from "./locale"; import { formatList, resolvePluginLocale } from "./locale";
describe("locale", () => { describe("locale", () => {
afterEach(() => { it("returns zh for zh-based language codes", () => {
vi.restoreAllMocks(); expect(resolvePluginLocale("zh")).toBe("zh");
expect(resolvePluginLocale("zh-CN")).toBe("zh");
expect(resolvePluginLocale("zh-TW")).toBe("zh");
}); });
it("returns zh only when Obsidian language is exactly zh", () => { it("falls back to en for every other language code", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh"); for (const language of ["en", "en-GB", "ja"]) {
expect(resolvePluginLocale(language)).toBe("en");
expect(resolvePluginLocale()).toBe("zh");
});
it("falls back to en for every other Obsidian language code", () => {
const getLanguage = vi.spyOn(obsidian, "getLanguage");
for (const language of ["en", "en-GB", "ja", "zh-TW"]) {
getLanguage.mockReturnValue(language);
expect(resolvePluginLocale()).toBe("en");
} }
}); });

View file

@ -2,8 +2,9 @@ import { getLanguage } from "obsidian";
export type PluginLocale = "en" | "zh"; export type PluginLocale = "en" | "zh";
export function resolvePluginLocale(): PluginLocale { export function resolvePluginLocale(languageCode = getLanguage()): PluginLocale {
return getLanguage() === "zh" ? "zh" : "en"; const normalizedLanguage = languageCode.toLowerCase();
return normalizedLanguage === "zh" || normalizedLanguage.startsWith("zh-") ? "zh" : "en";
} }
export function formatList(locale: PluginLocale, items: string[]): string { export function formatList(locale: PluginLocale, items: string[]): string {

View file

@ -10,34 +10,34 @@ export const en = {
cleanupEmptyDecks: "Clean up empty decks", cleanupEmptyDecks: "Clean up empty decks",
}, },
settings: { settings: {
pluginTitle: "Anki Heading Sync", pluginTitle: "Anki heading sync",
ankiConnectUrl: { ankiConnectUrl: {
name: "AnkiConnect URL", name: "AnkiConnect URL",
desc: "Default is http://127.0.0.1:8765", desc: "Default: http://127.0.0.1:8765",
placeholder: "http://127.0.0.1:8765", placeholder: "Example: http://127.0.0.1:8765",
}, },
qaHeadingLevel: { qaHeadingLevel: {
name: "QA heading level", name: "Heading level for question-and-answer cards",
desc: "Default is H4", desc: "Default question-and-answer heading level is 4.",
}, },
clozeHeadingLevel: { clozeHeadingLevel: {
name: "Cloze heading level", name: "Cloze heading level",
desc: "Default is H5", desc: "Default cloze heading level is 5.",
}, },
cardAnswerCutoffMode: { cardAnswerCutoffMode: {
name: "Card answer cutoff mode", name: "Card answer cutoff mode",
desc: "A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines.", desc: "A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines.",
options: { options: {
headingBlock: "heading-block: whole heading block", headingBlock: "Heading-block: whole heading block",
doubleBlankLines: "double-blank-lines: stop before the first 2+ blank lines", doubleBlankLines: "Double-blank-lines: stop before the first 2+ blank lines",
}, },
}, },
qaGroup: { qaGroup: {
title: "QA Group list notes", title: "List notes for question-and-answer groups",
marker: { marker: {
name: "QA Group marker", name: "Marker for question-and-answer groups",
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one QA Group list note.", desc: "When a question-and-answer heading ends with this hashtag marker, the whole heading block syncs as one list note for that group.",
placeholder: "#anki-list", placeholder: "Hashtag marker",
}, },
managedNoteType: "Current note type: {{modelName}}. This route syncs QA Group list notes using the field mapping shown below.", managedNoteType: "Current note type: {{modelName}}. This route syncs QA Group list notes using the field mapping shown below.",
managedModelContract: "Current note type summary: {{fieldCount}} fields and {{templateCount}} templates are available for this route.", managedModelContract: "Current note type summary: {{fieldCount}} fields and {{templateCount}} templates are available for this route.",
@ -49,7 +49,7 @@ export const en = {
}, },
obsidianBacklinkLabel: { obsidianBacklinkLabel: {
name: "Obsidian backlink label", name: "Obsidian backlink label",
desc: "The text shown for the backlink. Blank input falls back to Open in Obsidian.", desc: "The text shown for the backlink. Blank input uses the default backlink label.",
placeholder: "Open in Obsidian", placeholder: "Open in Obsidian",
}, },
obsidianBacklinkPlacement: { obsidianBacklinkPlacement: {
@ -62,7 +62,7 @@ export const en = {
}, },
}, },
highlightsToCloze: { highlightsToCloze: {
name: "Highlights to Cloze", name: "Highlights to cloze",
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.", desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
}, },
syncObsidianTagsToAnki: { syncObsidianTagsToAnki: {
@ -71,7 +71,7 @@ export const en = {
}, },
keepPureTagLinesInCardBody: { keepPureTagLinesInCardBody: {
name: "Keep pure tag lines in card body", name: "Keep pure tag lines in card body",
desc: "When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically.", desc: "When turned off, remove every line in the card body that contains only tags, such as \"#example #tag/case\". Inline tags like \"this is a #tag example\" and lines with ordinary text like \"tag label: #project\" are kept. Extra blank lines created by removal are cleaned up automatically.",
}, },
}, },
mapping: { mapping: {
@ -97,8 +97,8 @@ export const en = {
}, },
section: { section: {
basic: { basic: {
title: "QA / Basic", title: "Basic and question-and-answer",
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.", description: "Choose the note type for question-and-answer cards, read its fields from Anki, then confirm the title and body mapping.",
}, },
cloze: { cloze: {
title: "Cloze", title: "Cloze",
@ -123,7 +123,7 @@ export const en = {
name: "Cloze main field", name: "Cloze main field",
desc: "The selected field receives title + <br><hr> + body during sync.", desc: "The selected field receives title + <br><hr> + body during sync.",
}, },
selectFieldPlaceholder: "-- Select field --", selectFieldPlaceholder: "-- select field --",
}, },
deck: { deck: {
defaultSectionTitle: "Default deck", defaultSectionTitle: "Default deck",
@ -138,7 +138,7 @@ export const en = {
}, },
marker: { marker: {
name: "Deck marker name", name: "Deck marker name",
desc: "YAML key and body marker share the same identifier. The default value is TARGET DECK.", desc: "YAML key and body marker share the same identifier. By default, use the built-in target deck marker.",
}, },
template: { template: {
name: "Default deck template", name: "Default deck template",
@ -196,6 +196,22 @@ export const en = {
failedLoad: "Failed to load folders from the current vault.", failedLoad: "Failed to load folders from the current vault.",
expandFolder: "Expand {{name}}", expandFolder: "Expand {{name}}",
collapseFolder: "Collapse {{name}}", collapseFolder: "Collapse {{name}}",
folderDeckModeOverride: {
ariaLabel: "Toggle the alternate folder deck mapping mode for {{name}}",
hint: {
folder: "This folder uses the folder-only deck name by itself",
folderAndFile: "This folder uses the folder-and-file deck name by itself",
},
title: {
folder: "Global mode is folder-and-file; enable this to use folder-only deck names for this folder",
folderAndFile: "Global mode is folder-only; enable this to use folder-and-file deck names for this folder",
},
},
standaloneParentDeck: {
ariaLabel: "Use {{name}} as a standalone parent deck root",
hint: "This folder is used as a standalone parent deck",
title: "Enable this to treat this folder as a standalone parent deck root in Anki",
},
}, },
cards: { cards: {
cardTypes: { cardTypes: {
@ -203,7 +219,7 @@ export const en = {
desc: "Edit recognition rules directly. Changes save automatically.", desc: "Edit recognition rules directly. Changes save automatically.",
advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.", advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.",
markerPlaceholder: "/", markerPlaceholder: "/",
fieldsUnavailable: "-- Read fields first --", fieldsUnavailable: "-- read fields first --",
autoManagedField: "Written automatically", autoManagedField: "Written automatically",
autoComposedAnswer: "Auto composed", autoComposedAnswer: "Auto composed",
qaGroupManagedNoteType: "{{modelName}} (for QA Group list notes)", qaGroupManagedNoteType: "{{modelName}} (for QA Group list notes)",
@ -241,7 +257,7 @@ export const en = {
qaGroupFirstQuestionField: "First question field:", qaGroupFirstQuestionField: "First question field:",
qaGroupFirstAnswerField: "First answer field:", qaGroupFirstAnswerField: "First answer field:",
qaGroupConfiguredSlots: "Configured pairs:", qaGroupConfiguredSlots: "Configured pairs:",
qaGroupSlotFields: "Q/A fields:", qaGroupSlotFields: "Question/answer fields:",
qaGroupWarnings: "Warnings:", qaGroupWarnings: "Warnings:",
}, },
qaGroup: { qaGroup: {
@ -258,8 +274,8 @@ export const en = {
}, },
sharedClozeMappingHint: "Reuse the current Anki note type and main field from the sequential cloze row. No separate setup is needed.", sharedClozeMappingHint: "Reuse the current Anki note type and main field from the sequential cloze row. No separate setup is needed.",
rows: { rows: {
basic: "Q&A (regular paragraph)", basic: "Question and answer (regular paragraph)",
qaGroup: "Q&A (nested list)", qaGroup: "Question and answer (nested list)",
cloze: "Cloze (sequential deletions)", cloze: "Cloze (sequential deletions)",
clozeAll: "Cloze (all deletions)", clozeAll: "Cloze (all deletions)",
}, },
@ -353,15 +369,15 @@ export const en = {
}, },
settings: { settings: {
headingLevelsRange: "Heading levels must be integers between 1 and 6.", headingLevelsRange: "Heading levels must be integers between 1 and 6.",
headingLevelsDifferent: "QA and Cloze heading levels must be different.", headingLevelsDifferent: "The question-and-answer and cloze heading levels must be different.",
cardTypeConfigsObject: "Card type configs must be an object.", cardTypeConfigsObject: "Card type configs must be an object.",
cardTypeEnabledBoolean: "Each card type enabled flag must be a boolean.", cardTypeEnabledBoolean: "Each card type enabled flag must be a boolean.",
cardTypeExtraMarkerString: "Each card type extra marker must be a string.", cardTypeExtraMarkerString: "Each card type extra marker must be a string.",
cardTypeDefaultConflict: "Only one enabled default card type is allowed at heading H{{headingLevel}}.", cardTypeDefaultConflict: "Only one enabled default card type is allowed at heading H{{headingLevel}}.",
cardAnswerCutoffModeInvalid: "Card answer cutoff mode must be heading-block or double-blank-lines.", cardAnswerCutoffModeInvalid: "Card answer cutoff mode must be heading-block or double-blank-lines.",
qaNoteTypeRequired: "QA note type is required.", qaNoteTypeRequired: "A question-and-answer note type is required.",
qaGroupMarkerRequired: "QA Group marker is required.", qaGroupMarkerRequired: "A question-and-answer group marker is required.",
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.", qaGroupMarkerInvalid: "The question-and-answer group marker must be a hashtag-style token.",
clozeNoteTypeRequired: "Cloze note type is required.", clozeNoteTypeRequired: "Cloze note type is required.",
defaultDeckRequired: "Default deck is required.", defaultDeckRequired: "Default deck is required.",
fileDeckEnabledBoolean: "File deck enabled must be a boolean.", fileDeckEnabledBoolean: "File deck enabled must be a boolean.",
@ -369,7 +385,7 @@ export const en = {
keepPureTagLinesInCardBodyBoolean: "Keep pure tag lines in card body must be a boolean.", keepPureTagLinesInCardBodyBoolean: "Keep pure tag lines in card body must be a boolean.",
fileDeckMarkerString: "File deck marker must be a string.", fileDeckMarkerString: "File deck marker must be a string.",
fileDeckTemplateString: "File deck template must be a string.", fileDeckTemplateString: "File deck template must be a string.",
fileDeckInsertLocationInvalid: "File deck insert location must be yaml or body.", fileDeckInsertLocationInvalid: "File deck insert location must be YAML or body.",
folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.", folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.",
scopeModeInvalid: "Scope mode must be one of all, include, or exclude.", scopeModeInvalid: "Scope mode must be one of all, include, or exclude.",
obsidianBacklinkPlacementInvalid: "Obsidian backlink placement must be question-last-line, answer-first-line, or answer-last-line.", obsidianBacklinkPlacementInvalid: "Obsidian backlink placement must be question-last-line, answer-first-line, or answer-last-line.",
@ -377,6 +393,10 @@ export const en = {
includeFoldersStrings: "Include folders must only contain strings.", includeFoldersStrings: "Include folders must only contain strings.",
excludeFoldersArray: "Exclude folders must be an array.", excludeFoldersArray: "Exclude folders must be an array.",
excludeFoldersStrings: "Exclude folders must only contain strings.", excludeFoldersStrings: "Exclude folders must only contain strings.",
alternateFolderDeckModeFoldersArray: "Alternate folder deck mode folders must be an array.",
alternateFolderDeckModeFoldersStrings: "Alternate folder deck mode folders must only contain strings.",
standaloneParentDeckFoldersArray: "Standalone parent deck folders must be an array.",
standaloneParentDeckFoldersStrings: "Standalone parent deck folders must only contain strings.",
ankiConnectUrlRequired: "AnkiConnect URL is required.", ankiConnectUrlRequired: "AnkiConnect URL is required.",
ankiNoteTypeCacheArray: "Anki note type cache must be an array.", ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.", ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.",
@ -390,25 +410,25 @@ export const en = {
noteFieldMappingsModelName: "Note field mappings must include a model name.", noteFieldMappingsModelName: "Note field mappings must include a model name.",
noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.", noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.",
noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.", noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.",
noteFieldMappingsQaGroupSlotsArray: "QA Group field mapping slots must be an array.", noteFieldMappingsQaGroupSlotsArray: "Question-and-answer group field mapping slots must be an array.",
noteFieldMappingsQaGroupSlotObject: "Each QA Group field mapping slot must be an object.", noteFieldMappingsQaGroupSlotObject: "Each question-and-answer group field mapping slot must be an object.",
noteFieldMappingsQaGroupSlotIndex: "Each QA Group field mapping slot.index must be an integer greater than 0.", noteFieldMappingsQaGroupSlotIndex: "Each question-and-answer group field mapping slot.index must be an integer greater than 0.",
noteFieldMappingsQaGroupSlotQuestionField: "Each QA Group field mapping slot.questionField must be a string.", noteFieldMappingsQaGroupSlotQuestionField: "Each question-and-answer group field mapping slot.questionField must be a string.",
noteFieldMappingsQaGroupSlotAnswerField: "Each QA Group field mapping slot.answerField must be a string.", noteFieldMappingsQaGroupSlotAnswerField: "Each question-and-answer group field mapping slot.answerField must be a string.",
noteFieldMappingsQaGroupWarningsArray: "QA Group field mapping warnings must be an array.", noteFieldMappingsQaGroupWarningsArray: "Question-and-answer group field mapping warnings must be an array.",
noteFieldMappingsQaGroupWarningsStrings: "QA Group field mapping warnings can only contain strings.", noteFieldMappingsQaGroupWarningsStrings: "Question-and-answer group field mapping warnings can only contain strings.",
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group field mapping acceptedWarnings must be an array.", noteFieldMappingsQaGroupAcceptedWarningsArray: "Question-and-answer group field mapping acceptedWarnings must be an array.",
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group field mapping acceptedWarnings can only contain strings.", noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.",
noteFieldMappingsQaGroupDerivationObject: "QA Group field mapping derivation must be an object.", noteFieldMappingsQaGroupDerivationObject: "Question-and-answer group field mapping derivation must be an object.",
noteFieldMappingsQaGroupDerivationMode: "QA Group field mapping derivation.mode must be first-pair.", noteFieldMappingsQaGroupDerivationMode: "Question-and-answer group field mapping derivation mode must be first-pair.",
noteFieldMappingsQaGroupDerivationFirstQuestionField: "QA Group field mapping derivation.firstQuestionField must be a string.", noteFieldMappingsQaGroupDerivationFirstQuestionField: "Question-and-answer group field mapping derivation.firstQuestionField must be a string.",
noteFieldMappingsQaGroupDerivationFirstAnswerField: "QA Group field mapping derivation.firstAnswerField must be a string.", noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.",
}, },
noteFieldMapping: { noteFieldMapping: {
noteTypeNotSelected: { noteTypeNotSelected: {
basic: "No Anki note type is selected for basic cards. Choose one in settings before syncing.", basic: "No Anki note type is selected for basic cards. Choose one in settings before syncing.",
cloze: "No Anki note type is selected for cloze cards. Choose one in settings before syncing.", cloze: "No Anki note type is selected for cloze cards. Choose one in settings before syncing.",
qaGroup: "No Anki note type is selected for QA Group cards. Choose one in settings before syncing.", qaGroup: "No Anki note type is selected for question-and-answer group cards. Choose one in settings before syncing.",
}, },
missingSavedMapping: { missingSavedMapping: {
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.", basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",

View file

@ -194,6 +194,22 @@ export const zh = {
failedLoad: "读取当前 vault 文件夹失败。", failedLoad: "读取当前 vault 文件夹失败。",
expandFolder: "展开 {{name}}", expandFolder: "展开 {{name}}",
collapseFolder: "收起 {{name}}", collapseFolder: "收起 {{name}}",
folderDeckModeOverride: {
ariaLabel: "切换 {{name}} 的文件夹牌组映射模式",
hint: {
folder: "本文件夹单独采用「文件夹」作为牌组名",
folderAndFile: "本文件夹单独采用「文件夹及文件名」作为牌组名",
},
title: {
folder: "当前全局为「文件夹及文件名」,勾选后此文件夹改用「文件夹」作为牌组名",
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
},
},
standaloneParentDeck: {
ariaLabel: "将 {{name}} 指定为单独的父牌组",
hint: "本文件夹指定为「单独的父牌组」",
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
},
}, },
cards: { cards: {
cardTypes: { cardTypes: {
@ -375,6 +391,10 @@ export const zh = {
includeFoldersStrings: "包含文件夹列表中只能包含字符串。", includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
excludeFoldersArray: "排除文件夹必须是数组。", excludeFoldersArray: "排除文件夹必须是数组。",
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。", excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。", ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。", ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。", ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",

View file

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const { const {
FakeButtonComponent, FakeButtonComponent,
@ -21,6 +21,12 @@ const {
return child; return child;
} }
createSpan(options?: { text?: string }): HoistedFakeElement {
const child = new HoistedFakeElement("span", options?.text ?? "");
this.children.push(child);
return child;
}
addClass(className: string): void { addClass(className: string): void {
this.classes.push(className); this.classes.push(className);
} }
@ -167,6 +173,14 @@ type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
type FakeContainerElInstance = InstanceType<typeof FakeModal>["contentEl"]; type FakeContainerElInstance = InstanceType<typeof FakeModal>["contentEl"];
type FakeToggleComponentInstance = InstanceType<typeof FakeToggleComponent>; type FakeToggleComponentInstance = InstanceType<typeof FakeToggleComponent>;
function setObsidianLanguage(language: string): void {
getLanguageMock.mockReturnValue(language);
}
afterEach(() => {
vi.restoreAllMocks();
});
function getFakeContentEl(modal: EmptyDeckSelectionModal): FakeContainerElInstance { function getFakeContentEl(modal: EmptyDeckSelectionModal): FakeContainerElInstance {
return modal.contentEl as unknown as FakeContainerElInstance; return modal.contentEl as unknown as FakeContainerElInstance;
} }
@ -188,8 +202,8 @@ function getFooterCountText(contentEl: FakeContainerElInstance): string | undefi
} }
describe("EmptyDeckSelectionModal", () => { describe("EmptyDeckSelectionModal", () => {
it("renders the modal text in English when Obsidian language is not zh", async () => { it("renders the modal text in English when Obsidian language is not zh", () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();
@ -208,8 +222,8 @@ describe("EmptyDeckSelectionModal", () => {
expect(getFooterCountText(contentEl)).toBe("Selected 0 / 3 empty decks"); expect(getFooterCountText(contentEl)).toBe("Selected 0 / 3 empty decks");
}); });
it("starts with zero selected count and a disabled delete button", async () => { it("starts with zero selected count and a disabled delete button", () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();
@ -221,7 +235,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("selects every candidate when the select-all button is clicked", async () => { it("selects every candidate when the select-all button is clicked", async () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
const selectionPromise = modal.openAndGetSelection(); const selectionPromise = modal.openAndGetSelection();
@ -244,7 +258,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("clears every candidate when the clear-all button is clicked", async () => { it("clears every candidate when the clear-all button is clicked", async () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();
@ -262,7 +276,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("inverts selected candidates when the invert button is clicked", async () => { it("inverts selected candidates when the invert button is clicked", async () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();
@ -281,7 +295,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("enables and disables the delete button as manual selection changes", async () => { it("enables and disables the delete button as manual selection changes", async () => {
getLanguageMock.mockReturnValue("en"); setObsidianLanguage("en");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();
@ -299,7 +313,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("returns null when cancel is clicked", async () => { it("returns null when cancel is clicked", async () => {
getLanguageMock.mockReturnValue("zh"); setObsidianLanguage("zh");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
const selectionPromise = modal.openAndGetSelection(); const selectionPromise = modal.openAndGetSelection();
@ -311,7 +325,7 @@ describe("EmptyDeckSelectionModal", () => {
}); });
it("renders selection text in Simplified Chinese when Obsidian language is zh", async () => { it("renders selection text in Simplified Chinese when Obsidian language is zh", async () => {
getLanguageMock.mockReturnValue("zh"); setObsidianLanguage("zh");
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]); const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
void modal.openAndGetSelection(); void modal.openAndGetSelection();

View file

@ -71,7 +71,7 @@ export class EmptyDeckSelectionModal extends Modal {
}); });
}); });
this.selectionCountEl = footerSetting.settingEl.createEl("span", { this.selectionCountEl = footerSetting.settingEl.createSpan({
text: this.getSelectionCountText(), text: this.getSelectionCountText(),
}); });
this.selectionCountEl.addClass("anki-helper-empty-deck-selection-count"); this.selectionCountEl.addClass("anki-helper-empty-deck-selection-count");

View file

@ -87,7 +87,7 @@ const {
public readonly parent: HoistedFakeElement | null = null, public readonly parent: HoistedFakeElement | null = null,
) {} ) {}
createEl(tag: string, options?: { text?: string }): HoistedFakeElement { private createChild(tag: string, options?: { text?: string }): HoistedFakeElement {
const child = new HoistedFakeElement(this.root, tag, this); const child = new HoistedFakeElement(this.root, tag, this);
if (options?.text) { if (options?.text) {
child.text = options.text; child.text = options.text;
@ -98,8 +98,16 @@ const {
return child; return child;
} }
createEl(tag: string, options?: { text?: string }): HoistedFakeElement {
return this.createChild(tag, options);
}
createDiv(options?: { text?: string }): HoistedFakeElement { createDiv(options?: { text?: string }): HoistedFakeElement {
return this.createEl("div", options); return this.createChild("div", options);
}
createSpan(options?: { text?: string }): HoistedFakeElement {
return this.createChild("span", options);
} }
addEventListener(eventName: string, callback: () => void | Promise<void>): void { addEventListener(eventName: string, callback: () => void | Promise<void>): void {
@ -286,27 +294,57 @@ const {
class HoistedFakeSetting { class HoistedFakeSetting {
public name = ""; public name = "";
public desc: unknown = ""; public desc: unknown = "";
public readonly settingEl: HoistedFakeElement;
public readonly infoEl: HoistedFakeElement;
public readonly nameEl: HoistedFakeElement;
public readonly descEl: HoistedFakeElement;
public readonly controlEl: HoistedFakeElement;
public controls: Array< public controls: Array<
HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent
> = []; > = [];
constructor(containerEl: HoistedFakeElement) { constructor(containerEl: HoistedFakeElement) {
this.settingEl = new HoistedFakeElement(containerEl.root, "div", containerEl);
this.infoEl = new HoistedFakeElement(containerEl.root, "div", this.settingEl);
this.nameEl = new HoistedFakeElement(containerEl.root, "div", this.infoEl);
this.descEl = new HoistedFakeElement(containerEl.root, "div", this.infoEl);
this.controlEl = new HoistedFakeElement(containerEl.root, "div", this.settingEl);
this.settingEl.children.push(this.infoEl, this.controlEl);
this.infoEl.children.push(this.nameEl, this.descEl);
containerEl.children.push(this.settingEl);
containerEl.root.settings.push(this); containerEl.root.settings.push(this);
containerEl.ownedSettings.push(this); containerEl.ownedSettings.push(this);
} }
setName(name: string): this { setName(name: string): this {
this.name = name; this.name = name;
this.nameEl.text = name;
this.nameEl.textContent = name;
return this; return this;
} }
setDesc(desc: unknown): this { setDesc(desc: unknown): this {
this.desc = desc; this.desc = desc;
if (typeof desc === "string") {
this.descEl.text = desc;
this.descEl.textContent = desc;
}
return this;
}
setHeading(): this {
this.settingEl.classList.add("is-heading");
return this;
}
setClass(className: string): this {
this.settingEl.classList.add(className);
return this; return this;
} }
addButton(callback: (button: HoistedFakeButtonComponent) => void): this { addButton(callback: (button: HoistedFakeButtonComponent) => void): this {
const button = new HoistedFakeButtonComponent(); const button = new HoistedFakeButtonComponent(this.controlEl);
this.controls.push(button); this.controls.push(button);
callback(button); callback(button);
return this; return this;
@ -405,7 +443,11 @@ type FakeElementInstance = InstanceType<typeof FakeElement>;
type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement; type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
class FakePlugin { class FakePlugin {
public readonly app = {}; public readonly app = {
workspace: {
activeWindow: typeof window === "undefined" ? undefined : window,
},
};
public readonly updateCalls: Array<Record<string, unknown>> = []; public readonly updateCalls: Array<Record<string, unknown>> = [];
public readonly fieldNamesByModelName: Record<string, string[]> = {}; public readonly fieldNamesByModelName: Record<string, string[]> = {};
public listNoteModelsCalls = 0; public listNoteModelsCalls = 0;
@ -451,7 +493,7 @@ class FakePlugin {
}, },
]; ];
async updateSettings(partialSettings: Record<string, unknown>): Promise<void> { updateSettings(partialSettings: Record<string, unknown>): Promise<void> {
this.updateCalls.push(partialSettings); this.updateCalls.push(partialSettings);
this.settings = normalizePluginSettings({ this.settings = normalizePluginSettings({
...this.settings, ...this.settings,
@ -459,40 +501,45 @@ class FakePlugin {
cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs, cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs,
noteFieldMappings: (partialSettings.noteFieldMappings as typeof this.settings.noteFieldMappings | undefined) ?? this.settings.noteFieldMappings, noteFieldMappings: (partialSettings.noteFieldMappings as typeof this.settings.noteFieldMappings | undefined) ?? this.settings.noteFieldMappings,
}); });
return Promise.resolve();
} }
async listNoteModels(): Promise<string[]> { listNoteModels(): Promise<string[]> {
this.listNoteModelsCalls += 1; this.listNoteModelsCalls += 1;
if (this.listNoteModelsError) { if (this.listNoteModelsError) {
throw this.listNoteModelsError; return Promise.reject(this.listNoteModelsError);
} }
return [...this.noteModels]; return Promise.resolve([...this.noteModels]);
} }
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> { getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
this.getModelFieldNamesByModelNamesCalls += 1; this.getModelFieldNamesByModelNamesCalls += 1;
return modelNames.reduce<Record<string, string[]>>((fieldNamesByModelName, modelName) => { const fieldNamesByModelName = modelNames.reduce<Record<string, string[]>>((resolvedFieldNamesByModelName, modelName) => {
fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName); resolvedFieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
return fieldNamesByModelName; return resolvedFieldNamesByModelName;
}, {}); }, {});
return Promise.resolve(fieldNamesByModelName);
} }
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> { getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
this.getNoteModelDetailsCalls += 1; this.getNoteModelDetailsCalls += 1;
return { return Promise.resolve({
fieldNames: this.getFieldNamesForModel(modelName), fieldNames: this.getFieldNamesForModel(modelName),
}; });
} }
async listFolderTree(): Promise<typeof this.folderTree> { listFolderTree(): Promise<typeof this.folderTree> {
this.listFolderTreeCalls += 1; this.listFolderTreeCalls += 1;
return this.folderTree; return Promise.resolve(this.folderTree);
} }
async insertDeckTemplateToCurrentFile(): Promise<void> { insertDeckTemplateToCurrentFile(): Promise<void> {
this.insertDeckTemplateCalls += 1; this.insertDeckTemplateCalls += 1;
return Promise.resolve();
} }
private getFieldNamesForModel(modelName: string): string[] { private getFieldNamesForModel(modelName: string): string[] {
@ -641,31 +688,118 @@ async function flushPromises(): Promise<void> {
} }
async function expandCard(tab: AnkiHeadingSyncSettingTab, cardId: string): Promise<void> { async function expandCard(tab: AnkiHeadingSyncSettingTab, cardId: string): Promise<void> {
if (queryByDataset(tab.containerEl, "settingsCardBody", cardId).style.display === "block") { if (!queryByDataset(tab.containerEl, "settingsCardBody", cardId).classList.contains("ahs-is-hidden")) {
return; return;
} }
await queryByDataset(tab.containerEl, "settingsCardToggle", cardId).trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", cardId).trigger("click");
} }
describe("PluginSettingTab", () => { function isCardExpanded(tab: AnkiHeadingSyncSettingTab, cardId: string): boolean {
const originalResizeObserver = globalThis.ResizeObserver; return !queryByDataset(tab.containerEl, "settingsCardBody", cardId).classList.contains("ahs-is-hidden");
}
type TestActiveWindow = {
setTimeout: (callback: () => void, delay?: number) => number;
clearTimeout: (timer: number) => void;
};
type TestWindow = {
activeWindow: TestActiveWindow;
ResizeObserver: typeof ResizeObserver;
};
type ScheduledTestTimer = {
callback: () => void;
dueAt: number;
};
type TestTimerController = {
setTimeout: (callback: () => void, delay?: number) => number;
clearTimeout: (timer: number) => void;
advanceBy: (duration: number) => void;
};
let testTimerController: TestTimerController | undefined;
function createTestTimerController(): TestTimerController {
let currentTime = 0;
let nextTimer = 1;
const timers = new Map<number, ScheduledTestTimer>();
return {
setTimeout(callback, delay = 0): number {
const timer = nextTimer;
nextTimer += 1;
timers.set(timer, {
callback,
dueAt: currentTime + delay,
});
return timer;
},
clearTimeout(timer): void {
timers.delete(timer);
},
advanceBy(duration): void {
const targetTime = currentTime + duration;
while (true) {
const nextDueTimer = [...timers.entries()]
.filter(([, scheduledTimer]) => scheduledTimer.dueAt <= targetTime)
.sort(([, left], [, right]) => left.dueAt - right.dueAt)[0];
if (!nextDueTimer) {
break;
}
const [timer, scheduledTimer] = nextDueTimer;
timers.delete(timer);
currentTime = scheduledTimer.dueAt;
scheduledTimer.callback();
}
currentTime = targetTime;
},
};
}
function advanceTestTimersByTime(duration: number): void {
if (!testTimerController) {
throw new Error("Test timer controller is not initialized.");
}
testTimerController.advanceBy(duration);
}
function createTestWindow(): TestWindow {
const timerController = createTestTimerController();
testTimerController = timerController;
const activeWindow: TestActiveWindow = {
setTimeout: (callback, delay) => timerController.setTimeout(callback, delay),
clearTimeout: (timer) => timerController.clearTimeout(timer),
};
return {
activeWindow,
ResizeObserver: FakeResizeObserver as unknown as typeof ResizeObserver,
};
}
describe("PluginSettingTab", () => {
beforeEach(() => { beforeEach(() => {
getLanguageMock.mockReturnValue("zh"); getLanguageMock.mockReturnValue("zh");
vi.useRealTimers(); vi.useRealTimers();
resetResizeObserverInstances(); resetResizeObserverInstances();
Reflect.set(globalThis, "ResizeObserver", FakeResizeObserver); vi.stubGlobal("navigator", { language: "zh" });
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
vi.stubGlobal("window", createTestWindow());
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
if (typeof originalResizeObserver === "undefined") { vi.unstubAllGlobals();
Reflect.deleteProperty(globalThis, "ResizeObserver"); testTimerController = undefined;
return;
}
Reflect.set(globalThis, "ResizeObserver", originalResizeObserver);
}); });
it("renders five cards collapsed by default", () => { it("renders five cards collapsed by default", () => {
@ -678,11 +812,11 @@ describe("PluginSettingTab", () => {
expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined(); expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined();
const cards = queryAllByDataset(tab.containerEl, "settingsCard"); const cards = queryAllByDataset(tab.containerEl, "settingsCard");
expect(cards).toHaveLength(5); expect(cards).toHaveLength(5);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); expect(isCardExpanded(tab, "sync-content")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none"); expect(isCardExpanded(tab, "card-types")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none"); expect(isCardExpanded(tab, "commands")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(isCardExpanded(tab, "scope")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); expect(isCardExpanded(tab, "deck")).toBe(false);
expect(cards.map((card) => card.dataset.settingsCard)).toEqual(["card-types", "sync-content", "deck", "scope", "commands"]); expect(cards.map((card) => card.dataset.settingsCard)).toEqual(["card-types", "sync-content", "deck", "scope", "commands"]);
@ -699,15 +833,15 @@ describe("PluginSettingTab", () => {
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block"); expect(isCardExpanded(tab, "scope")).toBe(true);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none"); expect(isCardExpanded(tab, "card-types")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none"); expect(isCardExpanded(tab, "sync-content")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); expect(isCardExpanded(tab, "deck")).toBe(false);
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none"); expect(isCardExpanded(tab, "commands")).toBe(false);
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(isCardExpanded(tab, "scope")).toBe(false);
}); });
it("applies sticky styles to the opaque page header gap, mask, and card headers", () => { it("applies sticky styles to the opaque page header gap, mask, and card headers", () => {
@ -718,39 +852,17 @@ describe("PluginSettingTab", () => {
const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true");
const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true"); const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true");
expect(pageHeader.style.position).toBe("sticky"); expect(pageHeader.classList.contains("ahs-settings-page-header")).toBe(true);
expect(pageHeader.style.top).toBe("0px");
expect(pageHeader.style.zIndex).toBe("300");
expect(pageHeader.style.width).toBe("100%");
expect(pageHeader.style.boxSizing).toBe("border-box");
expect(pageHeader.style.marginBottom).toBe("0");
expect(pageHeader.style.paddingBottom).toBe("calc(12px + var(--ahs-settings-sticky-card-gap, 8px))");
expect(pageHeader.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
expect(pageHeader.style.boxShadow).toBe("none");
expect(collectTexts(pageHeader)).toContain("Anki Heading Sync"); expect(collectTexts(pageHeader)).toContain("Anki Heading Sync");
expect(pageHeaderMask.style.position).toBe("absolute"); expect(pageHeaderMask.classList.contains("ahs-settings-page-header-mask")).toBe(true);
expect(pageHeaderMask.style.top).toBe("-128px");
expect(pageHeaderMask.style.left).toBe("-24px");
expect(pageHeaderMask.style.right).toBe("-24px");
expect(pageHeaderMask.style.bottom).toBe("0px");
expect(pageHeaderMask.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px"); expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px");
expect(getStyleProperty(tab.containerEl, "--ahs-settings-sticky-card-gap")).toBe("8px"); expect(getStyleProperty(tab.containerEl, "--ahs-settings-sticky-card-gap")).toBe("8px");
for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) { for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) {
const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId); const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId);
expect(header.style.position).toBe("sticky"); expect(header.classList.contains("ahs-settings-card-toggle")).toBe(true);
expect(header.style.top).toBe("var(--ahs-settings-page-header-height, 64px)");
expect(header.style.zIndex).toBe("200");
expect(header.style.display).toBe("flex");
expect(header.style.width).toBe("100%");
expect(header.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
expect(header.style.fontSize).toBe("1.5em");
expect(header.style.fontWeight).toBe("600");
expect(header.style.border).toBe("2px solid var(--background-modifier-border)");
expect(header.style.boxShadow).toBe("none");
} }
}); });
@ -819,7 +931,9 @@ describe("PluginSettingTab", () => {
expect(queryByDataset(tab.containerEl, "settingsCardToggle", "sync-content").textContent).toContain("卡片正文同步内容"); expect(queryByDataset(tab.containerEl, "settingsCardToggle", "sync-content").textContent).toContain("卡片正文同步内容");
expect(collectTexts(tab.containerEl)).not.toContain("这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。"); expect(collectTexts(tab.containerEl)).not.toContain("这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。");
const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle").map((element) => element.textContent || element.text); const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle")
.map((element) => element.textContent || element.text)
.filter((text): text is string => text.length > 0);
expect(sectionTitles).toEqual([ expect(sectionTitles).toEqual([
"1. 确定「卡片正文」范围", "1. 确定「卡片正文」范围",
"2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian", "2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian",
@ -827,7 +941,17 @@ describe("PluginSettingTab", () => {
"4. 「填空题」专项", "4. 「填空题」专项",
]); ]);
expect(collectSettingNames(tab.containerEl)).toEqual([ const syncContentSettingNames = collectSettingNames(tab.containerEl).filter((name) => [
"卡片正文截止模式",
"添加 Obsidian 回链",
"Obsidian 回链显示名称",
"Obsidian 回链放置位置",
"同步 Obsidian 标签到 Anki",
"在卡片正文中保留纯标签行",
"高亮转填空题",
].includes(name));
expect(syncContentSettingNames).toEqual([
"卡片正文截止模式", "卡片正文截止模式",
"添加 Obsidian 回链", "添加 Obsidian 回链",
"Obsidian 回链显示名称", "Obsidian 回链显示名称",
@ -983,7 +1107,6 @@ describe("PluginSettingTab", () => {
}); });
it("debounces text input saves instead of saving every keystroke", async () => { it("debounces text input saves instead of saving every keystroke", async () => {
vi.useFakeTimers();
const plugin = new FakePlugin(); const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never); const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -998,18 +1121,17 @@ describe("PluginSettingTab", () => {
expect(plugin.updateCalls).toHaveLength(0); expect(plugin.updateCalls).toHaveLength(0);
vi.advanceTimersByTime(499); advanceTestTimersByTime(499);
await flushPromises(); await flushPromises();
expect(plugin.updateCalls).toHaveLength(0); expect(plugin.updateCalls).toHaveLength(0);
vi.advanceTimersByTime(1); advanceTestTimersByTime(1);
await flushPromises(); await flushPromises();
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab");
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
}); });
it("flushes pending text saves when the settings tab hides", async () => { it("flushes pending text saves when the settings tab hides", async () => {
vi.useFakeTimers();
const plugin = new FakePlugin(); const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never); const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -1028,7 +1150,7 @@ describe("PluginSettingTab", () => {
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden");
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
vi.runOnlyPendingTimers(); advanceTestTimersByTime(500);
await flushPromises(); await flushPromises();
expect(plugin.updateCalls).toHaveLength(1); expect(plugin.updateCalls).toHaveLength(1);
@ -1045,13 +1167,11 @@ describe("PluginSettingTab", () => {
clozeHeading.value = "4"; clozeHeading.value = "4";
await clozeHeading.trigger("change"); await clozeHeading.trigger("change");
vi.useFakeTimers();
const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze"); const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
clozeMarker.value = ""; clozeMarker.value = "";
await clozeMarker.trigger("input"); await clozeMarker.trigger("input");
vi.advanceTimersByTime(500); advanceTestTimersByTime(500);
await flushPromises(); await flushPromises();
vi.useRealTimers();
expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4); expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4);
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze"); expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze");
@ -1066,11 +1186,11 @@ describe("PluginSettingTab", () => {
const initialEmptyCount = getEmptyCallCount(tab.containerEl); const initialEmptyCount = getEmptyCallCount(tab.containerEl);
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block"); expect(isCardExpanded(tab, "scope")).toBe(true);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(isCardExpanded(tab, "scope")).toBe(false);
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
}); });
@ -1108,22 +1228,18 @@ describe("PluginSettingTab", () => {
"Custom Cloze", "Custom Cloze",
QA_GROUP_USER_MODEL_NAME, QA_GROUP_USER_MODEL_NAME,
].sort((left, right) => left.localeCompare(right))); ].sort((left, right) => left.localeCompare(right)));
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true); expect(plugin.updateCalls.some((call) => Array.isArray(call["ankiNoteTypeCache"]))).toBe(true);
expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({ const { ankiModelFieldCache } = plugin.settings;
Basic: { expect(ankiModelFieldCache.Basic?.fieldNames).toEqual(["Front", "Back", "Extra"]);
fieldNames: ["Front", "Back", "Extra"], expect(typeof ankiModelFieldCache.Basic?.loadedAt).toBe("number");
loadedAt: expect.any(Number), expect(ankiModelFieldCache["Custom Basic"]?.fieldNames).toEqual(["Title", "Body", "Hint"]);
}, expect(typeof ankiModelFieldCache["Custom Basic"]?.loadedAt).toBe("number");
"Custom Basic": { expect(ankiModelFieldCache.Cloze?.fieldNames).toEqual(["Text", "Extra", "Hint"]);
fieldNames: ["Title", "Body", "Hint"], expect(typeof ankiModelFieldCache.Cloze?.loadedAt).toBe("number");
loadedAt: expect.any(Number), expect(plugin.updateCalls.some((call) => {
}, const ankiModelFieldCache = call["ankiModelFieldCache"];
Cloze: { return typeof ankiModelFieldCache === "object" && ankiModelFieldCache !== null;
fieldNames: ["Text", "Extra", "Hint"], })).toBe(true);
loadedAt: expect.any(Number),
},
}));
expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true);
const cachedPlugin = new FakePlugin(); const cachedPlugin = new FakePlugin();
cachedPlugin.settings = normalizePluginSettings({ cachedPlugin.settings = normalizePluginSettings({
@ -1564,17 +1680,13 @@ describe("PluginSettingTab", () => {
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub"); const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
expect(parentToggle.textContent).toBe("▾"); expect(parentToggle.textContent).toBe("▾");
expect(parentRow.style.display).toBe("grid"); expect(parentRow.classList.contains("ahs-settings-folder-row")).toBe(true);
expect(parentRow.style.gridTemplateColumns).toBe("24px 24px minmax(0, 1fr)"); expect(getStyleProperty(childRow, "--ahs-folder-depth-indent")).toBe("18px");
expect(parentRow.style.alignItems).toBe("center"); expect(childToggle.classList.contains("ahs-settings-folder-toggle")).toBe(true);
expect(childRow.style.marginLeft).toBe("18px"); expect(childToggle.classList.contains("ahs-settings-folder-toggle-spacer")).toBe(true);
expect(childToggle.style.width).toBe("24px");
expect(childToggle.style.height).toBe("24px");
expect(childToggle.style.fontSize).toBe("2em");
expect(childCheckbox).toBeDefined(); expect(childCheckbox).toBeDefined();
expect(childLabel.style.whiteSpace).toBe("nowrap"); expect(childLabel.classList.contains("ahs-settings-folder-label")).toBe(true);
expect(childLabel.style.overflow).toBe("hidden"); expect(childLabel.classList.contains("ahs-settings-fluid-ellipsis")).toBe(true);
expect(childLabel.style.textOverflow).toBe("ellipsis");
}); });
it("folder tree expand and check refresh only the scope card", async () => { it("folder tree expand and check refresh only the scope card", async () => {
@ -1606,6 +1718,202 @@ describe("PluginSettingTab", () => {
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
}); });
it("renders folder deck mode override controls for checked include rows when folder mapping is enabled", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const overrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const overrideAttributes = overrideCheckbox as unknown as { title?: string; "aria-label"?: string };
const standaloneParentDeckAttributes = standaloneParentDeckCheckbox as unknown as { title?: string; "aria-label"?: string };
const labelParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
const overrideParent = (overrideCheckbox as unknown as { parent?: { children?: unknown[] } }).parent;
expect(overrideCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
expect(standaloneParentDeckCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
expect(overrideAttributes.title).toBe("当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名");
expect(overrideAttributes["aria-label"]).toBe("切换 sub 的文件夹牌组映射模式");
expect(standaloneParentDeckAttributes.title).toBe("勾选后,此文件夹在 Anki 中会作为单独的父牌组起点");
expect(standaloneParentDeckAttributes["aria-label"]).toBe("将 sub 指定为单独的父牌组");
expect(overrideParent).toBe(labelParent);
expect(labelParent?.children?.[0]).toBe(childLabel);
expect(labelParent?.children?.[1]).toBe(overrideCheckbox);
expect(labelParent?.children?.[2]).toBe(standaloneParentDeckCheckbox);
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub")).toThrow();
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub")).toThrow();
});
it("toggling a folder deck mode override only updates alternateFolderDeckModeFolders and shows the hint", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const overrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
overrideCheckbox.checked = true;
await overrideCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual(["notes/sub"]);
expect(plugin.updateCalls).toContainEqual({
alternateFolderDeckModeFolders: ["notes/sub"],
});
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const hint = queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub");
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
expect((hint as unknown as { parent?: unknown }).parent).toBe(inlineParent);
expect(inlineParent?.children?.[0]).toBe(childLabel);
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
expect(inlineParent?.children?.[2]).toBe(hint);
expect(inlineParent?.children?.[3]).toBe(standaloneParentDeckCheckbox);
expect(hint.textContent).toContain("文件夹及文件名");
});
it("renders standalone parent deck controls for inherited checked non-root rows and hides them for top-level rows", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes")).toThrow();
await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click");
expect(queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toBeDefined();
});
it("toggling a standalone parent deck only updates standaloneParentDeckFolders and shows the hint", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "folder",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
standaloneParentDeckCheckbox.checked = true;
await standaloneParentDeckCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
expect(plugin.updateCalls).toContainEqual({
standaloneParentDeckFolders: ["notes/sub"],
});
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
const rerenderedStandaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
const hint = queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub");
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
expect((hint as unknown as { parent?: unknown }).parent).toBe(inlineParent);
expect(inlineParent?.children?.[0]).toBe(childLabel);
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
expect(inlineParent?.children?.[2]).toBe(rerenderedStandaloneParentDeckCheckbox);
expect(inlineParent?.children?.[3]).toBe(hint);
expect(hint.textContent).toContain("单独的父牌组");
});
it("cleans folder deck mode overrides for an unchecked include folder and its descendants", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
standaloneParentDeckFolders: ["notes", "notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
const notesCheckbox = queryByDataset(tab.containerEl, "folderPath", "notes");
notesCheckbox.checked = false;
await notesCheckbox.trigger("change");
expect(plugin.settings.includeFolders).toEqual([]);
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
expect(plugin.settings.standaloneParentDeckFolders).toEqual([]);
});
it("auto expands ancestors for saved standalone parent deck folders", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes"],
folderDeckMode: "folder",
standaloneParentDeckFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub")).toBeDefined();
});
it("hides folder deck mode override controls when folder mapping is off", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
folderDeckMode: "off",
alternateFolderDeckModeFolders: ["notes/sub"],
standaloneParentDeckFolders: ["notes/sub"],
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
await flushPromises();
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub")).toThrow();
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual(["notes/sub"]);
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toThrow();
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
});
it("shows an explicit warning when include mode has no selected folders", async () => { it("shows an explicit warning when include mode has no selected folders", async () => {
const plugin = new FakePlugin(); const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({ plugin.settings = normalizePluginSettings({
@ -1648,7 +1956,9 @@ describe("PluginSettingTab", () => {
await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click"); await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click");
const deckBody = queryByDataset(tab.containerEl, "settingsCardBody", "deck"); const deckBody = queryByDataset(tab.containerEl, "settingsCardBody", "deck");
const sectionTitles = queryAllByDataset(deckBody, "deckSectionTitle").map((element) => element.textContent || element.text); const sectionTitles = queryAllByDataset(deckBody, "deckSectionTitle")
.map((element) => element.textContent || element.text)
.filter((text): text is string => text.length > 0);
expect(sectionTitles).toEqual([ expect(sectionTitles).toEqual([
"1推荐用「文件夹及文件名」作为「Anki牌组」", "1推荐用「文件夹及文件名」作为「Anki牌组」",
"2可打开「文件级自定义牌组」作为个性化定制", "2可打开「文件级自定义牌组」作为个性化定制",
@ -1667,18 +1977,13 @@ describe("PluginSettingTab", () => {
const exampleRows = queryAllByDataset(exampleBlock, "deckExampleRow"); const exampleRows = queryAllByDataset(exampleBlock, "deckExampleRow");
expect(collectTexts(exampleBlock)).toContain("示例:"); expect(collectTexts(exampleBlock)).toContain("示例:");
expect(exampleRows).toHaveLength(2); expect(exampleRows).toHaveLength(2);
expect(exampleBlock.style.marginLeft).toBe("32px"); expect(exampleBlock.classList.contains("ahs-settings-deck-example-block")).toBe(true);
expect(exampleRows[0]?.style.color).toBe("var(--text-muted)"); expect(exampleRows[0]?.classList.contains("ahs-settings-helper-text")).toBe(true);
expect(exampleRows[0]?.style.fontSize).toBe("var(--font-ui-small)");
const fallbackHelper = queryByDataset(section3, "deckHelperText", "fallback"); const fallbackHelper = queryByDataset(section3, "deckHelperText", "fallback");
const priorityFooter = queryByDataset(deckBody, "deckPriorityFooter", "true"); const priorityFooter = queryByDataset(deckBody, "deckPriorityFooter", "true");
expect(fallbackHelper.style.marginLeft).toBe("32px"); expect(fallbackHelper.classList.contains("ahs-settings-helper-text")).toBe(true);
expect(fallbackHelper.style.color).toBe("var(--text-muted)"); expect(priorityFooter.classList.contains("ahs-settings-helper-text")).toBe(true);
expect(fallbackHelper.style.fontSize).toBe("var(--font-ui-small)");
expect(priorityFooter.style.marginLeft).toBe("32px");
expect(priorityFooter.style.color).toBe("var(--text-muted)");
expect(priorityFooter.style.fontSize).toBe("var(--font-ui-small)");
expect(collectTexts(section3)).not.toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。"); expect(collectTexts(section3)).not.toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。");
expect(collectOwnedSettingNames(section1)).toEqual(["文件夹映射模式"]); expect(collectOwnedSettingNames(section1)).toEqual(["文件夹映射模式"]);

View file

@ -39,18 +39,12 @@ const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "deck", "scope", "com
const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "cloze-all"] as const; const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "cloze-all"] as const;
const SETTINGS_STICKY_CARD_GAP_PX = 8; const SETTINGS_STICKY_CARD_GAP_PX = 8;
const SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX = 64; const SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX = 64;
const SETTINGS_PAGE_HEADER_BACKGROUND = "var(--modal-background, var(--background-primary))";
const SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE = "--ahs-settings-page-header-height"; const SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE = "--ahs-settings-page-header-height";
const SETTINGS_STICKY_CARD_GAP_VARIABLE = "--ahs-settings-sticky-card-gap"; const SETTINGS_STICKY_CARD_GAP_VARIABLE = "--ahs-settings-sticky-card-gap";
const SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK = `${SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX}px`; const SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK = `${SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX}px`;
const SETTINGS_STICKY_CARD_GAP = `${SETTINGS_STICKY_CARD_GAP_PX}px`; const SETTINGS_STICKY_CARD_GAP = `${SETTINGS_STICKY_CARD_GAP_PX}px`;
const SETTINGS_PAGE_HEADER_HEIGHT_VALUE = `var(${SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE}, ${SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK})`;
const SETTINGS_STICKY_CARD_GAP_VALUE = `var(${SETTINGS_STICKY_CARD_GAP_VARIABLE}, ${SETTINGS_STICKY_CARD_GAP})`;
const SETTINGS_PAGE_HEADER_PADDING_TOP = "12px";
const SETTINGS_PAGE_HEADER_MASK_TOP = "-128px";
const SETTINGS_PAGE_HEADER_MASK_SIDE = "-24px";
const DECK_HELPER_TEXT_INDENT = "32px";
const SETTINGS_ROOT_CLASS = "anki-heading-sync-settings"; const SETTINGS_ROOT_CLASS = "anki-heading-sync-settings";
const SETTINGS_CARD_BODY_HIDDEN_CLASS = "ahs-is-hidden";
type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number]; type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number];
type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed"; type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed";
@ -62,10 +56,23 @@ interface SettingsCardShell {
} }
interface PendingTextSave { interface PendingTextSave {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<Window["setTimeout"]>;
saveAction: (draftValue: string) => Promise<void>; saveAction: (draftValue: string) => Promise<void>;
} }
function addClasses(element: HTMLElement, ...classNames: string[]): void {
element.classList.add(...classNames);
}
function setClassEnabled(element: HTMLElement, className: string, enabled: boolean): void {
if (enabled) {
element.classList.add(className);
return;
}
element.classList.remove(className);
}
export class AnkiHeadingSyncSettingTab extends PluginSettingTab { export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private readonly noteFieldMappingService = new NoteFieldMappingService(); private readonly noteFieldMappingService = new NoteFieldMappingService();
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService(); private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService();
@ -126,35 +133,15 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const pageHeaderEl = containerEl.createDiv(); const pageHeaderEl = containerEl.createDiv();
pageHeaderEl.dataset.settingsPageHeader = "true"; pageHeaderEl.dataset.settingsPageHeader = "true";
pageHeaderEl.style.position = "sticky"; addClasses(pageHeaderEl, "ahs-settings-page-header");
pageHeaderEl.style.top = "0px";
pageHeaderEl.style.zIndex = "300";
pageHeaderEl.style.width = "100%";
pageHeaderEl.style.boxSizing = "border-box";
pageHeaderEl.style.paddingTop = SETTINGS_PAGE_HEADER_PADDING_TOP;
pageHeaderEl.style.paddingBottom = `calc(${SETTINGS_PAGE_HEADER_PADDING_TOP} + ${SETTINGS_STICKY_CARD_GAP_VALUE})`;
pageHeaderEl.style.marginBottom = "0";
pageHeaderEl.style.overflow = "visible";
pageHeaderEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
pageHeaderEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
pageHeaderEl.style.boxShadow = "none";
const pageHeaderMaskEl = pageHeaderEl.createDiv(); const pageHeaderMaskEl = pageHeaderEl.createDiv();
pageHeaderMaskEl.dataset.settingsPageHeaderMask = "true"; pageHeaderMaskEl.dataset.settingsPageHeaderMask = "true";
pageHeaderMaskEl.style.position = "absolute"; addClasses(pageHeaderMaskEl, "ahs-settings-page-header-mask");
pageHeaderMaskEl.style.top = SETTINGS_PAGE_HEADER_MASK_TOP;
pageHeaderMaskEl.style.left = SETTINGS_PAGE_HEADER_MASK_SIDE;
pageHeaderMaskEl.style.right = SETTINGS_PAGE_HEADER_MASK_SIDE;
pageHeaderMaskEl.style.bottom = "0px";
pageHeaderMaskEl.style.zIndex = "0";
pageHeaderMaskEl.style.pointerEvents = "none";
pageHeaderMaskEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
pageHeaderMaskEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
const titleEl = pageHeaderEl.createEl("h2", { text: t("settings.pluginTitle") }); const titleSetting = new Setting(pageHeaderEl).setName(t("settings.pluginTitle")).setHeading();
titleEl.style.margin = "0"; addClasses(titleSetting.settingEl, "ahs-settings-page-title-setting");
titleEl.style.position = "relative"; addClasses(titleSetting.nameEl, "ahs-settings-page-title");
titleEl.style.zIndex = "1";
this.observePageHeaderHeight(pageHeaderEl); this.observePageHeaderHeight(pageHeaderEl);
@ -182,34 +169,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const cardEl = containerEl.createDiv(); const cardEl = containerEl.createDiv();
cardEl.dataset.settingsCard = cardId; cardEl.dataset.settingsCard = cardId;
const headerEl = cardEl.createEl("button") as HTMLButtonElement; const headerEl = cardEl.createEl("button");
headerEl.type = "button"; headerEl.type = "button";
headerEl.dataset.settingsCardToggle = cardId; headerEl.dataset.settingsCardToggle = cardId;
headerEl.style.display = "flex"; addClasses(headerEl, "ahs-settings-card-toggle");
headerEl.style.alignItems = "center";
headerEl.style.justifyContent = "flex-start";
headerEl.style.position = "sticky";
headerEl.style.top = SETTINGS_PAGE_HEADER_HEIGHT_VALUE;
headerEl.style.zIndex = "200";
headerEl.style.width = "100%";
headerEl.style.maxWidth = "100%";
headerEl.style.boxSizing = "border-box";
headerEl.style.padding = "10px 14px";
headerEl.style.fontSize = "1.5em";
headerEl.style.fontWeight = "600";
headerEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
headerEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
headerEl.style.border = "2px solid var(--background-modifier-border)";
headerEl.style.borderRadius = "0";
headerEl.style.marginBottom = "8px";
headerEl.style.textAlign = "left";
headerEl.style.boxShadow = "none";
headerEl.addEventListener("click", () => { headerEl.addEventListener("click", () => {
this.toggleCard(cardId); this.toggleCard(cardId);
}); });
const bodyEl = cardEl.createDiv(); const bodyEl = cardEl.createDiv();
bodyEl.dataset.settingsCardBody = cardId; bodyEl.dataset.settingsCardBody = cardId;
addClasses(bodyEl, "ahs-settings-card-body", SETTINGS_CARD_BODY_HIDDEN_CLASS);
this.cardShells.set(cardId, { this.cardShells.set(cardId, {
cardEl, cardEl,
@ -238,7 +208,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const expanded = this.expandedCardIds.has(cardId); const expanded = this.expandedCardIds.has(cardId);
shell.headerEl.textContent = `${expanded ? "▾" : "▸"} ${this.getCardTitle(cardId)}`; shell.headerEl.textContent = `${expanded ? "▾" : "▸"} ${this.getCardTitle(cardId)}`;
shell.headerEl.setAttr("aria-expanded", String(expanded)); shell.headerEl.setAttr("aria-expanded", String(expanded));
shell.bodyEl.style.display = expanded ? "block" : "none"; setClassEnabled(shell.bodyEl, SETTINGS_CARD_BODY_HIDDEN_CLASS, !expanded);
shell.bodyEl.empty(); shell.bodyEl.empty();
if (!expanded) { if (!expanded) {
@ -301,10 +271,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.maybeStartNoteTypeCacheCheck(); this.maybeStartNoteTypeCacheCheck();
const actionRow = containerEl.createDiv(); const actionRow = containerEl.createDiv();
actionRow.style.display = "flex"; addClasses(actionRow, "ahs-settings-action-row");
actionRow.style.flexDirection = "column";
actionRow.style.alignItems = "flex-start";
actionRow.style.gap = "8px";
const loadButton = new ButtonComponent(actionRow) const loadButton = new ButtonComponent(actionRow)
.setButtonText(this.ankiConfigLoading .setButtonText(this.ankiConfigLoading
? t("settings.cards.cardTypes.loadAnki.loading") ? t("settings.cards.cardTypes.loadAnki.loading")
@ -313,15 +280,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setDisabled(this.ankiConfigLoading) .setDisabled(this.ankiConfigLoading)
.onClick(() => this.loadAnkiCardTypeConfig()); .onClick(() => this.loadAnkiCardTypeConfig());
loadButton.buttonEl.dataset.cardTypesRefresh = "true"; loadButton.buttonEl.dataset.cardTypesRefresh = "true";
actionRow.createEl("span", { actionRow.createSpan({
text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.getCardTypeStatusMessage())}`, text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.getCardTypeStatusMessage())}`,
}); });
const cardTypeList = containerEl.createDiv(); const cardTypeList = containerEl.createDiv();
cardTypeList.dataset.cardTypeList = "true"; cardTypeList.dataset.cardTypeList = "true";
cardTypeList.style.display = "flex"; addClasses(cardTypeList, "ahs-settings-card-list");
cardTypeList.style.flexDirection = "column";
cardTypeList.style.gap = "14px";
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) { for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
this.renderCardTypeBlock(cardTypeList, configId); this.renderCardTypeBlock(cardTypeList, configId);
@ -332,49 +297,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const config = this.plugin.settings.cardTypeConfigs[configId]; const config = this.plugin.settings.cardTypeConfigs[configId];
const blockEl = containerEl.createDiv(); const blockEl = containerEl.createDiv();
blockEl.dataset.cardTypeConfig = configId; blockEl.dataset.cardTypeConfig = configId;
blockEl.style.display = "flex"; addClasses(blockEl, "ahs-settings-card-type-block");
blockEl.style.flexDirection = "column";
blockEl.style.gap = "8px";
blockEl.style.padding = "12px";
blockEl.style.border = "1px solid var(--background-modifier-border)";
blockEl.style.borderRadius = "8px";
const headerRow = blockEl.createDiv(); const headerRow = blockEl.createDiv();
headerRow.style.display = "flex"; addClasses(headerRow, "ahs-settings-card-type-header");
headerRow.style.alignItems = "center";
headerRow.style.gap = "10px";
const enabledCheckbox = headerRow.createEl("input") as HTMLInputElement; const enabledCheckbox = headerRow.createEl("input");
enabledCheckbox.type = "checkbox"; enabledCheckbox.type = "checkbox";
enabledCheckbox.checked = config.enabled; enabledCheckbox.checked = config.enabled;
enabledCheckbox.dataset.cardTypeEnabled = configId; enabledCheckbox.dataset.cardTypeEnabled = configId;
enabledCheckbox.addEventListener("change", () => this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked })); enabledCheckbox.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked });
});
const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) }); const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) });
titleEl.style.fontSize = "var(--font-ui-medium)"; addClasses(titleEl, "ahs-settings-card-type-title");
const recognitionRow = blockEl.createDiv(); const recognitionRow = blockEl.createDiv();
recognitionRow.style.display = "flex"; addClasses(recognitionRow, "ahs-settings-recognition-row");
recognitionRow.style.flexWrap = "wrap";
recognitionRow.style.alignItems = "center";
recognitionRow.style.gap = "10px 14px";
const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel")); const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel"));
const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`); const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`);
headingSelect.dataset.cardTypeHeading = configId; headingSelect.dataset.cardTypeHeading = configId;
headingSelect.style.width = "88px"; addClasses(headingSelect, "ahs-settings-heading-select");
for (let level = 1; level <= 6; level += 1) { for (let level = 1; level <= 6; level += 1) {
this.appendOption(headingSelect, String(level), `H${level}`); this.appendOption(headingSelect, String(level), `H${level}`);
} }
headingSelect.value = String(config.headingLevel); headingSelect.value = String(config.headingLevel);
headingSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) })); headingSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) });
});
const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker")); const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker"));
const markerInput = markerGroup.createEl("input") as HTMLInputElement; const markerInput = markerGroup.createEl("input");
markerInput.type = "text"; markerInput.type = "text";
markerInput.dataset.cardTypeMarker = configId; markerInput.dataset.cardTypeMarker = configId;
markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder"); markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder");
markerInput.style.width = "220px"; addClasses(markerInput, "ahs-settings-marker-input");
markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker); markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker);
markerInput.addEventListener("input", () => { markerInput.addEventListener("input", () => {
this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => { this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => {
@ -390,19 +349,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
text: t("settings.cards.cardTypes.sharedClozeMappingHint"), text: t("settings.cards.cardTypes.sharedClozeMappingHint"),
}); });
sharedHintEl.dataset.cardTypeSharedHint = configId; sharedHintEl.dataset.cardTypeSharedHint = configId;
sharedHintEl.style.margin = "0"; addClasses(sharedHintEl, "ahs-settings-muted-text");
sharedHintEl.style.color = "var(--text-muted)";
return; return;
} }
const ankiRow = blockEl.createDiv(); const ankiRow = blockEl.createDiv();
ankiRow.style.display = "grid"; addClasses(ankiRow, "ahs-settings-grid-row");
ankiRow.style.gridTemplateColumns = "minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr)";
ankiRow.style.alignItems = "center";
ankiRow.style.columnGap = "16px";
ankiRow.style.rowGap = "8px";
ankiRow.style.overflow = "visible";
ankiRow.style.width = "100%";
const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType")); const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType"));
const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`); const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`);
@ -413,7 +365,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
noteTypeSelect.value = config.noteType; noteTypeSelect.value = config.noteType;
noteTypeSelect.disabled = this.ankiConfigLoading; noteTypeSelect.disabled = this.ankiConfigLoading;
noteTypeSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value })); noteTypeSelect.addEventListener("change", () => {
void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value });
});
this.renderCardTypeFieldControls(ankiRow, configId); this.renderCardTypeFieldControls(ankiRow, configId);
} }
@ -432,7 +386,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
mainFieldSelect.dataset.cardTypeQuestionField = configId; mainFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyFluidEllipsis(mainFieldSelect); this.applyFluidEllipsis(mainFieldSelect);
this.populateFieldSelect(mainFieldSelect, clozeMapping?.loadedFieldNames ?? [], clozeMapping?.mainField); this.populateFieldSelect(mainFieldSelect, clozeMapping?.loadedFieldNames ?? [], clozeMapping?.mainField);
mainFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined })); mainFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined });
});
return; return;
} }
@ -442,14 +398,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
titleFieldSelect.dataset.cardTypeQuestionField = configId; titleFieldSelect.dataset.cardTypeQuestionField = configId;
this.applyFluidEllipsis(titleFieldSelect); this.applyFluidEllipsis(titleFieldSelect);
this.populateFieldSelect(titleFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.titleField); this.populateFieldSelect(titleFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.titleField);
titleFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined })); titleFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined });
});
const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField")); const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField"));
const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`); const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`);
bodyFieldSelect.dataset.cardTypeAnswerField = configId; bodyFieldSelect.dataset.cardTypeAnswerField = configId;
this.applyFluidEllipsis(bodyFieldSelect); this.applyFluidEllipsis(bodyFieldSelect);
this.populateFieldSelect(bodyFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.bodyField); this.populateFieldSelect(bodyFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.bodyField);
bodyFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined })); bodyFieldSelect.addEventListener("change", () => {
void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined });
});
} }
private renderQaGroupFieldControls(containerEl: HTMLElement, configId: Extract<CardTypeConfigId, "qa-group">): void { private renderQaGroupFieldControls(containerEl: HTMLElement, configId: Extract<CardTypeConfigId, "qa-group">): void {
@ -465,13 +425,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.populateFieldSelect(titleFieldSelect, qaGroupState.mapping.loadedFieldNames, qaGroupState.mapping.titleField); this.populateFieldSelect(titleFieldSelect, qaGroupState.mapping.loadedFieldNames, qaGroupState.mapping.titleField);
const qaGroupPairRow = containerEl.createDiv(); const qaGroupPairRow = containerEl.createDiv();
qaGroupPairRow.dataset.qaGroupPairRow = configId; qaGroupPairRow.dataset.qaGroupPairRow = configId;
qaGroupPairRow.style.gridColumn = "1 / -1"; addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
qaGroupPairRow.style.display = "grid";
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
qaGroupPairRow.style.alignItems = "center";
qaGroupPairRow.style.columnGap = "16px";
qaGroupPairRow.style.rowGap = "8px";
qaGroupPairRow.style.width = "100%";
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField")); const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
const firstQuestionFieldSelect = this.createFieldSelect(firstQuestionFieldGroup, `card-type-qa-group-first-question-field:${configId}`); const firstQuestionFieldSelect = this.createFieldSelect(firstQuestionFieldGroup, `card-type-qa-group-first-question-field:${configId}`);
@ -485,56 +439,50 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.applyFluidEllipsis(firstAnswerFieldSelect); this.applyFluidEllipsis(firstAnswerFieldSelect);
this.populateFieldSelect(firstAnswerFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstAnswerField); this.populateFieldSelect(firstAnswerFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstAnswerField);
const saveSelection = () => this.saveQaGroupFieldSelection(configId, { const saveSelection = () => {
titleField: titleFieldSelect.value || undefined, void this.saveQaGroupFieldSelection(configId, {
firstQuestionField: firstQuestionFieldSelect.value || undefined, titleField: titleFieldSelect.value || undefined,
firstAnswerField: firstAnswerFieldSelect.value || undefined, firstQuestionField: firstQuestionFieldSelect.value || undefined,
}); firstAnswerField: firstAnswerFieldSelect.value || undefined,
});
};
titleFieldSelect.addEventListener("change", saveSelection); titleFieldSelect.addEventListener("change", saveSelection);
firstQuestionFieldSelect.addEventListener("change", saveSelection); firstQuestionFieldSelect.addEventListener("change", saveSelection);
firstAnswerFieldSelect.addEventListener("change", saveSelection); firstAnswerFieldSelect.addEventListener("change", saveSelection);
const slotSummaryEl = qaGroupPairRow.createEl("span", { const slotSummaryEl = qaGroupPairRow.createSpan({
text: t("settings.cards.cardTypes.qaGroup.configuredSlots", { text: t("settings.cards.cardTypes.qaGroup.configuredSlots", {
count: qaGroupState.mapping.slots.length, count: qaGroupState.mapping.slots.length,
}), }),
}); });
slotSummaryEl.dataset.qaGroupSlotSummary = configId; slotSummaryEl.dataset.qaGroupSlotSummary = configId;
slotSummaryEl.style.whiteSpace = "nowrap"; addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
slotSummaryEl.style.justifySelf = "start";
this.applyFluidEllipsis(slotSummaryEl); this.applyFluidEllipsis(slotSummaryEl);
} else { } else {
titleFieldGroup.createEl("span", { titleFieldGroup.createSpan({
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"), text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupTitleField = configId; }).dataset.qaGroupTitleField = configId;
const qaGroupPairRow = containerEl.createDiv(); const qaGroupPairRow = containerEl.createDiv();
qaGroupPairRow.dataset.qaGroupPairRow = configId; qaGroupPairRow.dataset.qaGroupPairRow = configId;
qaGroupPairRow.style.gridColumn = "1 / -1"; addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
qaGroupPairRow.style.display = "grid";
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
qaGroupPairRow.style.alignItems = "center";
qaGroupPairRow.style.columnGap = "16px";
qaGroupPairRow.style.rowGap = "8px";
qaGroupPairRow.style.width = "100%";
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField")); const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
firstQuestionFieldGroup.createEl("span", { firstQuestionFieldGroup.createSpan({
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"), text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupFirstQuestionField = configId; }).dataset.qaGroupFirstQuestionField = configId;
const firstAnswerFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstAnswerField")); const firstAnswerFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstAnswerField"));
firstAnswerFieldGroup.createEl("span", { firstAnswerFieldGroup.createSpan({
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"), text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}).dataset.qaGroupFirstAnswerField = configId; }).dataset.qaGroupFirstAnswerField = configId;
const slotSummaryEl = qaGroupPairRow.createEl("span", { const slotSummaryEl = qaGroupPairRow.createSpan({
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"), text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
}); });
slotSummaryEl.dataset.qaGroupSlotSummary = configId; slotSummaryEl.dataset.qaGroupSlotSummary = configId;
slotSummaryEl.style.whiteSpace = "nowrap"; addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
slotSummaryEl.style.justifySelf = "start";
this.applyFluidEllipsis(slotSummaryEl); this.applyFluidEllipsis(slotSummaryEl);
} }
@ -543,7 +491,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
text: renderUserFacingMessage(qaGroupState.error), text: renderUserFacingMessage(qaGroupState.error),
}); });
errorEl.dataset.qaGroupError = configId; errorEl.dataset.qaGroupError = configId;
errorEl.style.gridColumn = "1 / -1"; addClasses(errorEl, "ahs-settings-grid-full");
return; return;
} }
@ -553,28 +501,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const warningsContainer = containerEl.createDiv(); const warningsContainer = containerEl.createDiv();
warningsContainer.dataset.qaGroupWarnings = configId; warningsContainer.dataset.qaGroupWarnings = configId;
warningsContainer.style.gridColumn = "1 / -1"; addClasses(warningsContainer, "ahs-settings-grid-full", "ahs-settings-warnings");
warningsContainer.style.display = "flex";
warningsContainer.style.flexDirection = "column";
warningsContainer.style.gap = "6px";
warningsContainer.createEl("strong", { text: t("settings.cards.cardTypes.labels.qaGroupWarnings") }); warningsContainer.createEl("strong", { text: t("settings.cards.cardTypes.labels.qaGroupWarnings") });
for (const warning of qaGroupState.mapping.warnings) { for (const warning of qaGroupState.mapping.warnings) {
warningsContainer.createEl("div", { warningsContainer.createDiv({
text: this.renderQaGroupWarning(warning), text: this.renderQaGroupWarning(warning),
}).dataset.qaGroupWarning = configId; }).dataset.qaGroupWarning = configId;
} }
const acceptLabel = warningsContainer.createEl("label"); const acceptLabel = warningsContainer.createEl("label");
acceptLabel.style.display = "inline-flex"; addClasses(acceptLabel, "ahs-settings-inline-check");
acceptLabel.style.alignItems = "center"; const acceptCheckbox = acceptLabel.createEl("input");
acceptLabel.style.gap = "8px";
const acceptCheckbox = acceptLabel.createEl("input") as HTMLInputElement;
acceptCheckbox.type = "checkbox"; acceptCheckbox.type = "checkbox";
acceptCheckbox.checked = areQaGroupWarningsAccepted(qaGroupState.mapping.warnings, qaGroupState.mapping.acceptedWarnings); acceptCheckbox.checked = areQaGroupWarningsAccepted(qaGroupState.mapping.warnings, qaGroupState.mapping.acceptedWarnings);
acceptCheckbox.dataset.qaGroupWarningAccept = configId; acceptCheckbox.dataset.qaGroupWarningAccept = configId;
acceptCheckbox.addEventListener("change", () => this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked)); acceptCheckbox.addEventListener("change", () => {
acceptLabel.createEl("span", { text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") }); void this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked);
});
acceptLabel.createSpan({ text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
} }
private renderSyncContentCard(containerEl: HTMLElement): void { private renderSyncContentCard(containerEl: HTMLElement): void {
@ -663,14 +608,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private createSyncContentSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement { private createSyncContentSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
const sectionEl = containerEl.createDiv(); const sectionEl = containerEl.createDiv();
sectionEl.dataset.syncContentSection = sectionId; sectionEl.dataset.syncContentSection = sectionId;
sectionEl.style.display = "flex"; addClasses(sectionEl, "ahs-settings-section");
sectionEl.style.flexDirection = "column"; this.createSectionHeading(sectionEl, "syncContentSectionTitle", sectionId, title);
sectionEl.style.gap = "8px";
sectionEl.style.marginTop = "16px";
const titleEl = sectionEl.createEl("h4", { text: title });
titleEl.dataset.syncContentSectionTitle = sectionId;
titleEl.style.margin = "0";
return sectionEl; return sectionEl;
} }
@ -697,8 +636,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
if (!isRunScopeConfigured(this.plugin.settings.scopeMode, this.plugin.settings.includeFolders)) { if (!isRunScopeConfigured(this.plugin.settings.scopeMode, this.plugin.settings.includeFolders)) {
const warningEl = containerEl.createEl("p", { text: t("settings.scope.unconfiguredWarning") }); const warningEl = containerEl.createEl("p", { text: t("settings.scope.unconfiguredWarning") });
warningEl.dataset.scopeWarning = "unconfigured"; warningEl.dataset.scopeWarning = "unconfigured";
warningEl.style.color = "var(--text-warning)"; addClasses(warningEl, "ahs-settings-warning-text");
warningEl.style.fontWeight = "600";
} }
if (this.plugin.settings.scopeMode === "all") { if (this.plugin.settings.scopeMode === "all") {
@ -729,9 +667,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders); const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders);
const treeContainer = containerEl.createDiv(); const treeContainer = containerEl.createDiv();
treeContainer.dataset.scopeTree = "true"; treeContainer.dataset.scopeTree = "true";
treeContainer.style.display = "flex"; addClasses(treeContainer, "ahs-settings-folder-tree");
treeContainer.style.flexDirection = "column";
treeContainer.style.gap = "2px";
for (const node of selectionTree) { for (const node of selectionTree) {
this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0); this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0);
@ -858,14 +794,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private renderDeckExampleBlock(containerEl: HTMLElement): void { private renderDeckExampleBlock(containerEl: HTMLElement): void {
const exampleBlockEl = containerEl.createDiv(); const exampleBlockEl = containerEl.createDiv();
exampleBlockEl.dataset.deckExampleBlock = "true"; exampleBlockEl.dataset.deckExampleBlock = "true";
exampleBlockEl.style.display = "flex"; addClasses(exampleBlockEl, "ahs-settings-deck-example-block");
exampleBlockEl.style.flexDirection = "column";
exampleBlockEl.style.gap = "4px";
exampleBlockEl.style.marginTop = "2px";
exampleBlockEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
const titleEl = exampleBlockEl.createEl("strong", { text: t("settings.deck.examplesTitle") }); const titleEl = exampleBlockEl.createEl("strong", { text: t("settings.deck.examplesTitle") });
titleEl.style.fontSize = "var(--font-ui-small)"; addClasses(titleEl, "ahs-settings-small-title");
for (const exampleText of [ for (const exampleText of [
t("settings.deck.folderExample"), t("settings.deck.folderExample"),
@ -873,40 +805,28 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
]) { ]) {
const rowEl = exampleBlockEl.createDiv({ text: exampleText }); const rowEl = exampleBlockEl.createDiv({ text: exampleText });
rowEl.dataset.deckExampleRow = "true"; rowEl.dataset.deckExampleRow = "true";
rowEl.style.color = "var(--text-muted)"; addClasses(rowEl, "ahs-settings-helper-text");
rowEl.style.fontSize = "var(--font-ui-small)";
rowEl.style.lineHeight = "1.4";
} }
} }
private createDeckHelperText(containerEl: HTMLElement, text: string, helperId: string): HTMLElement { private createDeckHelperText(containerEl: HTMLElement, text: string, helperId: string): HTMLElement {
const helperEl = containerEl.createDiv({ text }); const helperEl = containerEl.createDiv({ text });
helperEl.dataset.deckHelperText = helperId; helperEl.dataset.deckHelperText = helperId;
helperEl.style.color = "var(--text-muted)"; addClasses(helperEl, "ahs-settings-helper-text");
helperEl.style.fontSize = "var(--font-ui-small)";
helperEl.style.lineHeight = "1.4";
helperEl.style.marginTop = "2px";
helperEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
return helperEl; return helperEl;
} }
private createDeckPriorityFooter(containerEl: HTMLElement): void { private createDeckPriorityFooter(containerEl: HTMLElement): void {
const footerEl = this.createDeckHelperText(containerEl, t("settings.deck.priorityDesc"), "priority"); const footerEl = this.createDeckHelperText(containerEl, t("settings.deck.priorityDesc"), "priority");
footerEl.dataset.deckPriorityFooter = "true"; footerEl.dataset.deckPriorityFooter = "true";
footerEl.style.marginTop = "12px"; addClasses(footerEl, "ahs-settings-helper-text-priority");
} }
private createDeckSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement { private createDeckSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
const sectionEl = containerEl.createDiv(); const sectionEl = containerEl.createDiv();
sectionEl.dataset.deckSection = sectionId; sectionEl.dataset.deckSection = sectionId;
sectionEl.style.display = "flex"; addClasses(sectionEl, "ahs-settings-section");
sectionEl.style.flexDirection = "column"; this.createSectionHeading(sectionEl, "deckSectionTitle", sectionId, title);
sectionEl.style.gap = "8px";
sectionEl.style.marginTop = "16px";
const titleEl = sectionEl.createEl("h4", { text: title });
titleEl.dataset.deckSectionTitle = sectionId;
titleEl.style.margin = "0";
return sectionEl; return sectionEl;
} }
@ -1688,7 +1608,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}); });
} }
private async refreshFolderTree(): Promise<void> { private refreshFolderTree(): void {
this.expandedFolderPaths.clear(); this.expandedFolderPaths.clear();
this.ensureFolderTreeLoaded(true); this.ensureFolderTreeLoaded(true);
this.renderCard("scope"); this.renderCard("scope");
@ -1707,44 +1627,68 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders; const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked); const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked);
await this.plugin.updateSettings(scopeMode === "include" ? { includeFolders: nextSelection } : { excludeFolders: nextSelection }); if (scopeMode === "include") {
const nextAlternateFolderDeckModeFolders = checked
? this.plugin.settings.alternateFolderDeckModeFolders
: this.plugin.settings.alternateFolderDeckModeFolders.filter((path) => !isPathInsideFolder(path, folderPath));
const nextStandaloneParentDeckFolders = checked
? this.plugin.settings.standaloneParentDeckFolders
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => !isPathInsideFolder(path, folderPath));
await this.plugin.updateSettings({
includeFolders: nextSelection,
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
});
} else {
await this.plugin.updateSettings({ excludeFolders: nextSelection });
}
this.renderCard("scope");
}
private async updateAlternateFolderDeckModeFolder(folderPath: string, checked: boolean): Promise<void> {
const nextAlternateFolderDeckModeFolders = checked
? (this.plugin.settings.alternateFolderDeckModeFolders.includes(folderPath)
? [...this.plugin.settings.alternateFolderDeckModeFolders]
: [...this.plugin.settings.alternateFolderDeckModeFolders, folderPath])
: this.plugin.settings.alternateFolderDeckModeFolders.filter((path) => path !== folderPath);
await this.plugin.updateSettings({
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
});
this.renderCard("scope");
}
private async updateStandaloneParentDeckFolder(folderPath: string, checked: boolean): Promise<void> {
const nextStandaloneParentDeckFolders = checked
? (this.plugin.settings.standaloneParentDeckFolders.includes(folderPath)
? [...this.plugin.settings.standaloneParentDeckFolders]
: [...this.plugin.settings.standaloneParentDeckFolders, folderPath])
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => path !== folderPath);
await this.plugin.updateSettings({
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
});
this.renderCard("scope"); this.renderCard("scope");
} }
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void { private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
const row = containerEl.createDiv(); const row = containerEl.createDiv();
row.dataset.folderRow = node.path; row.dataset.folderRow = node.path;
row.style.display = "grid"; addClasses(row, "ahs-settings-folder-row");
row.style.gridTemplateColumns = "24px 24px minmax(0, 1fr)"; row.style.setProperty("--ahs-folder-depth-indent", `${depth * 18}px`);
row.style.alignItems = "center";
row.style.columnGap = "0px";
row.style.minHeight = "28px";
row.style.marginLeft = `${depth * 18}px`;
row.style.width = "100%";
row.style.boxSizing = "border-box";
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const expanded = hasChildren && this.expandedFolderPaths.has(node.path); const expanded = hasChildren && this.expandedFolderPaths.has(node.path);
const toggleControl = row.createEl(hasChildren ? "button" : "span"); const toggleControl = row.createEl(hasChildren ? "button" : "span");
toggleControl.dataset.folderToggle = node.path; toggleControl.dataset.folderToggle = node.path;
toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : ""; toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : "";
toggleControl.style.width = "24px"; addClasses(toggleControl, "ahs-settings-folder-toggle");
toggleControl.style.height = "24px";
toggleControl.style.display = "inline-flex";
toggleControl.style.alignItems = "center";
toggleControl.style.justifyContent = "center";
toggleControl.style.padding = "0";
toggleControl.style.margin = "0";
toggleControl.style.border = "0";
toggleControl.style.background = "transparent";
toggleControl.style.boxShadow = "none";
toggleControl.style.fontSize = "2em";
toggleControl.style.lineHeight = "1";
toggleControl.style.color = "var(--text-muted)";
if (hasChildren) { if (hasChildren) {
addClasses(toggleControl, "ahs-settings-folder-toggle-button");
toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name })); toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
toggleControl.setAttr("title", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name })); toggleControl.setAttr("title", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
toggleControl.style.cursor = "pointer";
toggleControl.addEventListener("click", () => { toggleControl.addEventListener("click", () => {
if (this.expandedFolderPaths.has(node.path)) { if (this.expandedFolderPaths.has(node.path)) {
this.expandedFolderPaths.delete(node.path); this.expandedFolderPaths.delete(node.path);
@ -1754,25 +1698,82 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.renderCard("scope"); this.renderCard("scope");
}); });
} else {
addClasses(toggleControl, "ahs-settings-folder-toggle-spacer");
} }
const checkbox = row.createEl("input") as HTMLInputElement; const checkbox = row.createEl("input");
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = node.checked; checkbox.checked = node.checked;
checkbox.indeterminate = node.indeterminate; checkbox.indeterminate = node.indeterminate;
checkbox.dataset.folderPath = node.path; checkbox.dataset.folderPath = node.path;
checkbox.style.margin = "0"; addClasses(checkbox, "ahs-settings-folder-checkbox");
checkbox.style.justifySelf = "center";
checkbox.addEventListener("change", () => { checkbox.addEventListener("change", () => {
void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); void this.updateFolderSelection(scopeMode, node.path, checkbox.checked);
}); });
const labelEl = row.createEl("span", { text: node.name }); const contentEl = row.createDiv();
addClasses(contentEl, "ahs-settings-folder-label-stack");
const inlineLabelEl = contentEl.createDiv();
addClasses(inlineLabelEl, "ahs-settings-folder-label-inline");
const labelEl = inlineLabelEl.createSpan({ text: node.name });
labelEl.dataset.folderPathLabel = node.path; labelEl.dataset.folderPathLabel = node.path;
labelEl.style.minWidth = "0"; addClasses(labelEl, "ahs-settings-folder-label");
labelEl.style.overflow = "hidden"; this.applyFluidEllipsis(labelEl);
labelEl.style.textOverflow = "ellipsis";
labelEl.style.whiteSpace = "nowrap"; if (scopeMode === "include" && node.checked && this.plugin.settings.folderDeckMode !== "off") {
const overrideCheckbox = inlineLabelEl.createEl("input");
const overrideLabel = t("settings.scope.folderDeckModeOverride.ariaLabel", { name: node.name });
const overrideTitle = this.getFolderDeckModeOverrideTitle();
const overrideChecked = this.plugin.settings.alternateFolderDeckModeFolders.includes(node.path);
overrideCheckbox.type = "checkbox";
overrideCheckbox.checked = overrideChecked;
overrideCheckbox.dataset.folderDeckModeOverride = node.path;
overrideCheckbox.setAttr("aria-label", overrideLabel);
overrideCheckbox.setAttr("title", overrideTitle);
addClasses(overrideCheckbox, "ahs-settings-folder-override-checkbox");
overrideCheckbox.addEventListener("click", (event?: Event) => {
event?.stopPropagation();
});
overrideCheckbox.addEventListener("change", () => {
void this.updateAlternateFolderDeckModeFolder(node.path, overrideCheckbox.checked);
});
if (overrideChecked) {
const hintEl = inlineLabelEl.createSpan({ text: this.getFolderDeckModeOverrideHint() });
hintEl.dataset.folderDeckModeOverrideHint = node.path;
addClasses(hintEl, "ahs-settings-folder-override-hint");
}
if (depth > 0) {
const standaloneParentDeckCheckbox = inlineLabelEl.createEl("input");
const standaloneParentDeckLabel = t("settings.scope.standaloneParentDeck.ariaLabel", { name: node.name });
const standaloneParentDeckTitle = this.getStandaloneParentDeckTitle();
const standaloneParentDeckChecked = this.plugin.settings.standaloneParentDeckFolders.includes(node.path);
standaloneParentDeckCheckbox.type = "checkbox";
standaloneParentDeckCheckbox.checked = standaloneParentDeckChecked;
standaloneParentDeckCheckbox.dataset.folderStandaloneParentDeck = node.path;
standaloneParentDeckCheckbox.setAttr("aria-label", standaloneParentDeckLabel);
standaloneParentDeckCheckbox.setAttr("title", standaloneParentDeckTitle);
addClasses(standaloneParentDeckCheckbox, "ahs-settings-folder-override-checkbox");
standaloneParentDeckCheckbox.addEventListener("click", (event?: Event) => {
event?.stopPropagation();
});
standaloneParentDeckCheckbox.addEventListener("change", () => {
void this.updateStandaloneParentDeckFolder(node.path, standaloneParentDeckCheckbox.checked);
});
if (standaloneParentDeckChecked) {
const hintEl = inlineLabelEl.createSpan({ text: this.getStandaloneParentDeckHint() });
hintEl.dataset.folderStandaloneParentDeckHint = node.path;
addClasses(hintEl, "ahs-settings-folder-override-hint");
}
}
}
if (!hasChildren || !expanded) { if (!hasChildren || !expanded) {
return; return;
@ -1780,9 +1781,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const childrenContainer = containerEl.createDiv(); const childrenContainer = containerEl.createDiv();
childrenContainer.dataset.folderChildren = node.path; childrenContainer.dataset.folderChildren = node.path;
childrenContainer.style.display = "flex"; addClasses(childrenContainer, "ahs-settings-folder-children");
childrenContainer.style.flexDirection = "column";
childrenContainer.style.gap = "2px";
for (const child of node.children) { for (const child of node.children) {
this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1); this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1);
} }
@ -1798,46 +1797,72 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
this.scopeNeedsSelectedAncestorExpansion = false; this.scopeNeedsSelectedAncestorExpansion = false;
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) { for (const folderPath of this.getFoldersForAncestorExpansion(scopeMode)) {
for (const ancestorPath of getAncestorFolderPaths(folderPath)) { for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
this.expandedFolderPaths.add(ancestorPath); this.expandedFolderPaths.add(ancestorPath);
} }
} }
} }
private getFoldersForAncestorExpansion(scopeMode: ScopeMode): string[] {
if (scopeMode !== "include") {
return this.getSelectedFoldersForScopeMode(scopeMode);
}
return Array.from(new Set([
...this.plugin.settings.includeFolders,
...this.plugin.settings.alternateFolderDeckModeFolders,
...this.plugin.settings.standaloneParentDeckFolders,
]));
}
private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] { private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] {
return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders; return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
} }
private getFolderDeckModeOverrideHint(): string {
if (this.plugin.settings.folderDeckMode === "folder") {
return t("settings.scope.folderDeckModeOverride.hint.folderAndFile");
}
return t("settings.scope.folderDeckModeOverride.hint.folder");
}
private getFolderDeckModeOverrideTitle(): string {
if (this.plugin.settings.folderDeckMode === "folder") {
return t("settings.scope.folderDeckModeOverride.title.folderAndFile");
}
return t("settings.scope.folderDeckModeOverride.title.folder");
}
private getStandaloneParentDeckHint(): string {
return t("settings.scope.standaloneParentDeck.hint");
}
private getStandaloneParentDeckTitle(): string {
return t("settings.scope.standaloneParentDeck.title");
}
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement { private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label"); const groupEl = containerEl.createEl("label");
groupEl.style.display = "inline-flex"; addClasses(groupEl, "ahs-settings-inline-control-group");
groupEl.style.alignItems = "center";
groupEl.style.gap = "6px";
groupEl.style.flexWrap = "nowrap";
const labelEl = groupEl.createEl("span", { text: label }); const labelEl = groupEl.createSpan({ text: label });
labelEl.style.whiteSpace = "nowrap"; addClasses(labelEl, "ahs-settings-inline-control-label");
return groupEl; return groupEl;
} }
private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement { private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement {
const groupEl = containerEl.createEl("label"); const groupEl = containerEl.createEl("label");
groupEl.style.display = "flex"; addClasses(groupEl, "ahs-settings-grid-control-slot");
groupEl.style.alignItems = "center";
groupEl.style.gap = "8px";
groupEl.style.minWidth = "0";
groupEl.style.overflow = "visible";
const labelEl = groupEl.createEl("span", { text: label }); const labelEl = groupEl.createSpan({ text: label });
labelEl.style.whiteSpace = "nowrap"; addClasses(labelEl, "ahs-settings-grid-control-label");
labelEl.style.flex = "0 0 auto";
const slotEl = groupEl.createDiv(); const slotEl = groupEl.createDiv();
slotEl.style.flex = "1 1 0"; addClasses(slotEl, "ahs-settings-grid-control-content");
slotEl.style.minWidth = "0";
slotEl.style.overflow = "visible";
return slotEl; return slotEl;
} }
@ -1845,25 +1870,30 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private applyFluidEllipsis(element: HTMLElement): void { private applyFluidEllipsis(element: HTMLElement): void {
const tagName = (element.tagName || (element as unknown as { tag?: string }).tag || "").toLowerCase(); const tagName = (element.tagName || (element as unknown as { tag?: string }).tag || "").toLowerCase();
const isFieldControl = tagName === "select" || tagName === "input"; const isFieldControl = tagName === "select" || tagName === "input";
element.style.boxSizing = "border-box"; addClasses(element, "ahs-settings-fluid-ellipsis");
element.style.width = isFieldControl ? "calc(100% - 2px)" : "100%"; if (isFieldControl) {
element.style.maxWidth = isFieldControl ? "calc(100% - 2px)" : "100%"; addClasses(element, "ahs-settings-fluid-control");
element.style.minWidth = "0"; }
element.style.margin = isFieldControl ? "1px" : "0"; }
element.style.overflow = isFieldControl ? "visible" : "hidden";
element.style.textOverflow = "ellipsis"; private createSectionHeading(containerEl: HTMLElement, datasetKey: string, sectionId: string, title: string): void {
element.style.whiteSpace = "nowrap"; const headingContainer = containerEl.createDiv();
const headingSetting = new Setting(headingContainer).setName(title).setHeading();
headingSetting.settingEl.dataset[datasetKey] = sectionId;
headingSetting.nameEl.dataset[datasetKey] = sectionId;
addClasses(headingSetting.settingEl, "ahs-settings-section-heading");
} }
private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise<void>): void { private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise<void>): void {
const activeWindow = window.activeWindow;
this.textDraftValues.set(key, value); this.textDraftValues.set(key, value);
const pendingTimer = this.debouncedTextSaves.get(key); const pendingTimer = this.debouncedTextSaves.get(key);
if (pendingTimer) { if (pendingTimer) {
globalThis.clearTimeout(pendingTimer.timer); activeWindow.clearTimeout(pendingTimer.timer);
} }
const timer = globalThis.setTimeout(() => { const timer = activeWindow.setTimeout(() => {
void this.flushDebouncedTextSave(key); void this.flushDebouncedTextSave(key);
}, TEXT_SAVE_DEBOUNCE_MS); }, TEXT_SAVE_DEBOUNCE_MS);
@ -1871,12 +1901,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
private async flushDebouncedTextSave(key: string): Promise<void> { private async flushDebouncedTextSave(key: string): Promise<void> {
const activeWindow = window.activeWindow;
const pendingSave = this.debouncedTextSaves.get(key); const pendingSave = this.debouncedTextSaves.get(key);
if (!pendingSave) { if (!pendingSave) {
return; return;
} }
globalThis.clearTimeout(pendingSave.timer); activeWindow.clearTimeout(pendingSave.timer);
this.debouncedTextSaves.delete(key); this.debouncedTextSaves.delete(key);
await pendingSave.saveAction(this.textDraftValues.get(key) ?? ""); await pendingSave.saveAction(this.textDraftValues.get(key) ?? "");
} }
@ -1891,7 +1922,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
private createSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement { private createSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement {
const selectEl = containerEl.createEl("select") as HTMLSelectElement; const selectEl = containerEl.createEl("select");
selectEl.dataset.selectKey = datasetKey; selectEl.dataset.selectKey = datasetKey;
return selectEl; return selectEl;
} }
@ -1901,7 +1932,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
private appendOption(selectEl: HTMLSelectElement, value: string, label: string): void { private appendOption(selectEl: HTMLSelectElement, value: string, label: string): void {
const optionEl = selectEl.createEl("option", { text: label }) as HTMLOptionElement; const optionEl = selectEl.createEl("option", { text: label });
optionEl.value = value; optionEl.value = value;
} }
@ -1980,6 +2011,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
} }
} }
function normalizeFolderScopePath(path: string): string {
return path.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function isPathInsideFolder(filePath: string, folderPath: string): boolean {
const normalizedFilePath = normalizeFolderScopePath(filePath);
const normalizedFolderPath = normalizeFolderScopePath(folderPath);
return normalizedFilePath === normalizedFolderPath || normalizedFilePath.startsWith(`${normalizedFolderPath}/`);
}
function getScopeModeSummary(scopeMode: ScopeMode): string { function getScopeModeSummary(scopeMode: ScopeMode): string {
if (scopeMode === "include") { if (scopeMode === "include") {
return t("settings.scope.summary.include"); return t("settings.scope.summary.include");

View file

@ -17,13 +17,14 @@ export class InMemoryPluginStateRepository implements PluginStateRepository {
constructor(private state: PluginState = createEmptyPluginState()) {} constructor(private state: PluginState = createEmptyPluginState()) {}
async load(): Promise<PluginState> { load(): Promise<PluginState> {
return this.state; return Promise.resolve(this.state);
} }
async save(state: PluginState): Promise<void> { save(state: PluginState): Promise<void> {
this.state = state; this.state = state;
this.savedState = state; this.savedState = state;
return Promise.resolve();
} }
} }
@ -43,74 +44,49 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
} }
} }
async listMarkdownFileRefs() { listMarkdownFileRefs(): Promise<Array<{ path: string; basename: string; mtime: number; size: number }>> {
return Array.from(this.files.entries()).map(([path, file]) => ({ return Promise.resolve(Array.from(this.files.entries()).map(([path, file]) => ({
path, path,
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path, basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
mtime: file.mtime, mtime: file.mtime,
size: file.size, size: file.size,
})); })));
} }
async listFolderTree(): Promise<FolderTreeNode[]> { listFolderTree(): Promise<FolderTreeNode[]> {
const rootMap = new Map<string, FolderTreeNode>(); return Promise.resolve(buildFolderTree(Array.from(this.files.keys())));
for (const path of this.files.keys()) {
const segments = path.split("/");
segments.pop();
let currentPath = "";
let siblings = rootMap;
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
const existing = siblings.get(currentPath);
if (existing) {
siblings = new Map(existing.children.map((child) => [child.path, child]));
continue;
}
const nextNode: FolderTreeNode = {
path: currentPath,
name: segment,
children: [],
};
siblings.set(currentPath, nextNode);
siblings = new Map();
}
}
return buildFolderTree(Array.from(this.files.keys()));
} }
async readMarkdownFile(path: string): Promise<SourceFile | null> { readMarkdownFile(path: string): Promise<SourceFile | null> {
this.readCalls.push(path); this.readCalls.push(path);
const file = this.files.get(path); const file = this.files.get(path);
if (!file) { if (!file) {
return null; return Promise.resolve(null);
} }
return { return Promise.resolve({
path, path,
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path, basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
content: file.content, content: file.content,
tags: [], tags: [],
}; });
} }
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> { replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
this.replaceCalls.push({ path, expectedContent, nextContent }); this.replaceCalls.push({ path, expectedContent, nextContent });
if (this.errorPaths.has(path)) { const pathError = this.errorPaths.get(path);
throw this.errorPaths.get(path); if (pathError) {
return Promise.reject(pathError);
} }
if (this.conflictPaths.has(path)) { if (this.conflictPaths.has(path)) {
throw new MarkdownWriteConflictError(path); return Promise.reject(new MarkdownWriteConflictError(path));
} }
const current = this.files.get(path); const current = this.files.get(path);
if (!current || current.content !== expectedContent) { if (!current || current.content !== expectedContent) {
throw new MarkdownWriteConflictError(path); return Promise.reject(new MarkdownWriteConflictError(path));
} }
this.files.set(path, { this.files.set(path, {
@ -118,6 +94,8 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
mtime: current.mtime + 1, mtime: current.mtime + 1,
size: nextContent.length, size: nextContent.length,
}); });
return Promise.resolve();
} }
resolveWikiLink(rawTarget: string) { resolveWikiLink(rawTarget: string) {
@ -170,40 +148,44 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
await this.ensureDecks([deckName]); await this.ensureDecks([deckName]);
} }
async ensureDecks(deckNames: string[]): Promise<void> { ensureDecks(deckNames: string[]): Promise<void> {
this.operationLog.push("ensureDecks"); this.operationLog.push("ensureDecks");
this.ensuredDecks.push(deckNames); this.ensuredDecks.push(deckNames);
return Promise.resolve();
} }
async listNoteModels(): Promise<string[]> { listNoteModels(): Promise<string[]> {
return Object.keys(this.modelDetailsByName); return Promise.resolve(Object.keys(this.modelDetailsByName));
} }
async listDeckNames(): Promise<string[]> { listDeckNames(): Promise<string[]> {
return this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys()); return Promise.resolve(this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys()));
} }
async getModelDetails(modelName: string): Promise<NoteModelDetails> { getModelDetails(modelName: string): Promise<NoteModelDetails> {
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] }; return Promise.resolve(this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] });
} }
async getModelFieldNames(modelName: string): Promise<string[]> { getModelFieldNames(modelName: string): Promise<string[]> {
return this.modelDetailsByName[modelName]?.fieldNames ?? []; return Promise.resolve(this.modelDetailsByName[modelName]?.fieldNames ?? []);
} }
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> { getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
return Object.fromEntries(await Promise.all(modelNames.map(async (modelName) => [ const fieldNamesByModelName: Record<string, string[]> = {};
modelName,
await this.getModelFieldNames(modelName), for (const modelName of modelNames) {
]))); fieldNamesByModelName[modelName] = this.modelDetailsByName[modelName]?.fieldNames ?? [];
}
return Promise.resolve(fieldNamesByModelName);
} }
async findNoteIds(query: string): Promise<number[]> { findNoteIds(query: string): Promise<number[]> {
return [...(this.foundNoteIds.get(query) ?? [])]; return Promise.resolve([...(this.foundNoteIds.get(query) ?? [])]);
} }
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> { getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
return noteIds.flatMap((noteId) => { return Promise.resolve(noteIds.flatMap((noteId) => {
const detail = this.noteDetailsById.get(noteId); const detail = this.noteDetailsById.get(noteId);
if (detail) { if (detail) {
return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, tags: detail.tags ? [...detail.tags] : undefined, fields: { ...detail.fields } }]; return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, tags: detail.tags ? [...detail.tags] : undefined, fields: { ...detail.fields } }];
@ -211,18 +193,18 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
const summary = this.noteSummariesById.get(noteId); const summary = this.noteSummariesById.get(noteId);
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined, fields: {} }] : []; return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined, fields: {} }] : [];
}); }));
} }
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> { getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName }); return Promise.resolve(deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName }));
} }
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> { getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
return noteIds.flatMap((noteId) => { return Promise.resolve(noteIds.flatMap((noteId) => {
const summary = this.noteSummariesById.get(noteId); const summary = this.noteSummariesById.get(noteId);
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined }] : []; return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined }] : [];
}); }));
} }
async addNote(input: AddAnkiNoteInput): Promise<number> { async addNote(input: AddAnkiNoteInput): Promise<number> {
@ -230,12 +212,14 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
return noteId; return noteId;
} }
async addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> { addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
return inputs.map((input) => { const noteIds: number[] = [];
for (const input of inputs) {
this.operationLog.push("addNotes"); this.operationLog.push("addNotes");
const pendingError = this.addNotesErrorQueue.shift(); const pendingError = this.addNotesErrorQueue.shift();
if (pendingError) { if (pendingError) {
throw pendingError; return Promise.reject(pendingError);
} }
this.addedNotes.push(input); this.addedNotes.push(input);
@ -256,11 +240,13 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
tags: [...input.tags], tags: [...input.tags],
fields: { ...input.fields }, fields: { ...input.fields },
}); });
return noteId; noteIds.push(noteId);
}); }
return Promise.resolve(noteIds);
} }
async deleteNotes(noteIds: number[]): Promise<void> { deleteNotes(noteIds: number[]): Promise<void> {
this.operationLog.push("deleteNotes"); this.operationLog.push("deleteNotes");
this.deletedNotes.push(noteIds); this.deletedNotes.push(noteIds);
@ -271,17 +257,19 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
const pendingError = this.deleteNotesErrorQueue.shift(); const pendingError = this.deleteNotesErrorQueue.shift();
if (pendingError) { if (pendingError) {
throw pendingError; return Promise.reject(pendingError);
} }
return Promise.resolve();
} }
async updateNote(input: UpdateAnkiNoteInput): Promise<void> { async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
await this.updateNotes([input]); await this.updateNotes([input]);
} }
async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> { updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
if (this.updateNoteModelError) { if (this.updateNoteModelError) {
throw this.updateNoteModelError; return Promise.reject(this.updateNoteModelError);
} }
this.updatedNoteModels.push(input); this.updatedNoteModels.push(input);
@ -302,13 +290,16 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
fields: { ...input.fields }, fields: { ...input.fields },
}); });
} }
return Promise.resolve();
} }
async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> { updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
this.updatedNotes.push(...inputs); this.updatedNotes.push(...inputs);
return Promise.resolve();
} }
async syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> { syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> {
this.syncedNoteTags.push(...inputs); this.syncedNoteTags.push(...inputs);
for (const input of inputs) { for (const input of inputs) {
@ -333,22 +324,27 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
this.noteDetailsById.set(input.noteId, { ...detail, tags: nextTags }); this.noteDetailsById.set(input.noteId, { ...detail, tags: nextTags });
} }
} }
return Promise.resolve();
} }
async changeDecks(inputs: ChangeDeckInput[]): Promise<void> { changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
this.changedDecks.push(...inputs); this.changedDecks.push(...inputs);
return Promise.resolve();
} }
async deleteDecks(deckNames: string[]): Promise<void> { deleteDecks(deckNames: string[]): Promise<void> {
this.deletedDecks.push(deckNames); this.deletedDecks.push(deckNames);
return Promise.resolve();
} }
async storeMedia(asset: MediaAsset): Promise<void> { async storeMedia(asset: MediaAsset): Promise<void> {
await this.storeMediaFiles([asset]); await this.storeMediaFiles([asset]);
} }
async storeMediaFiles(assets: MediaAsset[]): Promise<void> { storeMediaFiles(assets: MediaAsset[]): Promise<void> {
this.storedMedia.push(...assets); this.storedMedia.push(...assets);
return Promise.resolve();
} }
} }

View file

@ -1,5 +1,5 @@
export async function requestUrl(): Promise<never> { export function requestUrl(): Promise<never> {
throw new Error("obsidian.requestUrl stub was called without a test mock."); return Promise.reject(new Error("obsidian.requestUrl stub was called without a test mock."));
} }
export function getAllTags(cache: { export function getAllTags(cache: {

View file

@ -1 +1,347 @@
/* Intentionally empty: settings controls use native Obsidian component styles. */ .anki-heading-sync-settings .ahs-is-hidden {
display: none;
}
.anki-heading-sync-settings .ahs-settings-page-header {
position: sticky;
top: 0;
z-index: 300;
width: 100%;
box-sizing: border-box;
padding-top: 12px;
padding-bottom: calc(12px + var(--ahs-settings-sticky-card-gap, 8px));
margin-bottom: 0;
overflow: visible;
background: var(--modal-background, var(--background-primary));
background-color: var(--modal-background, var(--background-primary));
box-shadow: none;
}
.anki-heading-sync-settings .ahs-settings-page-header-mask {
position: absolute;
top: -128px;
left: -24px;
right: -24px;
bottom: 0;
z-index: 0;
pointer-events: none;
background: var(--modal-background, var(--background-primary));
background-color: var(--modal-background, var(--background-primary));
}
.anki-heading-sync-settings .ahs-settings-page-title-setting {
position: relative;
z-index: 1;
margin: 0;
padding: 0;
}
.anki-heading-sync-settings .ahs-settings-page-title {
margin: 0;
}
.anki-heading-sync-settings .ahs-settings-card-toggle {
position: sticky;
top: var(--ahs-settings-page-header-height, 64px);
z-index: 200;
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 10px 14px;
margin-bottom: 8px;
border: 2px solid var(--background-modifier-border);
border-radius: 0;
background: var(--modal-background, var(--background-primary));
background-color: var(--modal-background, var(--background-primary));
box-shadow: none;
font-size: 1.5em;
font-weight: 600;
text-align: left;
}
.anki-heading-sync-settings .ahs-settings-action-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.anki-heading-sync-settings .ahs-settings-card-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.anki-heading-sync-settings .ahs-settings-card-type-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
}
.anki-heading-sync-settings .ahs-settings-card-type-header {
display: flex;
align-items: center;
gap: 10px;
}
.anki-heading-sync-settings .ahs-settings-card-type-title {
font-size: var(--font-ui-medium);
}
.anki-heading-sync-settings .ahs-settings-recognition-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.anki-heading-sync-settings .ahs-settings-heading-select {
width: 88px;
}
.anki-heading-sync-settings .ahs-settings-marker-input {
width: 220px;
}
.anki-heading-sync-settings .ahs-settings-muted-text {
margin: 0;
color: var(--text-muted);
}
.anki-heading-sync-settings .ahs-settings-grid-row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr);
align-items: center;
gap: 8px 16px;
overflow: visible;
width: 100%;
}
.anki-heading-sync-settings .ahs-settings-qa-group-pair-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) max-content;
align-items: center;
gap: 8px 16px;
width: 100%;
}
.anki-heading-sync-settings .ahs-settings-grid-full {
grid-column: 1 / -1;
}
.anki-heading-sync-settings .ahs-settings-nowrap-start {
justify-self: start;
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-warnings {
display: flex;
flex-direction: column;
gap: 6px;
}
.anki-heading-sync-settings .ahs-settings-inline-check {
display: inline-flex;
align-items: center;
gap: 8px;
}
.anki-heading-sync-settings .ahs-settings-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.anki-heading-sync-settings .ahs-settings-warning-text {
color: var(--text-warning);
font-weight: 600;
}
.anki-heading-sync-settings .ahs-settings-folder-tree,
.anki-heading-sync-settings .ahs-settings-folder-children {
display: flex;
flex-direction: column;
gap: 2px;
}
.anki-heading-sync-settings .ahs-settings-deck-example-block {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
margin-left: 32px;
}
.anki-heading-sync-settings .ahs-settings-small-title {
font-size: var(--font-ui-small);
}
.anki-heading-sync-settings .ahs-settings-helper-text {
margin-top: 2px;
margin-left: 32px;
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: 1.4;
}
.anki-heading-sync-settings .ahs-settings-helper-text-priority {
margin-top: 12px;
}
.anki-heading-sync-settings .ahs-settings-folder-row {
display: grid;
grid-template-columns: 24px 24px minmax(0, 1fr);
align-items: center;
gap: 0;
width: 100%;
min-height: 28px;
box-sizing: border-box;
margin-left: var(--ahs-folder-depth-indent, 0px);
}
.anki-heading-sync-settings .ahs-settings-folder-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: 0;
background: transparent;
box-shadow: none;
color: var(--text-muted);
font-size: 2em;
line-height: 1;
}
.anki-heading-sync-settings .ahs-settings-folder-toggle-button {
cursor: pointer;
}
.anki-heading-sync-settings .ahs-settings-folder-checkbox {
justify-self: center;
margin: 0;
}
.anki-heading-sync-settings .ahs-settings-folder-label {
min-width: 0;
}
.anki-heading-sync-settings .ahs-settings-folder-label-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
gap: 2px;
}
.anki-heading-sync-settings .ahs-settings-folder-label-inline {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox {
flex: none;
width: 16px;
height: 16px;
margin: 0;
appearance: none;
border: 1.5px solid var(--background-modifier-border);
border-radius: 999px;
background: var(--background-primary);
box-shadow: none;
cursor: pointer;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox:checked {
border-color: var(--interactive-accent);
background-image: radial-gradient(circle, var(--interactive-accent) 0 4px, transparent 4px);
background-repeat: no-repeat;
background-position: center;
}
.anki-heading-sync-settings .ahs-settings-folder-override-checkbox:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}
.anki-heading-sync-settings .ahs-settings-folder-override-hint {
flex: none;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
line-height: 1.35;
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-inline-control-group {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.anki-heading-sync-settings .ahs-settings-inline-control-label {
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-grid-control-slot {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: visible;
}
.anki-heading-sync-settings .ahs-settings-grid-control-label {
flex: 0 0 auto;
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-grid-control-content {
flex: 1 1 0;
min-width: 0;
overflow: visible;
}
.anki-heading-sync-settings .ahs-settings-fluid-ellipsis {
width: 100%;
max-width: 100%;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.anki-heading-sync-settings .ahs-settings-fluid-ellipsis:not(select):not(input) {
box-sizing: border-box;
margin: 0;
overflow: hidden;
}
.anki-heading-sync-settings .ahs-settings-folder-label.ahs-settings-fluid-ellipsis {
flex: 0 1 auto;
width: auto;
max-width: 100%;
}
.anki-heading-sync-settings select.ahs-settings-fluid-control,
.anki-heading-sync-settings input.ahs-settings-fluid-control {
box-sizing: border-box;
width: calc(100% - 2px);
max-width: calc(100% - 2px);
min-width: 0;
margin: 1px;
overflow: visible;
}

View file

@ -1,3 +1,7 @@
{ {
"1.0.0": "1.5.0" "1.0.0": "1.8.7",
"1.0.1": "1.8.7",
"1.0.2": "1.8.7",
"1.0.3": "1.8.7",
"1.0.4": "1.8.7"
} }