mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e36c25c4 | ||
|
|
0dc2d7cb5d | ||
|
|
befe28cd77 | ||
|
|
13b5685fe5 | ||
|
|
387f977dca | ||
|
|
0f8d5eefcb | ||
|
|
80d6020512 | ||
|
|
a898aa80e9 | ||
|
|
488bd3d91f | ||
|
|
3e5ce2c1d2 | ||
|
|
6fb8583feb | ||
|
|
b4e6e53c61 | ||
|
|
85eb77d20d |
47 changed files with 5078 additions and 689 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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`
|
||||
|
||||
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.
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -26,7 +26,7 @@ Anki Heading Sync 是一个 Obsidian 桌面端插件,用来批量地制作 Ank
|
|||
|
||||
### 使用要求
|
||||
|
||||
- Obsidian 桌面端 1.5.0 或更高版本
|
||||
- Obsidian 桌面端 1.8.7 或更高版本
|
||||
- Anki 桌面端
|
||||
- Anki 中已安装并启用 AnkiConnect
|
||||
|
||||
|
|
@ -48,6 +48,15 @@ Anki Heading Sync 是一个 Obsidian 桌面端插件,用来批量地制作 Ank
|
|||
- 如果 release 中包含 `styles.css`,也一起复制
|
||||
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 设置
|
||||
|
||||
1. 在桌面端打开 Anki。
|
||||
|
|
@ -139,7 +148,7 @@ Currently, the plugin mainly supports four card types:
|
|||
|
||||
### Requirements
|
||||
|
||||
- Obsidian desktop 1.5.0 or newer
|
||||
- Obsidian desktop 1.8.7 or newer
|
||||
- Anki desktop
|
||||
- AnkiConnect enabled in Anki
|
||||
|
||||
|
|
@ -161,6 +170,15 @@ Once the plugin is accepted into the Obsidian Community Plugins directory, insta
|
|||
- `styles.css` when present
|
||||
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
|
||||
|
||||
1. Open Anki on your desktop.
|
||||
|
|
|
|||
67
docs/folder-deck-mode-overrides-decisions.md
Normal file
67
docs/folder-deck-mode-overrides-decisions.md
Normal 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 的副作用。
|
||||
- 使用原生 checkbox,CSS 改成圆形外观并保留 `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` 没有这样做,新字段若单独增强会造成配置行为不一致。
|
||||
- 偏差 2:DeckResolution 直接改为接收完整 `settings`。原因:当前普通卡片和 QA Group 都通过该服务决策 deck,把覆盖规则聚合在这里最小且一致,避免在多个调用点重复计算“反向 mode”。
|
||||
65
docs/folder-deck-mode-overrides-gap-report.md
Normal file
65
docs/folder-deck-mode-overrides-gap-report.md
Normal 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` 时必须忽略覆盖列表,但不能清空已保存数据。
|
||||
|
|
@ -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.
|
||||
- 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`.
|
||||
- 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-lock.json is present, and no yarn.lock was found.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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 run build`.
|
||||
- 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:
|
||||
- main.js
|
||||
- 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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "node:process";
|
||||
import builtins from "builtin-modules";
|
||||
import { builtinModules } from "node:module";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
|
||||
const banner = `/*\nTHIS IS A GENERATED/BUNDLED FILE BY ESBUILD\n*/`;
|
||||
|
||||
const production = process.argv[2] === "production";
|
||||
const outfile = "dist/plugin/main.js";
|
||||
const builtins = [...new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))];
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
|
|
@ -8,6 +9,7 @@ export default tseslint.config(
|
|||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...obsidianmd.configs.recommendedWithLocalesEn,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
|
|
@ -24,7 +26,9 @@ export default tseslint.config(
|
|||
"error",
|
||||
{ "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
56
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "anki-heading-sync",
|
||||
"name": "Anki Heading Sync",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Focused heading-based Obsidian to Anki sync plugin.",
|
||||
"version": "1.0.4",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
"authorUrl": "https://github.com/panAtGitHub/AnkiHeadingSync",
|
||||
"authorUrl": "https://github.com/panAtGitHub",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2986
package-lock.json
generated
2986
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Focused Obsidian to Anki heading-based sync plugin.",
|
||||
"version": "1.0.4",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
"main": "dist/plugin/main.js",
|
||||
"dependencies": {
|
||||
|
|
@ -26,9 +26,9 @@
|
|||
"@types/node": "^22.15.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
"globals": "^16.4.0",
|
||||
"obsidian": "1.12.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ describe("PluginSettings", () => {
|
|||
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("folder-and-file");
|
||||
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
|
||||
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.obsidianBacklinkPlacement).toBe("answer-last-line");
|
||||
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", () => {
|
||||
const settings = mergePluginSettings({
|
||||
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export interface PluginSettings {
|
|||
scopeMode: ScopeMode;
|
||||
includeFolders: string[];
|
||||
excludeFolders: string[];
|
||||
alternateFolderDeckModeFolders: string[];
|
||||
standaloneParentDeckFolders: string[];
|
||||
addObsidianBacklink: boolean;
|
||||
obsidianBacklinkLabel: string;
|
||||
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
|
||||
|
|
@ -89,6 +91,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
scopeMode: "include",
|
||||
includeFolders: [],
|
||||
excludeFolders: [],
|
||||
alternateFolderDeckModeFolders: [],
|
||||
standaloneParentDeckFolders: [],
|
||||
addObsidianBacklink: true,
|
||||
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
|
||||
obsidianBacklinkPlacement: "answer-last-line",
|
||||
|
|
@ -144,6 +148,8 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
|
|||
ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache,
|
||||
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
|
||||
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.excludeFolders, "exclude");
|
||||
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
|
||||
validateFolderList(settings.standaloneParentDeckFolders, "standaloneParentDeck");
|
||||
|
||||
if (!settings.ankiConnectUrl.trim()) {
|
||||
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)) {
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import * as obsidian from "obsidian";
|
||||
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";
|
||||
|
||||
function setObsidianLanguage(language: string): void {
|
||||
getLanguageMock.mockReturnValue(language);
|
||||
}
|
||||
|
||||
describe("PluginUserError", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
|
|
@ -17,7 +28,7 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
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" });
|
||||
|
||||
|
|
@ -25,19 +36,19 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
"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(
|
||||
"运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders write-back failure summaries with localized detail lines", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
setObsidianLanguage("zh");
|
||||
|
||||
const error = new PluginUserError(
|
||||
"errors.writeBack.summary",
|
||||
|
|
@ -58,7 +69,7 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
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({ rawMessage: "raw failure" })).toBe("raw failure");
|
||||
|
|
@ -70,14 +81,14 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { AnkiBatchExecutor } from "./AnkiBatchExecutor";
|
|||
class CountingAnkiGateway extends FakeManualSyncAnkiGateway {
|
||||
public getModelDetailsCalls: string[] = [];
|
||||
|
||||
override async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
override getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
this.getModelDetailsCalls.push(modelName);
|
||||
return super.getModelDetails(modelName);
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
const result = await executor.execute(
|
||||
plan,
|
||||
renderedCards,
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
renderedCards,
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[createCard.card.syncKey, createRenderedSyncCard(createCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -270,7 +270,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
)).rejects.toThrow("delete old failed");
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"basic:New Basic": {
|
||||
|
|
@ -390,7 +390,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"cloze:New Cloze": {
|
||||
|
|
@ -439,7 +439,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -473,7 +473,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"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 {
|
||||
return {
|
||||
noteId,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class BatchScheduler {
|
|||
}
|
||||
|
||||
const batches = chunk(items, batchSize);
|
||||
const results: TResult[][] = new Array(batches.length);
|
||||
const results: TResult[][] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
|
|
|
|||
|
|
@ -299,6 +299,144 @@ describe("FileIndexerService", () => {
|
|||
expect(result.skippedUnchangedFiles).toBe(0);
|
||||
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] = {}) {
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function createFileStamp(mtime: number, size: number): string {
|
|||
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 {
|
||||
return hashString(JSON.stringify({
|
||||
|
|
@ -302,6 +302,8 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
|
|||
fileDeckEnabled: settings.fileDeckEnabled,
|
||||
fileDeckMarker: settings.fileDeckMarker,
|
||||
folderDeckMode: settings.folderDeckMode,
|
||||
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
|
||||
standaloneParentDeckFolders: [...settings.standaloneParentDeckFolders].sort(),
|
||||
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
|
||||
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -69,17 +69,28 @@ describe("ManualSyncService", () => {
|
|||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.rewrittenMarkers).toBe(1);
|
||||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
题目: "Concepts",
|
||||
问题01: "Alpha",
|
||||
答案01: expect.stringContaining("First answer"),
|
||||
问题02: "Beta",
|
||||
答案02: expect.stringContaining("Second answer"),
|
||||
});
|
||||
const addedNote = ankiGateway.addedNotes[0];
|
||||
expect(addedNote?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
const addedNoteFields = addedNote?.fields;
|
||||
expect(addedNoteFields).toBeDefined();
|
||||
if (!addedNoteFields) {
|
||||
throw new Error("Expected the created note fields to be recorded.");
|
||||
}
|
||||
|
||||
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(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1);
|
||||
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1);
|
||||
const savedState = stateRepository.savedState;
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export class ManualSyncService {
|
|||
const executionResult = await this.ankiBatchExecutor.execute(
|
||||
plan,
|
||||
renderedCards,
|
||||
async (plannedCard) => this.renderer.render(plannedCard, renderContext),
|
||||
(plannedCard) => Promise.resolve(this.renderer.render(plannedCard, renderContext)),
|
||||
settings.noteFieldMappings,
|
||||
);
|
||||
const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings);
|
||||
|
|
|
|||
|
|
@ -538,19 +538,20 @@ describe("QaGroupSyncService", () => {
|
|||
expect(result.resolvedNoteIds.get("notes/example.md\u0000group\u00001\u0000hash-1")).toBe(42);
|
||||
expect(ankiGateway.addedNotes).toEqual([]);
|
||||
expect(ankiGateway.updatedNotes).toEqual([]);
|
||||
expect(ankiGateway.updatedNoteModels).toEqual([
|
||||
{
|
||||
noteId: 42,
|
||||
modelName: QA_GROUP_USER_NOTE_TYPE,
|
||||
fields: expect.objectContaining({
|
||||
题目: "Concepts",
|
||||
问题01: "Alpha",
|
||||
答案01: expect.stringContaining("First answer"),
|
||||
问题02: "Beta",
|
||||
答案02: expect.stringContaining("Second answer"),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(ankiGateway.updatedNoteModels).toHaveLength(1);
|
||||
const updatedQaGroupNoteModel = ankiGateway.updatedNoteModels[0];
|
||||
expect(updatedQaGroupNoteModel).toBeDefined();
|
||||
if (!updatedQaGroupNoteModel) {
|
||||
throw new Error("Expected a QA Group note model update.");
|
||||
}
|
||||
|
||||
expect(updatedQaGroupNoteModel.noteId).toBe(42);
|
||||
expect(updatedQaGroupNoteModel.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
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([
|
||||
{
|
||||
noteId: 42,
|
||||
|
|
@ -642,16 +643,20 @@ describe("QaGroupSyncService", () => {
|
|||
}));
|
||||
|
||||
expect(result.migratedNoteTypes).toBe(1);
|
||||
expect(ankiGateway.updatedNoteModels[0]?.modelName).toBe("问答题(6组)");
|
||||
expect(ankiGateway.updatedNoteModels[0]?.fields).toMatchObject({
|
||||
题目: "Concepts",
|
||||
问题01: "A",
|
||||
答案01: expect.stringContaining("1"),
|
||||
问题05: "E",
|
||||
答案05: expect.stringContaining("5"),
|
||||
问题06: "",
|
||||
答案06: "",
|
||||
});
|
||||
const updatedNoteModel = ankiGateway.updatedNoteModels[0];
|
||||
expect(updatedNoteModel).toBeDefined();
|
||||
if (!updatedNoteModel) {
|
||||
throw new Error("Expected a migrated QA Group note model update.");
|
||||
}
|
||||
|
||||
expect(updatedNoteModel.modelName).toBe("问答题(6组)");
|
||||
expect(updatedNoteModel.fields["题目"]).toBe("Concepts");
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export class QaGroupSyncService {
|
|||
deckHint: block.deckHint,
|
||||
deckHintSource: block.deckHintSource,
|
||||
deckWarnings: block.deckWarnings,
|
||||
} as never, settings.defaultDeck, settings.folderDeckMode);
|
||||
} as never, settings);
|
||||
for (const warning of deckResolution.warnings) {
|
||||
warningMap.set(getDeckResolutionWarningKey(warning), warning);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export class RenderConfigService {
|
|||
|
||||
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
|
||||
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 mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
|
||||
const clozeMode = card.cardType === "cloze" && card.clozeMode === "all"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
|
||||
|
||||
import { DeckResolutionService } from "./DeckResolutionService";
|
||||
|
|
@ -12,7 +13,7 @@ describe("DeckResolutionService", () => {
|
|||
filePath: "课程/数学/第一章/导数.md",
|
||||
deckHint: "显式/Deck",
|
||||
deckHintSource: "frontmatter",
|
||||
}), "Default", "folder");
|
||||
}), createDeckSettings({ folderDeckMode: "folder" }));
|
||||
|
||||
expect(result.resolvedDeck).toEqual({
|
||||
value: "显式::Deck",
|
||||
|
|
@ -24,7 +25,7 @@ describe("DeckResolutionService", () => {
|
|||
it("uses folder mapping when no explicit deck exists", () => {
|
||||
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({
|
||||
value: "课程::数学::第一章",
|
||||
|
|
@ -35,7 +36,7 @@ describe("DeckResolutionService", () => {
|
|||
it("supports folder-and-file mapping mode", () => {
|
||||
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({
|
||||
value: "课程::数学::第一章::导数",
|
||||
|
|
@ -46,7 +47,10 @@ describe("DeckResolutionService", () => {
|
|||
it("falls back to default deck for root-level files and emits a warning", () => {
|
||||
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({
|
||||
value: "Default::Deck",
|
||||
|
|
@ -58,7 +62,7 @@ describe("DeckResolutionService", () => {
|
|||
it("warns and falls back to default deck when folder mapping is invalid", () => {
|
||||
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.warnings.map((warning) => warning.code)).toEqual([
|
||||
|
|
@ -70,10 +74,50 @@ describe("DeckResolutionService", () => {
|
|||
filePath: "课程::非法/导数.md",
|
||||
deckHint: "显式::Deck",
|
||||
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 {
|
||||
return {
|
||||
noteId: overrides.noteId,
|
||||
|
|
|
|||
|
|
@ -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 { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
|
||||
|
|
@ -11,7 +11,10 @@ export class DeckResolutionService {
|
|||
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) {
|
||||
return {
|
||||
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) {
|
||||
return {
|
||||
resolvedDeck: {
|
||||
|
|
@ -35,7 +43,7 @@ export class DeckResolutionService {
|
|||
|
||||
return {
|
||||
resolvedDeck: {
|
||||
value: this.deckNormalizationService.normalize(defaultDeck),
|
||||
value: this.deckNormalizationService.normalize(settings.defaultDeck),
|
||||
source: "default",
|
||||
},
|
||||
warnings: [
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
const service = new FolderDeckMappingService();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,24 +11,32 @@ export interface FolderDeckMappingResult {
|
|||
export class FolderDeckMappingService {
|
||||
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
|
||||
|
||||
mapFilePathToDeck(filePath: string, mode: FolderDeckMode): FolderDeckMappingResult {
|
||||
if (mode === "off") {
|
||||
mapFilePathToDeck(
|
||||
filePath: string,
|
||||
mode: FolderDeckMode,
|
||||
alternateFolderDeckModeFolders: string[] = [],
|
||||
standaloneParentDeckFolders: string[] = [],
|
||||
): FolderDeckMappingResult {
|
||||
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
|
||||
|
||||
if (effectiveMode === "off") {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const lastSlash = filePath.lastIndexOf("/");
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
const lastSlash = normalizedFilePath.lastIndexOf("/");
|
||||
if (lastSlash < 0) {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const folderPath = filePath.slice(0, lastSlash);
|
||||
const folderPath = normalizedFilePath.slice(0, lastSlash);
|
||||
if (!folderPath.trim()) {
|
||||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const segments = folderPath.split("/").filter(Boolean);
|
||||
if (mode === "folder-and-file") {
|
||||
const fileName = filePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
|
||||
const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
|
||||
if (effectiveMode === "folder-and-file") {
|
||||
const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
|
||||
if (fileName) {
|
||||
segments.push(fileName);
|
||||
}
|
||||
|
|
@ -48,4 +56,63 @@ export class FolderDeckMappingService {
|
|||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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}/`);
|
||||
}
|
||||
|
|
@ -10,6 +10,26 @@ vi.mock("obsidian", () => ({
|
|||
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
requestUrlMock.mockReset();
|
||||
|
|
@ -27,7 +47,7 @@ describe("AnkiConnectGateway", () => {
|
|||
const models = await gateway.listNoteModels();
|
||||
|
||||
expect(models).toEqual(["Basic", "Cloze", "Custom Basic"]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "modelNames",
|
||||
version: 6,
|
||||
params: {},
|
||||
|
|
@ -59,7 +79,7 @@ describe("AnkiConnectGateway", () => {
|
|||
Basic: ["Front", "Back"],
|
||||
Cloze: ["Text", "Extra"],
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -118,7 +138,7 @@ describe("AnkiConnectGateway", () => {
|
|||
const deckNames = await gateway.listDeckNames();
|
||||
|
||||
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",
|
||||
version: 6,
|
||||
params: {},
|
||||
|
|
@ -170,14 +190,14 @@ describe("AnkiConnectGateway", () => {
|
|||
{ 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"] },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "notesInfo",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [100, 101, 102],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "cardsInfo",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -205,7 +225,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "updateNoteModel",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -263,7 +283,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -330,12 +350,12 @@ describe("AnkiConnectGateway", () => {
|
|||
{ deckName: "Empty", noteCount: 0 },
|
||||
{ deckName: "Busy", noteCount: 3 },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "deckNamesAndIds",
|
||||
version: 6,
|
||||
params: {},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "getDeckStats",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -572,7 +592,7 @@ describe("AnkiConnectGateway", () => {
|
|||
]);
|
||||
|
||||
expect(noteIds).toEqual([9001, 9002]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -642,7 +662,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -659,7 +679,7 @@ describe("AnkiConnectGateway", () => {
|
|||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -674,7 +694,7 @@ describe("AnkiConnectGateway", () => {
|
|||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[2][0].body)).toEqual({
|
||||
expect(getRequestBody(2)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -707,7 +727,7 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "updateNoteFields",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -738,14 +758,14 @@ describe("AnkiConnectGateway", () => {
|
|||
await gateway.deleteNotes([1, 2]);
|
||||
await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "deleteNotes",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [1, 2],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "deleteDecks",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ export class ObsidianPluginDataStore<TData extends object> implements PluginData
|
|||
constructor(private readonly plugin: Plugin) {}
|
||||
|
||||
async load(): Promise<TData | null> {
|
||||
const data = await this.plugin.loadData();
|
||||
return (data as TData | null) ?? null;
|
||||
const data: unknown = await this.plugin.loadData();
|
||||
return data !== null && typeof data === "object" ? data as TData : null;
|
||||
}
|
||||
|
||||
async save(data: TData): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
cachedRead: async () => "# Title\nBody",
|
||||
cachedRead: () => Promise.resolve("# Title\nBody"),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: () => ({
|
||||
|
|
@ -43,7 +43,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
cachedRead: async () => "# Title\nBody",
|
||||
cachedRead: () => Promise.resolve("# Title\nBody"),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: () => null,
|
||||
|
|
@ -97,10 +97,10 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
process: async (targetFile: TFile, updater: (data: string) => string) => {
|
||||
process: (targetFile: TFile, updater: (data: string) => string) => {
|
||||
processedFile = targetFile;
|
||||
writtenContent = updater("# Title\nBody");
|
||||
return writtenContent;
|
||||
return Promise.resolve(writtenContent);
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
|
@ -117,7 +117,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,20 +15,20 @@ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "we
|
|||
export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
async listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return this.app.vault
|
||||
listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return Promise.resolve(this.app.vault
|
||||
.getRoot()
|
||||
.children.filter((child): child is TFolder => child instanceof TFolder)
|
||||
.map((folder) => this.toFolderTreeNode(folder));
|
||||
.map((folder) => this.toFolderTreeNode(folder)));
|
||||
}
|
||||
|
||||
async listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
|
||||
return this.app.vault.getMarkdownFiles().map((file) => ({
|
||||
listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
|
||||
return Promise.resolve(this.app.vault.getMarkdownFiles().map((file) => ({
|
||||
path: file.path,
|
||||
basename: file.basename,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
async listMarkdownFiles() {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "./DataJ
|
|||
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
load(): Promise<PluginDataSnapshot | null> {
|
||||
return Promise.resolve(this.snapshot);
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
save(data: PluginDataSnapshot): Promise<void> {
|
||||
this.snapshot = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +63,34 @@ describe("DataJsonPluginConfigRepository", () => {
|
|||
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 () => {
|
||||
const store = new InMemoryPluginDataStore({
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
|
|||
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
load(): Promise<PluginDataSnapshot | null> {
|
||||
return Promise.resolve(this.snapshot);
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
save(data: PluginDataSnapshot): Promise<void> {
|
||||
this.snapshot = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
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> = {};
|
||||
|
||||
for (const rawCard of Object.values(rawCards)) {
|
||||
|
|
@ -75,7 +77,7 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
}
|
||||
|
||||
const files: Record<string, FileState> = {};
|
||||
for (const [filePath, rawFile] of Object.entries(pluginState.files ?? {})) {
|
||||
for (const [filePath, rawFile] of Object.entries(rawFiles)) {
|
||||
files[filePath] = {
|
||||
filePath,
|
||||
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,
|
||||
lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0,
|
||||
noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards),
|
||||
groupIds: collectMigratedFileGroupIds(rawFile, pluginState.groupBlocks ?? {}),
|
||||
groupIds: collectMigratedFileGroupIds(rawFile, rawGroupBlocks),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (!nextGroupBlock) {
|
||||
continue;
|
||||
|
|
@ -199,7 +201,7 @@ function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: s
|
|||
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") {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -307,6 +309,44 @@ function collectMigratedFileGroupIds(
|
|||
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 {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
import * as obsidian from "obsidian";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatList, resolvePluginLocale } from "./locale";
|
||||
|
||||
describe("locale", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
it("returns zh for zh-based language codes", () => {
|
||||
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", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
|
||||
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");
|
||||
it("falls back to en for every other language code", () => {
|
||||
for (const language of ["en", "en-GB", "ja"]) {
|
||||
expect(resolvePluginLocale(language)).toBe("en");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { getLanguage } from "obsidian";
|
|||
|
||||
export type PluginLocale = "en" | "zh";
|
||||
|
||||
export function resolvePluginLocale(): PluginLocale {
|
||||
return getLanguage() === "zh" ? "zh" : "en";
|
||||
export function resolvePluginLocale(languageCode = getLanguage()): PluginLocale {
|
||||
const normalizedLanguage = languageCode.toLowerCase();
|
||||
return normalizedLanguage === "zh" || normalizedLanguage.startsWith("zh-") ? "zh" : "en";
|
||||
}
|
||||
|
||||
export function formatList(locale: PluginLocale, items: string[]): string {
|
||||
return items.join(locale === "zh" ? "、" : ", ");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,34 +10,34 @@ export const en = {
|
|||
cleanupEmptyDecks: "Clean up empty decks",
|
||||
},
|
||||
settings: {
|
||||
pluginTitle: "Anki Heading Sync",
|
||||
pluginTitle: "Anki heading sync",
|
||||
ankiConnectUrl: {
|
||||
name: "AnkiConnect URL",
|
||||
desc: "Default is http://127.0.0.1:8765",
|
||||
placeholder: "http://127.0.0.1:8765",
|
||||
desc: "Default: http://127.0.0.1:8765",
|
||||
placeholder: "Example: http://127.0.0.1:8765",
|
||||
},
|
||||
qaHeadingLevel: {
|
||||
name: "QA heading level",
|
||||
desc: "Default is H4",
|
||||
name: "Heading level for question-and-answer cards",
|
||||
desc: "Default question-and-answer heading level is 4.",
|
||||
},
|
||||
clozeHeadingLevel: {
|
||||
name: "Cloze heading level",
|
||||
desc: "Default is H5",
|
||||
desc: "Default cloze heading level is 5.",
|
||||
},
|
||||
cardAnswerCutoffMode: {
|
||||
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.",
|
||||
options: {
|
||||
headingBlock: "heading-block: whole heading block",
|
||||
doubleBlankLines: "double-blank-lines: stop before the first 2+ blank lines",
|
||||
headingBlock: "Heading-block: whole heading block",
|
||||
doubleBlankLines: "Double-blank-lines: stop before the first 2+ blank lines",
|
||||
},
|
||||
},
|
||||
qaGroup: {
|
||||
title: "QA Group list notes",
|
||||
title: "List notes for question-and-answer groups",
|
||||
marker: {
|
||||
name: "QA Group marker",
|
||||
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one QA Group list note.",
|
||||
placeholder: "#anki-list",
|
||||
name: "Marker for question-and-answer groups",
|
||||
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: "Hashtag marker",
|
||||
},
|
||||
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.",
|
||||
|
|
@ -49,7 +49,7 @@ export const en = {
|
|||
},
|
||||
obsidianBacklinkLabel: {
|
||||
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",
|
||||
},
|
||||
obsidianBacklinkPlacement: {
|
||||
|
|
@ -62,7 +62,7 @@ export const en = {
|
|||
},
|
||||
},
|
||||
highlightsToCloze: {
|
||||
name: "Highlights to Cloze",
|
||||
name: "Highlights to cloze",
|
||||
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
|
||||
},
|
||||
syncObsidianTagsToAnki: {
|
||||
|
|
@ -71,7 +71,7 @@ export const en = {
|
|||
},
|
||||
keepPureTagLinesInCardBody: {
|
||||
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: {
|
||||
|
|
@ -97,8 +97,8 @@ export const en = {
|
|||
},
|
||||
section: {
|
||||
basic: {
|
||||
title: "QA / Basic",
|
||||
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
|
||||
title: "Basic and question-and-answer",
|
||||
description: "Choose the note type for question-and-answer cards, read its fields from Anki, then confirm the title and body mapping.",
|
||||
},
|
||||
cloze: {
|
||||
title: "Cloze",
|
||||
|
|
@ -123,7 +123,7 @@ export const en = {
|
|||
name: "Cloze main field",
|
||||
desc: "The selected field receives title + <br><hr> + body during sync.",
|
||||
},
|
||||
selectFieldPlaceholder: "-- Select field --",
|
||||
selectFieldPlaceholder: "-- select field --",
|
||||
},
|
||||
deck: {
|
||||
defaultSectionTitle: "Default deck",
|
||||
|
|
@ -138,7 +138,7 @@ export const en = {
|
|||
},
|
||||
marker: {
|
||||
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: {
|
||||
name: "Default deck template",
|
||||
|
|
@ -196,6 +196,22 @@ export const en = {
|
|||
failedLoad: "Failed to load folders from the current vault.",
|
||||
expandFolder: "Expand {{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: {
|
||||
cardTypes: {
|
||||
|
|
@ -203,7 +219,7 @@ export const en = {
|
|||
desc: "Edit recognition rules directly. Changes save automatically.",
|
||||
advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.",
|
||||
markerPlaceholder: "/",
|
||||
fieldsUnavailable: "-- Read fields first --",
|
||||
fieldsUnavailable: "-- read fields first --",
|
||||
autoManagedField: "Written automatically",
|
||||
autoComposedAnswer: "Auto composed",
|
||||
qaGroupManagedNoteType: "{{modelName}} (for QA Group list notes)",
|
||||
|
|
@ -241,7 +257,7 @@ export const en = {
|
|||
qaGroupFirstQuestionField: "First question field:",
|
||||
qaGroupFirstAnswerField: "First answer field:",
|
||||
qaGroupConfiguredSlots: "Configured pairs:",
|
||||
qaGroupSlotFields: "Q/A fields:",
|
||||
qaGroupSlotFields: "Question/answer fields:",
|
||||
qaGroupWarnings: "Warnings:",
|
||||
},
|
||||
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.",
|
||||
rows: {
|
||||
basic: "Q&A (regular paragraph)",
|
||||
qaGroup: "Q&A (nested list)",
|
||||
basic: "Question and answer (regular paragraph)",
|
||||
qaGroup: "Question and answer (nested list)",
|
||||
cloze: "Cloze (sequential deletions)",
|
||||
clozeAll: "Cloze (all deletions)",
|
||||
},
|
||||
|
|
@ -353,15 +369,15 @@ export const en = {
|
|||
},
|
||||
settings: {
|
||||
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.",
|
||||
cardTypeEnabledBoolean: "Each card type enabled flag must be a boolean.",
|
||||
cardTypeExtraMarkerString: "Each card type extra marker must be a string.",
|
||||
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.",
|
||||
qaNoteTypeRequired: "QA note type is required.",
|
||||
qaGroupMarkerRequired: "QA Group marker is required.",
|
||||
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.",
|
||||
qaNoteTypeRequired: "A question-and-answer note type is required.",
|
||||
qaGroupMarkerRequired: "A question-and-answer group marker is required.",
|
||||
qaGroupMarkerInvalid: "The question-and-answer group marker must be a hashtag-style token.",
|
||||
clozeNoteTypeRequired: "Cloze note type is required.",
|
||||
defaultDeckRequired: "Default deck is required.",
|
||||
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.",
|
||||
fileDeckMarkerString: "File deck marker 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.",
|
||||
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.",
|
||||
|
|
@ -377,6 +393,10 @@ export const en = {
|
|||
includeFoldersStrings: "Include folders must only contain strings.",
|
||||
excludeFoldersArray: "Exclude folders must be an array.",
|
||||
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.",
|
||||
ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
|
||||
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.",
|
||||
noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.",
|
||||
noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.",
|
||||
noteFieldMappingsQaGroupSlotsArray: "QA Group field mapping slots must be an array.",
|
||||
noteFieldMappingsQaGroupSlotObject: "Each QA Group field mapping slot must be an object.",
|
||||
noteFieldMappingsQaGroupSlotIndex: "Each QA Group field mapping slot.index must be an integer greater than 0.",
|
||||
noteFieldMappingsQaGroupSlotQuestionField: "Each QA Group field mapping slot.questionField must be a string.",
|
||||
noteFieldMappingsQaGroupSlotAnswerField: "Each QA Group field mapping slot.answerField must be a string.",
|
||||
noteFieldMappingsQaGroupWarningsArray: "QA Group field mapping warnings must be an array.",
|
||||
noteFieldMappingsQaGroupWarningsStrings: "QA Group field mapping warnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group field mapping acceptedWarnings must be an array.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group field mapping acceptedWarnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupDerivationObject: "QA Group field mapping derivation must be an object.",
|
||||
noteFieldMappingsQaGroupDerivationMode: "QA Group field mapping derivation.mode must be first-pair.",
|
||||
noteFieldMappingsQaGroupDerivationFirstQuestionField: "QA Group field mapping derivation.firstQuestionField must be a string.",
|
||||
noteFieldMappingsQaGroupDerivationFirstAnswerField: "QA Group field mapping derivation.firstAnswerField must be a string.",
|
||||
noteFieldMappingsQaGroupSlotsArray: "Question-and-answer group field mapping slots must be an array.",
|
||||
noteFieldMappingsQaGroupSlotObject: "Each question-and-answer group field mapping slot must be an object.",
|
||||
noteFieldMappingsQaGroupSlotIndex: "Each question-and-answer group field mapping slot.index must be an integer greater than 0.",
|
||||
noteFieldMappingsQaGroupSlotQuestionField: "Each question-and-answer group field mapping slot.questionField must be a string.",
|
||||
noteFieldMappingsQaGroupSlotAnswerField: "Each question-and-answer group field mapping slot.answerField must be a string.",
|
||||
noteFieldMappingsQaGroupWarningsArray: "Question-and-answer group field mapping warnings must be an array.",
|
||||
noteFieldMappingsQaGroupWarningsStrings: "Question-and-answer group field mapping warnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsArray: "Question-and-answer group field mapping acceptedWarnings must be an array.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupDerivationObject: "Question-and-answer group field mapping derivation must be an object.",
|
||||
noteFieldMappingsQaGroupDerivationMode: "Question-and-answer group field mapping derivation mode must be first-pair.",
|
||||
noteFieldMappingsQaGroupDerivationFirstQuestionField: "Question-and-answer group field mapping derivation.firstQuestionField must be a string.",
|
||||
noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.",
|
||||
},
|
||||
noteFieldMapping: {
|
||||
noteTypeNotSelected: {
|
||||
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.",
|
||||
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: {
|
||||
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,22 @@ export const zh = {
|
|||
failedLoad: "读取当前 vault 文件夹失败。",
|
||||
expandFolder: "展开 {{name}}",
|
||||
collapseFolder: "收起 {{name}}",
|
||||
folderDeckModeOverride: {
|
||||
ariaLabel: "切换 {{name}} 的文件夹牌组映射模式",
|
||||
hint: {
|
||||
folder: "本文件夹单独采用「文件夹」作为牌组名",
|
||||
folderAndFile: "本文件夹单独采用「文件夹及文件名」作为牌组名",
|
||||
},
|
||||
title: {
|
||||
folder: "当前全局为「文件夹及文件名」,勾选后此文件夹改用「文件夹」作为牌组名",
|
||||
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
|
||||
},
|
||||
},
|
||||
standaloneParentDeck: {
|
||||
ariaLabel: "将 {{name}} 指定为单独的父牌组",
|
||||
hint: "本文件夹指定为「单独的父牌组」",
|
||||
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
cardTypes: {
|
||||
|
|
@ -375,6 +391,10 @@ export const zh = {
|
|||
includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
|
||||
excludeFoldersArray: "排除文件夹必须是数组。",
|
||||
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
|
||||
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
|
||||
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
|
||||
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
|
||||
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
|
||||
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
|
||||
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
FakeButtonComponent,
|
||||
|
|
@ -21,6 +21,12 @@ const {
|
|||
return child;
|
||||
}
|
||||
|
||||
createSpan(options?: { text?: string }): HoistedFakeElement {
|
||||
const child = new HoistedFakeElement("span", options?.text ?? "");
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addClass(className: string): void {
|
||||
this.classes.push(className);
|
||||
}
|
||||
|
|
@ -167,6 +173,14 @@ type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
|
|||
type FakeContainerElInstance = InstanceType<typeof FakeModal>["contentEl"];
|
||||
type FakeToggleComponentInstance = InstanceType<typeof FakeToggleComponent>;
|
||||
|
||||
function setObsidianLanguage(language: string): void {
|
||||
getLanguageMock.mockReturnValue(language);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function getFakeContentEl(modal: EmptyDeckSelectionModal): FakeContainerElInstance {
|
||||
return modal.contentEl as unknown as FakeContainerElInstance;
|
||||
}
|
||||
|
|
@ -188,8 +202,8 @@ function getFooterCountText(contentEl: FakeContainerElInstance): string | undefi
|
|||
}
|
||||
|
||||
describe("EmptyDeckSelectionModal", () => {
|
||||
it("renders the modal text in English when Obsidian language is not zh", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
it("renders the modal text in English when Obsidian language is not zh", () => {
|
||||
setObsidianLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -208,8 +222,8 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
expect(getFooterCountText(contentEl)).toBe("Selected 0 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("starts with zero selected count and a disabled delete button", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
it("starts with zero selected count and a disabled delete button", () => {
|
||||
setObsidianLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -221,7 +235,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
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 selectionPromise = modal.openAndGetSelection();
|
||||
|
|
@ -244,7 +258,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
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"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -262,7 +276,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
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"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -281,7 +295,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
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"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -299,7 +313,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("returns null when cancel is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
setObsidianLanguage("zh");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
|
||||
|
||||
const selectionPromise = modal.openAndGetSelection();
|
||||
|
|
@ -311,7 +325,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
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"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class EmptyDeckSelectionModal extends Modal {
|
|||
});
|
||||
});
|
||||
|
||||
this.selectionCountEl = footerSetting.settingEl.createEl("span", {
|
||||
this.selectionCountEl = footerSetting.settingEl.createSpan({
|
||||
text: this.getSelectionCountText(),
|
||||
});
|
||||
this.selectionCountEl.addClass("anki-helper-empty-deck-selection-count");
|
||||
|
|
|
|||
|
|
@ -129,4 +129,4 @@ describe("NoticeService", () => {
|
|||
"Empty-deck cleanup completed: candidates 4, selected 3, deleted 2, skipped 2. Skipped decks: Deck C, Deck D.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const {
|
|||
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);
|
||||
if (options?.text) {
|
||||
child.text = options.text;
|
||||
|
|
@ -98,8 +98,16 @@ const {
|
|||
return child;
|
||||
}
|
||||
|
||||
createEl(tag: string, options?: { text?: string }): HoistedFakeElement {
|
||||
return this.createChild(tag, options);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -286,27 +294,57 @@ const {
|
|||
class HoistedFakeSetting {
|
||||
public name = "";
|
||||
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<
|
||||
HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent
|
||||
> = [];
|
||||
|
||||
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.ownedSettings.push(this);
|
||||
}
|
||||
|
||||
setName(name: string): this {
|
||||
this.name = name;
|
||||
this.nameEl.text = name;
|
||||
this.nameEl.textContent = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
setDesc(desc: unknown): this {
|
||||
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;
|
||||
}
|
||||
|
||||
addButton(callback: (button: HoistedFakeButtonComponent) => void): this {
|
||||
const button = new HoistedFakeButtonComponent();
|
||||
const button = new HoistedFakeButtonComponent(this.controlEl);
|
||||
this.controls.push(button);
|
||||
callback(button);
|
||||
return this;
|
||||
|
|
@ -405,7 +443,11 @@ type FakeElementInstance = InstanceType<typeof FakeElement>;
|
|||
type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
|
||||
|
||||
class FakePlugin {
|
||||
public readonly app = {};
|
||||
public readonly app = {
|
||||
workspace: {
|
||||
activeWindow: typeof window === "undefined" ? undefined : window,
|
||||
},
|
||||
};
|
||||
public readonly updateCalls: Array<Record<string, unknown>> = [];
|
||||
public readonly fieldNamesByModelName: Record<string, string[]> = {};
|
||||
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.settings = normalizePluginSettings({
|
||||
...this.settings,
|
||||
|
|
@ -459,40 +501,45 @@ class FakePlugin {
|
|||
cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs,
|
||||
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;
|
||||
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;
|
||||
|
||||
return modelNames.reduce<Record<string, string[]>>((fieldNamesByModelName, modelName) => {
|
||||
fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
|
||||
return fieldNamesByModelName;
|
||||
const fieldNamesByModelName = modelNames.reduce<Record<string, string[]>>((resolvedFieldNamesByModelName, modelName) => {
|
||||
resolvedFieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
|
||||
return resolvedFieldNamesByModelName;
|
||||
}, {});
|
||||
|
||||
return Promise.resolve(fieldNamesByModelName);
|
||||
}
|
||||
|
||||
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
|
||||
getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
|
||||
this.getNoteModelDetailsCalls += 1;
|
||||
return {
|
||||
return Promise.resolve({
|
||||
fieldNames: this.getFieldNamesForModel(modelName),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listFolderTree(): Promise<typeof this.folderTree> {
|
||||
listFolderTree(): Promise<typeof this.folderTree> {
|
||||
this.listFolderTreeCalls += 1;
|
||||
return this.folderTree;
|
||||
return Promise.resolve(this.folderTree);
|
||||
}
|
||||
|
||||
async insertDeckTemplateToCurrentFile(): Promise<void> {
|
||||
insertDeckTemplateToCurrentFile(): Promise<void> {
|
||||
this.insertDeckTemplateCalls += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private getFieldNamesForModel(modelName: string): string[] {
|
||||
|
|
@ -641,31 +688,118 @@ async function flushPromises(): 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;
|
||||
}
|
||||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", cardId).trigger("click");
|
||||
}
|
||||
|
||||
describe("PluginSettingTab", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
function isCardExpanded(tab: AnkiHeadingSyncSettingTab, cardId: string): boolean {
|
||||
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(() => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
vi.useRealTimers();
|
||||
resetResizeObserverInstances();
|
||||
Reflect.set(globalThis, "ResizeObserver", FakeResizeObserver);
|
||||
vi.stubGlobal("navigator", { language: "zh" });
|
||||
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
|
||||
vi.stubGlobal("window", createTestWindow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (typeof originalResizeObserver === "undefined") {
|
||||
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
||||
return;
|
||||
}
|
||||
|
||||
Reflect.set(globalThis, "ResizeObserver", originalResizeObserver);
|
||||
vi.unstubAllGlobals();
|
||||
testTimerController = undefined;
|
||||
});
|
||||
|
||||
it("renders five cards collapsed by default", () => {
|
||||
|
|
@ -678,11 +812,11 @@ describe("PluginSettingTab", () => {
|
|||
expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined();
|
||||
const cards = queryAllByDataset(tab.containerEl, "settingsCard");
|
||||
expect(cards).toHaveLength(5);
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "sync-content")).toBe(false);
|
||||
expect(isCardExpanded(tab, "card-types")).toBe(false);
|
||||
expect(isCardExpanded(tab, "commands")).toBe(false);
|
||||
expect(isCardExpanded(tab, "scope")).toBe(false);
|
||||
expect(isCardExpanded(tab, "deck")).toBe(false);
|
||||
|
||||
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");
|
||||
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "scope")).toBe(true);
|
||||
expect(isCardExpanded(tab, "card-types")).toBe(false);
|
||||
expect(isCardExpanded(tab, "sync-content")).toBe(false);
|
||||
expect(isCardExpanded(tab, "deck")).toBe(false);
|
||||
expect(isCardExpanded(tab, "commands")).toBe(false);
|
||||
|
||||
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", () => {
|
||||
|
|
@ -718,39 +852,17 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true");
|
||||
const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true");
|
||||
expect(pageHeader.style.position).toBe("sticky");
|
||||
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(pageHeader.classList.contains("ahs-settings-page-header")).toBe(true);
|
||||
expect(collectTexts(pageHeader)).toContain("Anki Heading Sync");
|
||||
|
||||
expect(pageHeaderMask.style.position).toBe("absolute");
|
||||
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(pageHeaderMask.classList.contains("ahs-settings-page-header-mask")).toBe(true);
|
||||
|
||||
expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px");
|
||||
expect(getStyleProperty(tab.containerEl, "--ahs-settings-sticky-card-gap")).toBe("8px");
|
||||
|
||||
for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) {
|
||||
const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId);
|
||||
expect(header.style.position).toBe("sticky");
|
||||
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");
|
||||
expect(header.classList.contains("ahs-settings-card-toggle")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -819,7 +931,9 @@ describe("PluginSettingTab", () => {
|
|||
expect(queryByDataset(tab.containerEl, "settingsCardToggle", "sync-content").textContent).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([
|
||||
"1. 确定「卡片正文」范围",
|
||||
"2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian",
|
||||
|
|
@ -827,7 +941,17 @@ describe("PluginSettingTab", () => {
|
|||
"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 回链显示名称",
|
||||
|
|
@ -983,7 +1107,6 @@ describe("PluginSettingTab", () => {
|
|||
});
|
||||
|
||||
it("debounces text input saves instead of saving every keystroke", async () => {
|
||||
vi.useFakeTimers();
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -998,18 +1121,17 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
expect(plugin.updateCalls).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(499);
|
||||
advanceTestTimersByTime(499);
|
||||
await flushPromises();
|
||||
expect(plugin.updateCalls).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
advanceTestTimersByTime(1);
|
||||
await flushPromises();
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab");
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flushes pending text saves when the settings tab hides", async () => {
|
||||
vi.useFakeTimers();
|
||||
const plugin = new FakePlugin();
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -1028,7 +1150,7 @@ describe("PluginSettingTab", () => {
|
|||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-hidden");
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
advanceTestTimersByTime(500);
|
||||
await flushPromises();
|
||||
|
||||
expect(plugin.updateCalls).toHaveLength(1);
|
||||
|
|
@ -1045,13 +1167,11 @@ describe("PluginSettingTab", () => {
|
|||
clozeHeading.value = "4";
|
||||
await clozeHeading.trigger("change");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const clozeMarker = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze");
|
||||
clozeMarker.value = "";
|
||||
await clozeMarker.trigger("input");
|
||||
vi.advanceTimersByTime(500);
|
||||
advanceTestTimersByTime(500);
|
||||
await flushPromises();
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.headingLevel).toBe(4);
|
||||
expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#anki-cloze");
|
||||
|
|
@ -1066,11 +1186,11 @@ describe("PluginSettingTab", () => {
|
|||
const initialEmptyCount = getEmptyCallCount(tab.containerEl);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -1108,22 +1228,18 @@ describe("PluginSettingTab", () => {
|
|||
"Custom Cloze",
|
||||
QA_GROUP_USER_MODEL_NAME,
|
||||
].sort((left, right) => left.localeCompare(right)));
|
||||
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
|
||||
expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({
|
||||
Basic: {
|
||||
fieldNames: ["Front", "Back", "Extra"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
"Custom Basic": {
|
||||
fieldNames: ["Title", "Body", "Hint"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
Cloze: {
|
||||
fieldNames: ["Text", "Extra", "Hint"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
}));
|
||||
expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true);
|
||||
expect(plugin.updateCalls.some((call) => Array.isArray(call["ankiNoteTypeCache"]))).toBe(true);
|
||||
const { ankiModelFieldCache } = plugin.settings;
|
||||
expect(ankiModelFieldCache.Basic?.fieldNames).toEqual(["Front", "Back", "Extra"]);
|
||||
expect(typeof ankiModelFieldCache.Basic?.loadedAt).toBe("number");
|
||||
expect(ankiModelFieldCache["Custom Basic"]?.fieldNames).toEqual(["Title", "Body", "Hint"]);
|
||||
expect(typeof ankiModelFieldCache["Custom Basic"]?.loadedAt).toBe("number");
|
||||
expect(ankiModelFieldCache.Cloze?.fieldNames).toEqual(["Text", "Extra", "Hint"]);
|
||||
expect(typeof ankiModelFieldCache.Cloze?.loadedAt).toBe("number");
|
||||
expect(plugin.updateCalls.some((call) => {
|
||||
const ankiModelFieldCache = call["ankiModelFieldCache"];
|
||||
return typeof ankiModelFieldCache === "object" && ankiModelFieldCache !== null;
|
||||
})).toBe(true);
|
||||
|
||||
const cachedPlugin = new FakePlugin();
|
||||
cachedPlugin.settings = normalizePluginSettings({
|
||||
|
|
@ -1564,17 +1680,13 @@ describe("PluginSettingTab", () => {
|
|||
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
|
||||
|
||||
expect(parentToggle.textContent).toBe("▾");
|
||||
expect(parentRow.style.display).toBe("grid");
|
||||
expect(parentRow.style.gridTemplateColumns).toBe("24px 24px minmax(0, 1fr)");
|
||||
expect(parentRow.style.alignItems).toBe("center");
|
||||
expect(childRow.style.marginLeft).toBe("18px");
|
||||
expect(childToggle.style.width).toBe("24px");
|
||||
expect(childToggle.style.height).toBe("24px");
|
||||
expect(childToggle.style.fontSize).toBe("2em");
|
||||
expect(parentRow.classList.contains("ahs-settings-folder-row")).toBe(true);
|
||||
expect(getStyleProperty(childRow, "--ahs-folder-depth-indent")).toBe("18px");
|
||||
expect(childToggle.classList.contains("ahs-settings-folder-toggle")).toBe(true);
|
||||
expect(childToggle.classList.contains("ahs-settings-folder-toggle-spacer")).toBe(true);
|
||||
expect(childCheckbox).toBeDefined();
|
||||
expect(childLabel.style.whiteSpace).toBe("nowrap");
|
||||
expect(childLabel.style.overflow).toBe("hidden");
|
||||
expect(childLabel.style.textOverflow).toBe("ellipsis");
|
||||
expect(childLabel.classList.contains("ahs-settings-folder-label")).toBe(true);
|
||||
expect(childLabel.classList.contains("ahs-settings-fluid-ellipsis")).toBe(true);
|
||||
});
|
||||
|
||||
it("folder tree expand and check refresh only the scope card", async () => {
|
||||
|
|
@ -1606,6 +1718,202 @@ describe("PluginSettingTab", () => {
|
|||
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 () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
|
|
@ -1648,7 +1956,9 @@ describe("PluginSettingTab", () => {
|
|||
await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click");
|
||||
|
||||
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([
|
||||
"1,推荐用「文件夹及文件名」作为「Anki牌组」",
|
||||
"2,可打开「文件级自定义牌组」,作为个性化定制",
|
||||
|
|
@ -1667,18 +1977,13 @@ describe("PluginSettingTab", () => {
|
|||
const exampleRows = queryAllByDataset(exampleBlock, "deckExampleRow");
|
||||
expect(collectTexts(exampleBlock)).toContain("示例:");
|
||||
expect(exampleRows).toHaveLength(2);
|
||||
expect(exampleBlock.style.marginLeft).toBe("32px");
|
||||
expect(exampleRows[0]?.style.color).toBe("var(--text-muted)");
|
||||
expect(exampleRows[0]?.style.fontSize).toBe("var(--font-ui-small)");
|
||||
expect(exampleBlock.classList.contains("ahs-settings-deck-example-block")).toBe(true);
|
||||
expect(exampleRows[0]?.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
|
||||
const fallbackHelper = queryByDataset(section3, "deckHelperText", "fallback");
|
||||
const priorityFooter = queryByDataset(deckBody, "deckPriorityFooter", "true");
|
||||
expect(fallbackHelper.style.marginLeft).toBe("32px");
|
||||
expect(fallbackHelper.style.color).toBe("var(--text-muted)");
|
||||
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(fallbackHelper.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
expect(priorityFooter.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
expect(collectTexts(section3)).not.toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。");
|
||||
|
||||
expect(collectOwnedSettingNames(section1)).toEqual(["文件夹映射模式"]);
|
||||
|
|
|
|||
|
|
@ -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 SETTINGS_STICKY_CARD_GAP_PX = 8;
|
||||
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_STICKY_CARD_GAP_VARIABLE = "--ahs-settings-sticky-card-gap";
|
||||
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_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_CARD_BODY_HIDDEN_CLASS = "ahs-is-hidden";
|
||||
|
||||
type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number];
|
||||
type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed";
|
||||
|
|
@ -62,10 +56,23 @@ interface SettingsCardShell {
|
|||
}
|
||||
|
||||
interface PendingTextSave {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
timer: ReturnType<Window["setTimeout"]>;
|
||||
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 {
|
||||
private readonly noteFieldMappingService = new NoteFieldMappingService();
|
||||
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService();
|
||||
|
|
@ -126,35 +133,15 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const pageHeaderEl = containerEl.createDiv();
|
||||
pageHeaderEl.dataset.settingsPageHeader = "true";
|
||||
pageHeaderEl.style.position = "sticky";
|
||||
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";
|
||||
addClasses(pageHeaderEl, "ahs-settings-page-header");
|
||||
|
||||
const pageHeaderMaskEl = pageHeaderEl.createDiv();
|
||||
pageHeaderMaskEl.dataset.settingsPageHeaderMask = "true";
|
||||
pageHeaderMaskEl.style.position = "absolute";
|
||||
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;
|
||||
addClasses(pageHeaderMaskEl, "ahs-settings-page-header-mask");
|
||||
|
||||
const titleEl = pageHeaderEl.createEl("h2", { text: t("settings.pluginTitle") });
|
||||
titleEl.style.margin = "0";
|
||||
titleEl.style.position = "relative";
|
||||
titleEl.style.zIndex = "1";
|
||||
const titleSetting = new Setting(pageHeaderEl).setName(t("settings.pluginTitle")).setHeading();
|
||||
addClasses(titleSetting.settingEl, "ahs-settings-page-title-setting");
|
||||
addClasses(titleSetting.nameEl, "ahs-settings-page-title");
|
||||
|
||||
this.observePageHeaderHeight(pageHeaderEl);
|
||||
|
||||
|
|
@ -182,34 +169,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const cardEl = containerEl.createDiv();
|
||||
cardEl.dataset.settingsCard = cardId;
|
||||
|
||||
const headerEl = cardEl.createEl("button") as HTMLButtonElement;
|
||||
const headerEl = cardEl.createEl("button");
|
||||
headerEl.type = "button";
|
||||
headerEl.dataset.settingsCardToggle = cardId;
|
||||
headerEl.style.display = "flex";
|
||||
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";
|
||||
addClasses(headerEl, "ahs-settings-card-toggle");
|
||||
headerEl.addEventListener("click", () => {
|
||||
this.toggleCard(cardId);
|
||||
});
|
||||
|
||||
const bodyEl = cardEl.createDiv();
|
||||
bodyEl.dataset.settingsCardBody = cardId;
|
||||
addClasses(bodyEl, "ahs-settings-card-body", SETTINGS_CARD_BODY_HIDDEN_CLASS);
|
||||
|
||||
this.cardShells.set(cardId, {
|
||||
cardEl,
|
||||
|
|
@ -238,7 +208,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const expanded = this.expandedCardIds.has(cardId);
|
||||
shell.headerEl.textContent = `${expanded ? "▾" : "▸"} ${this.getCardTitle(cardId)}`;
|
||||
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();
|
||||
|
||||
if (!expanded) {
|
||||
|
|
@ -301,10 +271,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.maybeStartNoteTypeCacheCheck();
|
||||
|
||||
const actionRow = containerEl.createDiv();
|
||||
actionRow.style.display = "flex";
|
||||
actionRow.style.flexDirection = "column";
|
||||
actionRow.style.alignItems = "flex-start";
|
||||
actionRow.style.gap = "8px";
|
||||
addClasses(actionRow, "ahs-settings-action-row");
|
||||
const loadButton = new ButtonComponent(actionRow)
|
||||
.setButtonText(this.ankiConfigLoading
|
||||
? t("settings.cards.cardTypes.loadAnki.loading")
|
||||
|
|
@ -313,15 +280,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
.setDisabled(this.ankiConfigLoading)
|
||||
.onClick(() => this.loadAnkiCardTypeConfig());
|
||||
loadButton.buttonEl.dataset.cardTypesRefresh = "true";
|
||||
actionRow.createEl("span", {
|
||||
actionRow.createSpan({
|
||||
text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.getCardTypeStatusMessage())}`,
|
||||
});
|
||||
|
||||
const cardTypeList = containerEl.createDiv();
|
||||
cardTypeList.dataset.cardTypeList = "true";
|
||||
cardTypeList.style.display = "flex";
|
||||
cardTypeList.style.flexDirection = "column";
|
||||
cardTypeList.style.gap = "14px";
|
||||
addClasses(cardTypeList, "ahs-settings-card-list");
|
||||
|
||||
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
|
||||
this.renderCardTypeBlock(cardTypeList, configId);
|
||||
|
|
@ -332,49 +297,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const config = this.plugin.settings.cardTypeConfigs[configId];
|
||||
const blockEl = containerEl.createDiv();
|
||||
blockEl.dataset.cardTypeConfig = configId;
|
||||
blockEl.style.display = "flex";
|
||||
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";
|
||||
addClasses(blockEl, "ahs-settings-card-type-block");
|
||||
|
||||
const headerRow = blockEl.createDiv();
|
||||
headerRow.style.display = "flex";
|
||||
headerRow.style.alignItems = "center";
|
||||
headerRow.style.gap = "10px";
|
||||
addClasses(headerRow, "ahs-settings-card-type-header");
|
||||
|
||||
const enabledCheckbox = headerRow.createEl("input") as HTMLInputElement;
|
||||
const enabledCheckbox = headerRow.createEl("input");
|
||||
enabledCheckbox.type = "checkbox";
|
||||
enabledCheckbox.checked = config.enabled;
|
||||
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) });
|
||||
titleEl.style.fontSize = "var(--font-ui-medium)";
|
||||
addClasses(titleEl, "ahs-settings-card-type-title");
|
||||
|
||||
const recognitionRow = blockEl.createDiv();
|
||||
recognitionRow.style.display = "flex";
|
||||
recognitionRow.style.flexWrap = "wrap";
|
||||
recognitionRow.style.alignItems = "center";
|
||||
recognitionRow.style.gap = "10px 14px";
|
||||
addClasses(recognitionRow, "ahs-settings-recognition-row");
|
||||
|
||||
const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel"));
|
||||
const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`);
|
||||
headingSelect.dataset.cardTypeHeading = configId;
|
||||
headingSelect.style.width = "88px";
|
||||
addClasses(headingSelect, "ahs-settings-heading-select");
|
||||
for (let level = 1; level <= 6; level += 1) {
|
||||
this.appendOption(headingSelect, String(level), `H${level}`);
|
||||
}
|
||||
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 markerInput = markerGroup.createEl("input") as HTMLInputElement;
|
||||
const markerInput = markerGroup.createEl("input");
|
||||
markerInput.type = "text";
|
||||
markerInput.dataset.cardTypeMarker = configId;
|
||||
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.addEventListener("input", () => {
|
||||
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"),
|
||||
});
|
||||
sharedHintEl.dataset.cardTypeSharedHint = configId;
|
||||
sharedHintEl.style.margin = "0";
|
||||
sharedHintEl.style.color = "var(--text-muted)";
|
||||
addClasses(sharedHintEl, "ahs-settings-muted-text");
|
||||
return;
|
||||
}
|
||||
|
||||
const ankiRow = blockEl.createDiv();
|
||||
ankiRow.style.display = "grid";
|
||||
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%";
|
||||
addClasses(ankiRow, "ahs-settings-grid-row");
|
||||
|
||||
const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType"));
|
||||
const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`);
|
||||
|
|
@ -413,7 +365,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
noteTypeSelect.value = config.noteType;
|
||||
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);
|
||||
}
|
||||
|
|
@ -432,7 +386,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
mainFieldSelect.dataset.cardTypeQuestionField = configId;
|
||||
this.applyFluidEllipsis(mainFieldSelect);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -442,14 +398,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
titleFieldSelect.dataset.cardTypeQuestionField = configId;
|
||||
this.applyFluidEllipsis(titleFieldSelect);
|
||||
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 bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`);
|
||||
bodyFieldSelect.dataset.cardTypeAnswerField = configId;
|
||||
this.applyFluidEllipsis(bodyFieldSelect);
|
||||
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 {
|
||||
|
|
@ -465,13 +425,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.populateFieldSelect(titleFieldSelect, qaGroupState.mapping.loadedFieldNames, qaGroupState.mapping.titleField);
|
||||
const qaGroupPairRow = containerEl.createDiv();
|
||||
qaGroupPairRow.dataset.qaGroupPairRow = configId;
|
||||
qaGroupPairRow.style.gridColumn = "1 / -1";
|
||||
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%";
|
||||
addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
|
||||
|
||||
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
|
||||
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.populateFieldSelect(firstAnswerFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstAnswerField);
|
||||
|
||||
const saveSelection = () => this.saveQaGroupFieldSelection(configId, {
|
||||
titleField: titleFieldSelect.value || undefined,
|
||||
firstQuestionField: firstQuestionFieldSelect.value || undefined,
|
||||
firstAnswerField: firstAnswerFieldSelect.value || undefined,
|
||||
});
|
||||
const saveSelection = () => {
|
||||
void this.saveQaGroupFieldSelection(configId, {
|
||||
titleField: titleFieldSelect.value || undefined,
|
||||
firstQuestionField: firstQuestionFieldSelect.value || undefined,
|
||||
firstAnswerField: firstAnswerFieldSelect.value || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
titleFieldSelect.addEventListener("change", saveSelection);
|
||||
firstQuestionFieldSelect.addEventListener("change", saveSelection);
|
||||
firstAnswerFieldSelect.addEventListener("change", saveSelection);
|
||||
|
||||
const slotSummaryEl = qaGroupPairRow.createEl("span", {
|
||||
const slotSummaryEl = qaGroupPairRow.createSpan({
|
||||
text: t("settings.cards.cardTypes.qaGroup.configuredSlots", {
|
||||
count: qaGroupState.mapping.slots.length,
|
||||
}),
|
||||
});
|
||||
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
|
||||
slotSummaryEl.style.whiteSpace = "nowrap";
|
||||
slotSummaryEl.style.justifySelf = "start";
|
||||
addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
|
||||
this.applyFluidEllipsis(slotSummaryEl);
|
||||
} else {
|
||||
titleFieldGroup.createEl("span", {
|
||||
titleFieldGroup.createSpan({
|
||||
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
|
||||
}).dataset.qaGroupTitleField = configId;
|
||||
|
||||
const qaGroupPairRow = containerEl.createDiv();
|
||||
qaGroupPairRow.dataset.qaGroupPairRow = configId;
|
||||
qaGroupPairRow.style.gridColumn = "1 / -1";
|
||||
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%";
|
||||
addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
|
||||
|
||||
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"),
|
||||
}).dataset.qaGroupFirstQuestionField = configId;
|
||||
|
||||
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"),
|
||||
}).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"),
|
||||
});
|
||||
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
|
||||
slotSummaryEl.style.whiteSpace = "nowrap";
|
||||
slotSummaryEl.style.justifySelf = "start";
|
||||
addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
|
||||
this.applyFluidEllipsis(slotSummaryEl);
|
||||
}
|
||||
|
||||
|
|
@ -543,7 +491,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
text: renderUserFacingMessage(qaGroupState.error),
|
||||
});
|
||||
errorEl.dataset.qaGroupError = configId;
|
||||
errorEl.style.gridColumn = "1 / -1";
|
||||
addClasses(errorEl, "ahs-settings-grid-full");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -553,28 +501,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const warningsContainer = containerEl.createDiv();
|
||||
warningsContainer.dataset.qaGroupWarnings = configId;
|
||||
warningsContainer.style.gridColumn = "1 / -1";
|
||||
warningsContainer.style.display = "flex";
|
||||
warningsContainer.style.flexDirection = "column";
|
||||
warningsContainer.style.gap = "6px";
|
||||
addClasses(warningsContainer, "ahs-settings-grid-full", "ahs-settings-warnings");
|
||||
warningsContainer.createEl("strong", { text: t("settings.cards.cardTypes.labels.qaGroupWarnings") });
|
||||
|
||||
for (const warning of qaGroupState.mapping.warnings) {
|
||||
warningsContainer.createEl("div", {
|
||||
warningsContainer.createDiv({
|
||||
text: this.renderQaGroupWarning(warning),
|
||||
}).dataset.qaGroupWarning = configId;
|
||||
}
|
||||
|
||||
const acceptLabel = warningsContainer.createEl("label");
|
||||
acceptLabel.style.display = "inline-flex";
|
||||
acceptLabel.style.alignItems = "center";
|
||||
acceptLabel.style.gap = "8px";
|
||||
const acceptCheckbox = acceptLabel.createEl("input") as HTMLInputElement;
|
||||
addClasses(acceptLabel, "ahs-settings-inline-check");
|
||||
const acceptCheckbox = acceptLabel.createEl("input");
|
||||
acceptCheckbox.type = "checkbox";
|
||||
acceptCheckbox.checked = areQaGroupWarningsAccepted(qaGroupState.mapping.warnings, qaGroupState.mapping.acceptedWarnings);
|
||||
acceptCheckbox.dataset.qaGroupWarningAccept = configId;
|
||||
acceptCheckbox.addEventListener("change", () => this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked));
|
||||
acceptLabel.createEl("span", { text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
|
||||
acceptCheckbox.addEventListener("change", () => {
|
||||
void this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked);
|
||||
});
|
||||
acceptLabel.createSpan({ text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
|
||||
}
|
||||
|
||||
private renderSyncContentCard(containerEl: HTMLElement): void {
|
||||
|
|
@ -663,14 +608,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private createSyncContentSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
|
||||
const sectionEl = containerEl.createDiv();
|
||||
sectionEl.dataset.syncContentSection = sectionId;
|
||||
sectionEl.style.display = "flex";
|
||||
sectionEl.style.flexDirection = "column";
|
||||
sectionEl.style.gap = "8px";
|
||||
sectionEl.style.marginTop = "16px";
|
||||
|
||||
const titleEl = sectionEl.createEl("h4", { text: title });
|
||||
titleEl.dataset.syncContentSectionTitle = sectionId;
|
||||
titleEl.style.margin = "0";
|
||||
addClasses(sectionEl, "ahs-settings-section");
|
||||
this.createSectionHeading(sectionEl, "syncContentSectionTitle", sectionId, title);
|
||||
|
||||
return sectionEl;
|
||||
}
|
||||
|
|
@ -697,8 +636,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
if (!isRunScopeConfigured(this.plugin.settings.scopeMode, this.plugin.settings.includeFolders)) {
|
||||
const warningEl = containerEl.createEl("p", { text: t("settings.scope.unconfiguredWarning") });
|
||||
warningEl.dataset.scopeWarning = "unconfigured";
|
||||
warningEl.style.color = "var(--text-warning)";
|
||||
warningEl.style.fontWeight = "600";
|
||||
addClasses(warningEl, "ahs-settings-warning-text");
|
||||
}
|
||||
|
||||
if (this.plugin.settings.scopeMode === "all") {
|
||||
|
|
@ -729,9 +667,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders);
|
||||
const treeContainer = containerEl.createDiv();
|
||||
treeContainer.dataset.scopeTree = "true";
|
||||
treeContainer.style.display = "flex";
|
||||
treeContainer.style.flexDirection = "column";
|
||||
treeContainer.style.gap = "2px";
|
||||
addClasses(treeContainer, "ahs-settings-folder-tree");
|
||||
|
||||
for (const node of selectionTree) {
|
||||
this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0);
|
||||
|
|
@ -858,14 +794,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private renderDeckExampleBlock(containerEl: HTMLElement): void {
|
||||
const exampleBlockEl = containerEl.createDiv();
|
||||
exampleBlockEl.dataset.deckExampleBlock = "true";
|
||||
exampleBlockEl.style.display = "flex";
|
||||
exampleBlockEl.style.flexDirection = "column";
|
||||
exampleBlockEl.style.gap = "4px";
|
||||
exampleBlockEl.style.marginTop = "2px";
|
||||
exampleBlockEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
|
||||
addClasses(exampleBlockEl, "ahs-settings-deck-example-block");
|
||||
|
||||
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 [
|
||||
t("settings.deck.folderExample"),
|
||||
|
|
@ -873,40 +805,28 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
]) {
|
||||
const rowEl = exampleBlockEl.createDiv({ text: exampleText });
|
||||
rowEl.dataset.deckExampleRow = "true";
|
||||
rowEl.style.color = "var(--text-muted)";
|
||||
rowEl.style.fontSize = "var(--font-ui-small)";
|
||||
rowEl.style.lineHeight = "1.4";
|
||||
addClasses(rowEl, "ahs-settings-helper-text");
|
||||
}
|
||||
}
|
||||
|
||||
private createDeckHelperText(containerEl: HTMLElement, text: string, helperId: string): HTMLElement {
|
||||
const helperEl = containerEl.createDiv({ text });
|
||||
helperEl.dataset.deckHelperText = helperId;
|
||||
helperEl.style.color = "var(--text-muted)";
|
||||
helperEl.style.fontSize = "var(--font-ui-small)";
|
||||
helperEl.style.lineHeight = "1.4";
|
||||
helperEl.style.marginTop = "2px";
|
||||
helperEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
|
||||
addClasses(helperEl, "ahs-settings-helper-text");
|
||||
return helperEl;
|
||||
}
|
||||
|
||||
private createDeckPriorityFooter(containerEl: HTMLElement): void {
|
||||
const footerEl = this.createDeckHelperText(containerEl, t("settings.deck.priorityDesc"), "priority");
|
||||
footerEl.dataset.deckPriorityFooter = "true";
|
||||
footerEl.style.marginTop = "12px";
|
||||
addClasses(footerEl, "ahs-settings-helper-text-priority");
|
||||
}
|
||||
|
||||
private createDeckSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
|
||||
const sectionEl = containerEl.createDiv();
|
||||
sectionEl.dataset.deckSection = sectionId;
|
||||
sectionEl.style.display = "flex";
|
||||
sectionEl.style.flexDirection = "column";
|
||||
sectionEl.style.gap = "8px";
|
||||
sectionEl.style.marginTop = "16px";
|
||||
|
||||
const titleEl = sectionEl.createEl("h4", { text: title });
|
||||
titleEl.dataset.deckSectionTitle = sectionId;
|
||||
titleEl.style.margin = "0";
|
||||
addClasses(sectionEl, "ahs-settings-section");
|
||||
this.createSectionHeading(sectionEl, "deckSectionTitle", sectionId, title);
|
||||
|
||||
return sectionEl;
|
||||
}
|
||||
|
|
@ -1688,7 +1608,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
private async refreshFolderTree(): Promise<void> {
|
||||
private refreshFolderTree(): void {
|
||||
this.expandedFolderPaths.clear();
|
||||
this.ensureFolderTreeLoaded(true);
|
||||
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 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");
|
||||
}
|
||||
|
||||
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
|
||||
const row = containerEl.createDiv();
|
||||
row.dataset.folderRow = node.path;
|
||||
row.style.display = "grid";
|
||||
row.style.gridTemplateColumns = "24px 24px minmax(0, 1fr)";
|
||||
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";
|
||||
addClasses(row, "ahs-settings-folder-row");
|
||||
row.style.setProperty("--ahs-folder-depth-indent", `${depth * 18}px`);
|
||||
|
||||
const hasChildren = node.children.length > 0;
|
||||
const expanded = hasChildren && this.expandedFolderPaths.has(node.path);
|
||||
const toggleControl = row.createEl(hasChildren ? "button" : "span");
|
||||
toggleControl.dataset.folderToggle = node.path;
|
||||
toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : "";
|
||||
toggleControl.style.width = "24px";
|
||||
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)";
|
||||
addClasses(toggleControl, "ahs-settings-folder-toggle");
|
||||
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("title", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
|
||||
toggleControl.style.cursor = "pointer";
|
||||
toggleControl.addEventListener("click", () => {
|
||||
if (this.expandedFolderPaths.has(node.path)) {
|
||||
this.expandedFolderPaths.delete(node.path);
|
||||
|
|
@ -1754,25 +1698,82 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
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.checked = node.checked;
|
||||
checkbox.indeterminate = node.indeterminate;
|
||||
checkbox.dataset.folderPath = node.path;
|
||||
checkbox.style.margin = "0";
|
||||
checkbox.style.justifySelf = "center";
|
||||
addClasses(checkbox, "ahs-settings-folder-checkbox");
|
||||
checkbox.addEventListener("change", () => {
|
||||
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.style.minWidth = "0";
|
||||
labelEl.style.overflow = "hidden";
|
||||
labelEl.style.textOverflow = "ellipsis";
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
addClasses(labelEl, "ahs-settings-folder-label");
|
||||
this.applyFluidEllipsis(labelEl);
|
||||
|
||||
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) {
|
||||
return;
|
||||
|
|
@ -1780,9 +1781,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const childrenContainer = containerEl.createDiv();
|
||||
childrenContainer.dataset.folderChildren = node.path;
|
||||
childrenContainer.style.display = "flex";
|
||||
childrenContainer.style.flexDirection = "column";
|
||||
childrenContainer.style.gap = "2px";
|
||||
addClasses(childrenContainer, "ahs-settings-folder-children");
|
||||
for (const child of node.children) {
|
||||
this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1);
|
||||
}
|
||||
|
|
@ -1798,46 +1797,72 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
this.scopeNeedsSelectedAncestorExpansion = false;
|
||||
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) {
|
||||
for (const folderPath of this.getFoldersForAncestorExpansion(scopeMode)) {
|
||||
for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
|
||||
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[] {
|
||||
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 {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
groupEl.style.display = "inline-flex";
|
||||
groupEl.style.alignItems = "center";
|
||||
groupEl.style.gap = "6px";
|
||||
groupEl.style.flexWrap = "nowrap";
|
||||
addClasses(groupEl, "ahs-settings-inline-control-group");
|
||||
|
||||
const labelEl = groupEl.createEl("span", { text: label });
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
const labelEl = groupEl.createSpan({ text: label });
|
||||
addClasses(labelEl, "ahs-settings-inline-control-label");
|
||||
|
||||
return groupEl;
|
||||
}
|
||||
|
||||
private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
groupEl.style.display = "flex";
|
||||
groupEl.style.alignItems = "center";
|
||||
groupEl.style.gap = "8px";
|
||||
groupEl.style.minWidth = "0";
|
||||
groupEl.style.overflow = "visible";
|
||||
addClasses(groupEl, "ahs-settings-grid-control-slot");
|
||||
|
||||
const labelEl = groupEl.createEl("span", { text: label });
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
labelEl.style.flex = "0 0 auto";
|
||||
const labelEl = groupEl.createSpan({ text: label });
|
||||
addClasses(labelEl, "ahs-settings-grid-control-label");
|
||||
|
||||
const slotEl = groupEl.createDiv();
|
||||
slotEl.style.flex = "1 1 0";
|
||||
slotEl.style.minWidth = "0";
|
||||
slotEl.style.overflow = "visible";
|
||||
addClasses(slotEl, "ahs-settings-grid-control-content");
|
||||
|
||||
return slotEl;
|
||||
}
|
||||
|
|
@ -1845,25 +1870,30 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private applyFluidEllipsis(element: HTMLElement): void {
|
||||
const tagName = (element.tagName || (element as unknown as { tag?: string }).tag || "").toLowerCase();
|
||||
const isFieldControl = tagName === "select" || tagName === "input";
|
||||
element.style.boxSizing = "border-box";
|
||||
element.style.width = isFieldControl ? "calc(100% - 2px)" : "100%";
|
||||
element.style.maxWidth = isFieldControl ? "calc(100% - 2px)" : "100%";
|
||||
element.style.minWidth = "0";
|
||||
element.style.margin = isFieldControl ? "1px" : "0";
|
||||
element.style.overflow = isFieldControl ? "visible" : "hidden";
|
||||
element.style.textOverflow = "ellipsis";
|
||||
element.style.whiteSpace = "nowrap";
|
||||
addClasses(element, "ahs-settings-fluid-ellipsis");
|
||||
if (isFieldControl) {
|
||||
addClasses(element, "ahs-settings-fluid-control");
|
||||
}
|
||||
}
|
||||
|
||||
private createSectionHeading(containerEl: HTMLElement, datasetKey: string, sectionId: string, title: string): void {
|
||||
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 {
|
||||
const activeWindow = window.activeWindow;
|
||||
this.textDraftValues.set(key, value);
|
||||
|
||||
const pendingTimer = this.debouncedTextSaves.get(key);
|
||||
if (pendingTimer) {
|
||||
globalThis.clearTimeout(pendingTimer.timer);
|
||||
activeWindow.clearTimeout(pendingTimer.timer);
|
||||
}
|
||||
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
const timer = activeWindow.setTimeout(() => {
|
||||
void this.flushDebouncedTextSave(key);
|
||||
}, TEXT_SAVE_DEBOUNCE_MS);
|
||||
|
||||
|
|
@ -1871,12 +1901,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async flushDebouncedTextSave(key: string): Promise<void> {
|
||||
const activeWindow = window.activeWindow;
|
||||
const pendingSave = this.debouncedTextSaves.get(key);
|
||||
if (!pendingSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(pendingSave.timer);
|
||||
activeWindow.clearTimeout(pendingSave.timer);
|
||||
this.debouncedTextSaves.delete(key);
|
||||
await pendingSave.saveAction(this.textDraftValues.get(key) ?? "");
|
||||
}
|
||||
|
|
@ -1891,7 +1922,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private createSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement {
|
||||
const selectEl = containerEl.createEl("select") as HTMLSelectElement;
|
||||
const selectEl = containerEl.createEl("select");
|
||||
selectEl.dataset.selectKey = datasetKey;
|
||||
return selectEl;
|
||||
}
|
||||
|
|
@ -1901,7 +1932,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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 {
|
||||
if (scopeMode === "include") {
|
||||
return t("settings.scope.summary.include");
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ export class InMemoryPluginStateRepository implements PluginStateRepository {
|
|||
|
||||
constructor(private state: PluginState = createEmptyPluginState()) {}
|
||||
|
||||
async load(): Promise<PluginState> {
|
||||
return this.state;
|
||||
load(): Promise<PluginState> {
|
||||
return Promise.resolve(this.state);
|
||||
}
|
||||
|
||||
async save(state: PluginState): Promise<void> {
|
||||
save(state: PluginState): Promise<void> {
|
||||
this.state = state;
|
||||
this.savedState = state;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,74 +44,49 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
}
|
||||
}
|
||||
|
||||
async listMarkdownFileRefs() {
|
||||
return Array.from(this.files.entries()).map(([path, file]) => ({
|
||||
listMarkdownFileRefs(): Promise<Array<{ path: string; basename: string; mtime: number; size: number }>> {
|
||||
return Promise.resolve(Array.from(this.files.entries()).map(([path, file]) => ({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
mtime: file.mtime,
|
||||
size: file.size,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
async listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
const rootMap = new Map<string, FolderTreeNode>();
|
||||
|
||||
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()));
|
||||
listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return Promise.resolve(buildFolderTree(Array.from(this.files.keys())));
|
||||
}
|
||||
|
||||
async readMarkdownFile(path: string): Promise<SourceFile | null> {
|
||||
readMarkdownFile(path: string): Promise<SourceFile | null> {
|
||||
this.readCalls.push(path);
|
||||
const file = this.files.get(path);
|
||||
if (!file) {
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content: file.content,
|
||||
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 });
|
||||
|
||||
if (this.errorPaths.has(path)) {
|
||||
throw this.errorPaths.get(path);
|
||||
const pathError = this.errorPaths.get(path);
|
||||
if (pathError) {
|
||||
return Promise.reject(pathError);
|
||||
}
|
||||
|
||||
if (this.conflictPaths.has(path)) {
|
||||
throw new MarkdownWriteConflictError(path);
|
||||
return Promise.reject(new MarkdownWriteConflictError(path));
|
||||
}
|
||||
|
||||
const current = this.files.get(path);
|
||||
if (!current || current.content !== expectedContent) {
|
||||
throw new MarkdownWriteConflictError(path);
|
||||
return Promise.reject(new MarkdownWriteConflictError(path));
|
||||
}
|
||||
|
||||
this.files.set(path, {
|
||||
|
|
@ -118,6 +94,8 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
mtime: current.mtime + 1,
|
||||
size: nextContent.length,
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string) {
|
||||
|
|
@ -170,40 +148,44 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
await this.ensureDecks([deckName]);
|
||||
}
|
||||
|
||||
async ensureDecks(deckNames: string[]): Promise<void> {
|
||||
ensureDecks(deckNames: string[]): Promise<void> {
|
||||
this.operationLog.push("ensureDecks");
|
||||
this.ensuredDecks.push(deckNames);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
return Object.keys(this.modelDetailsByName);
|
||||
listNoteModels(): Promise<string[]> {
|
||||
return Promise.resolve(Object.keys(this.modelDetailsByName));
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys());
|
||||
listDeckNames(): Promise<string[]> {
|
||||
return Promise.resolve(this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys()));
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] };
|
||||
getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return Promise.resolve(this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] });
|
||||
}
|
||||
|
||||
async getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return this.modelDetailsByName[modelName]?.fieldNames ?? [];
|
||||
getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return Promise.resolve(this.modelDetailsByName[modelName]?.fieldNames ?? []);
|
||||
}
|
||||
|
||||
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
return Object.fromEntries(await Promise.all(modelNames.map(async (modelName) => [
|
||||
modelName,
|
||||
await this.getModelFieldNames(modelName),
|
||||
])));
|
||||
getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
const fieldNamesByModelName: Record<string, string[]> = {};
|
||||
|
||||
for (const modelName of modelNames) {
|
||||
fieldNamesByModelName[modelName] = this.modelDetailsByName[modelName]?.fieldNames ?? [];
|
||||
}
|
||||
|
||||
return Promise.resolve(fieldNamesByModelName);
|
||||
}
|
||||
|
||||
async findNoteIds(query: string): Promise<number[]> {
|
||||
return [...(this.foundNoteIds.get(query) ?? [])];
|
||||
findNoteIds(query: string): Promise<number[]> {
|
||||
return Promise.resolve([...(this.foundNoteIds.get(query) ?? [])]);
|
||||
}
|
||||
|
||||
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
return Promise.resolve(noteIds.flatMap((noteId) => {
|
||||
const detail = this.noteDetailsById.get(noteId);
|
||||
if (detail) {
|
||||
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);
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined, fields: {} }] : [];
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName });
|
||||
getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return Promise.resolve(deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName }));
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return Promise.resolve(noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined }] : [];
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async addNote(input: AddAnkiNoteInput): Promise<number> {
|
||||
|
|
@ -230,12 +212,14 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
return noteId;
|
||||
}
|
||||
|
||||
async addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
|
||||
return inputs.map((input) => {
|
||||
addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
|
||||
const noteIds: number[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
this.operationLog.push("addNotes");
|
||||
const pendingError = this.addNotesErrorQueue.shift();
|
||||
if (pendingError) {
|
||||
throw pendingError;
|
||||
return Promise.reject(pendingError);
|
||||
}
|
||||
|
||||
this.addedNotes.push(input);
|
||||
|
|
@ -256,11 +240,13 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
tags: [...input.tags],
|
||||
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.deletedNotes.push(noteIds);
|
||||
|
||||
|
|
@ -271,17 +257,19 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
|
||||
const pendingError = this.deleteNotesErrorQueue.shift();
|
||||
if (pendingError) {
|
||||
throw pendingError;
|
||||
return Promise.reject(pendingError);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
|
||||
await this.updateNotes([input]);
|
||||
}
|
||||
|
||||
async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
|
||||
updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
|
||||
if (this.updateNoteModelError) {
|
||||
throw this.updateNoteModelError;
|
||||
return Promise.reject(this.updateNoteModelError);
|
||||
}
|
||||
|
||||
this.updatedNoteModels.push(input);
|
||||
|
|
@ -302,13 +290,16 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
fields: { ...input.fields },
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
|
||||
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
|
||||
this.updatedNotes.push(...inputs);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> {
|
||||
syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> {
|
||||
this.syncedNoteTags.push(...inputs);
|
||||
|
||||
for (const input of inputs) {
|
||||
|
|
@ -333,22 +324,27 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
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);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async deleteDecks(deckNames: string[]): Promise<void> {
|
||||
deleteDecks(deckNames: string[]): Promise<void> {
|
||||
this.deletedDecks.push(deckNames);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async storeMedia(asset: MediaAsset): Promise<void> {
|
||||
await this.storeMediaFiles([asset]);
|
||||
}
|
||||
|
||||
async storeMediaFiles(assets: MediaAsset[]): Promise<void> {
|
||||
storeMediaFiles(assets: MediaAsset[]): Promise<void> {
|
||||
this.storedMedia.push(...assets);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export async function requestUrl(): Promise<never> {
|
||||
throw new Error("obsidian.requestUrl stub was called without a test mock.");
|
||||
export function requestUrl(): Promise<never> {
|
||||
return Promise.reject(new Error("obsidian.requestUrl stub was called without a test mock."));
|
||||
}
|
||||
|
||||
export function getAllTags(cache: {
|
||||
|
|
|
|||
348
styles.css
348
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue