mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: implement full bilingual i18n and structured error rendering
- 新增完整双语 i18n 支持,支持按 Obsidian 语言自动切换 - 实现结构化 warning / user error 渲染 - Added full bilingual i18n support with automatic switching based on Obsidian language settings - Implemented structured warning and user error rendering
This commit is contained in:
parent
a3c540a493
commit
67854cf154
34 changed files with 1829 additions and 357 deletions
64
docs/i18n-decisions.md
Normal file
64
docs/i18n-decisions.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# i18n Decisions
|
||||
|
||||
## 1. 实现入口
|
||||
|
||||
本轮继续沿用现有分层,不做额外框架化改造:
|
||||
|
||||
1. `src/presentation/i18n/` 放 locale、消息字典、`t()`、`formatList()`。
|
||||
2. 展示层入口继续是 `AnkiHeadingSyncPlugin`、`registerCommands`、`PluginSettingTab`、`EmptyDeckSelectionModal`、`NoticeService`。
|
||||
3. 结构化用户错误定义放在 `src/application/errors/`,供 application / presentation 共用。
|
||||
|
||||
## 2. locale 策略
|
||||
|
||||
1. 运行时唯一来源是 `obsidian.getLanguage()`。
|
||||
2. 仅当返回值严格等于 `zh` 时使用简体中文。
|
||||
3. 其他所有语言码都回退到英文,包括 `en`、`en-GB`、`ja`、`zh-TW`。
|
||||
4. 不缓存 locale 常量;`t()` 与 `renderUserMessage()` 每次调用都重新解析当前语言。
|
||||
|
||||
## 3. message 结构
|
||||
|
||||
按计划固定以下 key 分组:
|
||||
|
||||
1. `commands.*`
|
||||
2. `settings.*`
|
||||
3. `modal.emptyDeck.*`
|
||||
4. `notice.*`
|
||||
5. `errors.*`
|
||||
6. `warnings.deck.*`
|
||||
|
||||
`zh.ts` 通过 `satisfies typeof en` 约束字典 shape,避免漏 key。
|
||||
|
||||
## 4. 用户错误模型
|
||||
|
||||
1. 新增 `PluginUserError`,仅承载 `key` 与 `params`。
|
||||
2. 新增 `isPluginUserError()` 与 `renderUserMessage()`。
|
||||
3. 插件自有、会冒泡到 Notice/UI 的错误统一抛 `PluginUserError`。
|
||||
4. 未结构化的第三方或底层异常继续允许透传 `error.message` 作为诊断信息。
|
||||
|
||||
## 5. warning / failure 结构
|
||||
|
||||
1. `DeckResolutionWarning` 改为 `{ filePath, code, params? }`。
|
||||
2. `getDeckResolutionWarningKey()` 使用 `filePath + code + stable(params)` 去重,不再依赖最终展示文案。
|
||||
3. deck 相关领域服务只输出 warning code/params;最终显示由 `NoticeService` 渲染。
|
||||
4. clear/reset/write-back 类失败项也改为结构化 `code + params`,避免 lower layer 直接生成最终字符串。
|
||||
|
||||
## 6. 设置页集成方式
|
||||
|
||||
1. 保持现有 imperative render,不引入新的设置元描述层。
|
||||
2. 所有标题、说明、按钮、状态文案、preview、dropdown label、scope 文案、aria-label 直接切到 `t()`。
|
||||
3. 动态状态统一改成 key + params,而不是继续内联拼接。
|
||||
4. note type、field name、deck name、folder path 等业务数据原样透传,不做翻译。
|
||||
|
||||
## 7. 仓库兼容性偏差
|
||||
|
||||
相对草案,采用以下更贴合仓库的落地方式:
|
||||
|
||||
1. `PluginUserError` 放在 application 层而不是 presentation 层,因为 `validatePluginSettings()`、`ManualSyncService`、`NoteFieldMappingService` 都在 application。
|
||||
2. `NoticeService` 负责 summary、warning 列表和结构化 failure 的最终格式化,避免在 plugin 主类里重复拼接。
|
||||
3. 对 `pluginState` 中持久化的 `deckWarnings` 直接升级为结构化 payload,不做兼容旧 message 字段的额外迁移逻辑,因为本任务明确不要求 settings schema 迁移,且 warning 不属于设置 schema。
|
||||
|
||||
## 8. 验证策略
|
||||
|
||||
1. 先做 locale / i18n 单测,确保基础设施稳定。
|
||||
2. 再补 settings / modal / notice 的双语表现测试。
|
||||
3. 最后更新 error / warning / persistence 相关测试,跑完整 `npm test`、`npm run build`、`npm run lint`。
|
||||
67
docs/i18n-gap-report.md
Normal file
67
docs/i18n-gap-report.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# i18n Gap Report
|
||||
|
||||
## 审查范围
|
||||
|
||||
本次按真实运行链路审查以下实现入口:
|
||||
|
||||
1. `src/presentation/AnkiHeadingSyncPlugin.ts`
|
||||
2. `src/presentation/commands/registerCommands.ts`
|
||||
3. `src/presentation/settings/PluginSettingTab.ts`
|
||||
4. `src/presentation/modals/EmptyDeckSelectionModal.ts`
|
||||
5. `src/presentation/notices/NoticeService.ts`
|
||||
6. `src/application/config/PluginSettings.ts`
|
||||
7. `src/application/services/NoteFieldMappingService.ts`
|
||||
8. `src/application/services/ManualSyncService.ts`
|
||||
9. `src/domain/manual-sync/services/DeckExtractionService.ts`
|
||||
10. `src/domain/manual-sync/services/FolderDeckMappingService.ts`
|
||||
11. `src/domain/manual-sync/services/DeckResolutionService.ts`
|
||||
|
||||
## 当前实现结论
|
||||
|
||||
### 真实运行路径
|
||||
|
||||
1. 插件启动时由 `AnkiHeadingSyncPlugin.onload()` 读取设置、注册命令、挂载设置页。
|
||||
2. 命令名全部在 `registerCommands.ts` 中注册期硬编码。
|
||||
3. 设置页所有 UI 文案都由 `PluginSettingTab.display()` 及其私有渲染方法直接写入 `Setting`、`createEl()`、`aria-label`。
|
||||
4. 空牌组清理弹窗由 `EmptyDeckSelectionModal` 直接创建标题、说明、按钮、计数文案。
|
||||
5. 所有 summary notice 由 `NoticeService` 直接拼接最终字符串;warning 明细直接展示 `warning.message`。
|
||||
6. 多个应用层与领域层直接抛出最终用户字符串,展示层通常直接透传 `error.message`。
|
||||
|
||||
### 已确认的缺口
|
||||
|
||||
1. 仓库内没有统一 i18n 层,也没有 `locale -> message -> render` 的基础设施。
|
||||
2. 当前语言来源没有统一入口,代码未调用 Obsidian `getLanguage()`。
|
||||
3. 设置页存在明显中英混排:前半段大多英文,deck/scope 部分大多中文。
|
||||
4. 命令名当前全部是中文,且无法随 Obsidian 语言切换。
|
||||
5. `EmptyDeckSelectionModal` 当前全部是中文硬编码。
|
||||
6. `NoticeService` 当前 summary 文案是英文模板,但插件主类传入的 prefix 又常常是中文,形成混用输出。
|
||||
7. `DeckResolutionWarning` 当前结构为 `{ filePath, code, message }`,warning key 也依赖最终 `message`,不适合国际化。
|
||||
8. `DeckExtractionService`、`FolderDeckMappingService`、`DeckResolutionService` 在领域层直接生成中文 warning message。
|
||||
9. `CurrentFileOutOfScopeError`、`validatePluginSettings()`、`NoteFieldMappingService`、`ManualSyncService.createWriteBackFailureMessage()` 当前都直接生成最终用户字符串。
|
||||
10. `AnkiHeadingSyncPlugin` 对多个 fallback notice 直接写死最终字符串,没有统一渲染层。
|
||||
11. `PluginSettingTab` 当前大量动态状态文字直接拼接字符串,例如 note type 加载状态、field 列表、mapping 保存状态、scope 描述、folder tree 状态等。
|
||||
12. `aria-label` 目前只有文件夹树展开/收起文字,且为中文硬编码。
|
||||
|
||||
### 与计划相比的仓库兼容性约束
|
||||
|
||||
1. 设置页并不是 schema/JSON 驱动,而是手写 imperative render;所以国际化必须直接切到这些 render 方法,而不是抽象新的设置描述 DSL。
|
||||
2. 命令名注册发生在 `onload()`,仓库内没有语言变化事件订阅,也没有命令热更新基础设施;这里只能按当前语言注册,后续靠插件重载刷新。
|
||||
3. 当前 clear/reset/cleanup 结果类型里有 `failureFiles: Array<{ filePath, message }>`,若要避免下层携带最终字符串,需要把这类 failure 改为结构化数据并在 notice 层渲染。
|
||||
4. `pluginState` 会持久化 `deckWarnings`;因此 warning 结构调整必须同时兼容状态仓储读写与现有测试。
|
||||
|
||||
## 本轮修复边界
|
||||
|
||||
本轮按计划收敛到以下范围:
|
||||
|
||||
1. 新增统一 i18n 基础设施,只支持 `en` 和简体中文。
|
||||
2. 统一接管插件运行时所有用户可见文案。
|
||||
3. 把用户错误与 deck warning 改成结构化数据,在上层按 locale 渲染。
|
||||
4. 更新持久化与测试以适配新的 warning / error 结构。
|
||||
5. 不修改 `PluginSettings` 持久化 schema。
|
||||
6. 不改 README、manifest 元数据或发布物料。
|
||||
|
||||
## 风险点
|
||||
|
||||
1. `PluginSettingTab.test.ts` 和 `EmptyDeckSelectionModal.test.ts` 当前大量断言具体文本,改造后需要成组重写为中英双 locale 断言。
|
||||
2. 由于 `obsidian` 在测试中普遍被 mock,新增 `getLanguage()` 后需要同步更新 mock/stub,避免测试在导入 i18n 时崩溃。
|
||||
3. warning 结构变化会穿过 domain、application、persistence、多组测试,必须一次性改全,否则类型和序列化都会断。
|
||||
|
|
@ -1,29 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
|
||||
import { DEFAULT_SETTINGS, validatePluginSettings } from "./PluginSettings";
|
||||
|
||||
function expectPluginUserError(action: () => void, key: string): void {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PluginUserError);
|
||||
expect((error as PluginUserError).userMessage.key).toBe(key);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Expected PluginUserError for key: ${key}`);
|
||||
}
|
||||
|
||||
describe("PluginSettings", () => {
|
||||
it("accepts the default V1 settings", () => {
|
||||
expect(() => validatePluginSettings(DEFAULT_SETTINGS)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects equal QA and Cloze heading levels", () => {
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
qaHeadingLevel: 4,
|
||||
clozeHeadingLevel: 4,
|
||||
}),
|
||||
).toThrow("QA and Cloze heading levels must be different.");
|
||||
});
|
||||
}, "errors.settings.headingLevelsDifferent");
|
||||
});
|
||||
|
||||
it("rejects invalid scope modes", () => {
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
scopeMode: "invalid" as never,
|
||||
}),
|
||||
).toThrow("Scope mode must be one of all, include, or exclude.");
|
||||
});
|
||||
}, "errors.settings.scopeModeInvalid");
|
||||
});
|
||||
|
||||
it("includes module 5 defaults", () => {
|
||||
|
|
@ -36,34 +50,34 @@ describe("PluginSettings", () => {
|
|||
});
|
||||
|
||||
it("rejects invalid QA Group markers and semantic marker collisions", () => {
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
qaGroupMarker: "anki-list",
|
||||
}),
|
||||
).toThrow("QA Group marker must be a hashtag-style token like #anki-list.");
|
||||
});
|
||||
}, "errors.settings.qaGroupMarkerInvalid");
|
||||
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
qaGroupMarker: "#anki-list-qa",
|
||||
}),
|
||||
).toThrow("QA Group marker must be different from Semantic QA marker.");
|
||||
});
|
||||
}, "errors.settings.qaGroupMarkerConflict");
|
||||
});
|
||||
|
||||
it("rejects invalid module 5 enum values", () => {
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
fileDeckInsertLocation: "middle" as never,
|
||||
}),
|
||||
).toThrow("File deck insert location must be yaml or body.");
|
||||
});
|
||||
}, "errors.settings.fileDeckInsertLocationInvalid");
|
||||
|
||||
expect(() =>
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
folderDeckMode: "tree" as never,
|
||||
}),
|
||||
).toThrow("Folder deck mode must be off, folder, or folder-and-file.");
|
||||
});
|
||||
}, "errors.settings.folderDeckModeInvalid");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
|
||||
export type ScopeMode = "all" | "include" | "exclude";
|
||||
export type FileDeckInsertLocation = "yaml" | "body";
|
||||
|
|
@ -65,116 +66,116 @@ export function validatePluginSettings(settings: PluginSettings): void {
|
|||
|
||||
for (const level of headingLevels) {
|
||||
if (!Number.isInteger(level) || level < 1 || level > 6) {
|
||||
throw new Error("Heading levels must be integers between 1 and 6.");
|
||||
throw new PluginUserError("errors.settings.headingLevelsRange");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.qaHeadingLevel === settings.clozeHeadingLevel) {
|
||||
throw new Error("QA and Cloze heading levels must be different.");
|
||||
throw new PluginUserError("errors.settings.headingLevelsDifferent");
|
||||
}
|
||||
|
||||
if (!settings.qaNoteType.trim()) {
|
||||
throw new Error("QA note type is required.");
|
||||
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
|
||||
}
|
||||
|
||||
if (!settings.qaGroupMarker.trim()) {
|
||||
throw new Error("QA Group marker is required.");
|
||||
throw new PluginUserError("errors.settings.qaGroupMarkerRequired");
|
||||
}
|
||||
|
||||
if (!isValidHashtagMarker(settings.qaGroupMarker.trim())) {
|
||||
throw new Error("QA Group marker must be a hashtag-style token like #anki-list.");
|
||||
throw new PluginUserError("errors.settings.qaGroupMarkerInvalid");
|
||||
}
|
||||
|
||||
if (!settings.clozeNoteType.trim()) {
|
||||
throw new Error("Cloze note type is required.");
|
||||
throw new PluginUserError("errors.settings.clozeNoteTypeRequired");
|
||||
}
|
||||
|
||||
if (!settings.semanticQaMarker.trim()) {
|
||||
throw new Error("Semantic QA marker is required.");
|
||||
throw new PluginUserError("errors.settings.semanticQaMarkerRequired");
|
||||
}
|
||||
|
||||
if (!isValidSemanticQaMarker(settings.semanticQaMarker.trim())) {
|
||||
throw new Error("Semantic QA marker must be a hashtag-style token like #anki-list-qa.");
|
||||
throw new PluginUserError("errors.settings.semanticQaMarkerInvalid");
|
||||
}
|
||||
|
||||
if (settings.qaGroupMarker.trim() === settings.semanticQaMarker.trim()) {
|
||||
throw new Error("QA Group marker must be different from Semantic QA marker.");
|
||||
throw new PluginUserError("errors.settings.qaGroupMarkerConflict");
|
||||
}
|
||||
|
||||
if (!settings.semanticQaNoteType.trim()) {
|
||||
throw new Error("Semantic QA note type is required.");
|
||||
throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired");
|
||||
}
|
||||
|
||||
validateNoteFieldMappings(settings.noteFieldMappings);
|
||||
|
||||
if (!settings.defaultDeck.trim()) {
|
||||
throw new Error("Default deck is required.");
|
||||
throw new PluginUserError("errors.settings.defaultDeckRequired");
|
||||
}
|
||||
|
||||
if (typeof settings.fileDeckEnabled !== "boolean") {
|
||||
throw new Error("File deck enabled must be a boolean.");
|
||||
throw new PluginUserError("errors.settings.fileDeckEnabledBoolean");
|
||||
}
|
||||
|
||||
if (typeof settings.fileDeckMarker !== "string") {
|
||||
throw new Error("File deck marker must be a string.");
|
||||
throw new PluginUserError("errors.settings.fileDeckMarkerString");
|
||||
}
|
||||
|
||||
if (typeof settings.fileDeckTemplate !== "string") {
|
||||
throw new Error("File deck template must be a string.");
|
||||
throw new PluginUserError("errors.settings.fileDeckTemplateString");
|
||||
}
|
||||
|
||||
if (settings.fileDeckInsertLocation !== "yaml" && settings.fileDeckInsertLocation !== "body") {
|
||||
throw new Error("File deck insert location must be yaml or body.");
|
||||
throw new PluginUserError("errors.settings.fileDeckInsertLocationInvalid");
|
||||
}
|
||||
|
||||
if (settings.folderDeckMode !== "off" && settings.folderDeckMode !== "folder" && settings.folderDeckMode !== "folder-and-file") {
|
||||
throw new Error("Folder deck mode must be off, folder, or folder-and-file.");
|
||||
throw new PluginUserError("errors.settings.folderDeckModeInvalid");
|
||||
}
|
||||
|
||||
if (settings.scopeMode !== "all" && settings.scopeMode !== "include" && settings.scopeMode !== "exclude") {
|
||||
throw new Error("Scope mode must be one of all, include, or exclude.");
|
||||
throw new PluginUserError("errors.settings.scopeModeInvalid");
|
||||
}
|
||||
|
||||
validateFolderList(settings.includeFolders, "Include folders");
|
||||
validateFolderList(settings.excludeFolders, "Exclude folders");
|
||||
validateFolderList(settings.includeFolders, "include");
|
||||
validateFolderList(settings.excludeFolders, "exclude");
|
||||
|
||||
if (!settings.ankiConnectUrl.trim()) {
|
||||
throw new Error("AnkiConnect URL is required.");
|
||||
throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
|
||||
}
|
||||
}
|
||||
|
||||
function validateFolderList(folderList: string[], label: string): void {
|
||||
function validateFolderList(folderList: string[], label: "include" | "exclude"): void {
|
||||
if (!Array.isArray(folderList)) {
|
||||
throw new Error(`${label} must be an array.`);
|
||||
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray");
|
||||
}
|
||||
|
||||
for (const folder of folderList) {
|
||||
if (typeof folder !== "string") {
|
||||
throw new Error(`${label} must only contain strings.`);
|
||||
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFieldMapping>): void {
|
||||
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
|
||||
throw new Error("Note field mappings must be an object.");
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsObject");
|
||||
}
|
||||
|
||||
for (const mapping of Object.values(noteFieldMappings)) {
|
||||
if (mapping.cardType !== "basic" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
|
||||
throw new Error("Note field mappings must use a supported card type.");
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsCardType");
|
||||
}
|
||||
|
||||
if (!mapping.modelName.trim()) {
|
||||
throw new Error("Note field mappings must include a model name.");
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsModelName");
|
||||
}
|
||||
|
||||
if (!Array.isArray(mapping.loadedFieldNames)) {
|
||||
throw new Error("Note field mappings must include loaded field names.");
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedFieldNames");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(mapping.loadedAt)) {
|
||||
throw new Error("Note field mappings must include a loaded timestamp.");
|
||||
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/application/errors/PluginUserError.test.ts
Normal file
71
src/application/errors/PluginUserError.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import * as obsidian from "obsidian";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PluginUserError, renderPluginFileFailure, renderPluginFileFailuresInline, renderUnknownUserFacingError, renderUserFacingMessage, renderUserMessage } from "./PluginUserError";
|
||||
|
||||
describe("PluginUserError", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders plugin-owned errors in English", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
|
||||
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
|
||||
|
||||
expect(renderUserMessage(error)).toBe("The current file is outside the plugin scope: notes/example.md");
|
||||
});
|
||||
|
||||
it("renders plugin-owned errors in Simplified Chinese", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
|
||||
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
|
||||
|
||||
expect(renderUserMessage(error)).toBe("当前文件不在插件作用范围内:notes/example.md");
|
||||
});
|
||||
|
||||
it("renders write-back failure summaries with localized detail lines", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
|
||||
const error = new PluginUserError(
|
||||
"errors.writeBack.summary",
|
||||
{ fileCount: 2 },
|
||||
{
|
||||
failures: [
|
||||
{ filePath: "a.md", key: "errors.writeBack.missingSourceContent" },
|
||||
{ filePath: "b.md", rawMessage: "permission denied" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(renderUserMessage(error)).toBe([
|
||||
"Markdown 标记写回失败,共 2 个文件。",
|
||||
"a.md: 缺少用于写回标记的扫描源码内容。",
|
||||
"b.md: permission denied",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("renders raw and keyed user-facing messages plus inline failure lists", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
|
||||
expect(renderUserFacingMessage({ key: "notice.failedSavePluginSettings" })).toBe("Failed to save plugin settings.");
|
||||
expect(renderUserFacingMessage({ rawMessage: "raw failure" })).toBe("raw failure");
|
||||
expect(renderPluginFileFailure({ filePath: "a.md", key: "errors.markerRemoval.markdownFileNotFound" })).toBe("Markdown file was not found.");
|
||||
expect(renderPluginFileFailuresInline([
|
||||
{ filePath: "a.md", key: "errors.markerRemoval.markdownFileNotFound" },
|
||||
{ filePath: "b.md", rawMessage: "permission denied" },
|
||||
])).toBe("a.md (Markdown file was not found.), b.md (permission denied)");
|
||||
});
|
||||
|
||||
it("falls back to raw error messages for unknown errors", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("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");
|
||||
|
||||
expect(renderUnknownUserFacingError(null, "notice.vaultSyncFailed")).toBe("Vault sync failed.");
|
||||
});
|
||||
});
|
||||
115
src/application/errors/PluginUserError.ts
Normal file
115
src/application/errors/PluginUserError.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { formatList, resolvePluginLocale, t, type MessageParams, type TranslationKey } from "@/presentation/i18n";
|
||||
|
||||
export interface PluginUserMessage {
|
||||
key: TranslationKey;
|
||||
params?: MessageParams;
|
||||
}
|
||||
|
||||
export interface RawUserMessage {
|
||||
rawMessage: string;
|
||||
}
|
||||
|
||||
export type UserFacingMessage = PluginUserMessage | RawUserMessage;
|
||||
|
||||
export type PluginFileFailure =
|
||||
| ({ filePath: string } & PluginUserMessage)
|
||||
| { filePath: string; rawMessage: string };
|
||||
|
||||
interface PluginUserErrorDetails {
|
||||
failures?: PluginFileFailure[];
|
||||
}
|
||||
|
||||
export class PluginUserError extends Error {
|
||||
readonly userMessage: PluginUserMessage;
|
||||
readonly details?: PluginUserErrorDetails;
|
||||
|
||||
constructor(key: TranslationKey, params?: MessageParams, details?: PluginUserErrorDetails) {
|
||||
super(key);
|
||||
this.name = "PluginUserError";
|
||||
this.userMessage = { key, params };
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginUserError(value: unknown): value is PluginUserError {
|
||||
return value instanceof PluginUserError;
|
||||
}
|
||||
|
||||
export function renderUserFacingMessage(message: UserFacingMessage): string {
|
||||
if ("rawMessage" in message) {
|
||||
return message.rawMessage;
|
||||
}
|
||||
|
||||
return t(message.key, message.params);
|
||||
}
|
||||
|
||||
export function renderPluginFileFailure(failure: PluginFileFailure): string {
|
||||
if ("rawMessage" in failure) {
|
||||
return failure.rawMessage;
|
||||
}
|
||||
|
||||
return t(failure.key, failure.params);
|
||||
}
|
||||
|
||||
export function renderUserMessage(error: PluginUserError): string {
|
||||
const message = renderUserFacingMessage(error.userMessage);
|
||||
|
||||
if (!error.details?.failures?.length) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const failureLines = error.details.failures
|
||||
.map((failure) => `${failure.filePath}: ${renderPluginFileFailure(failure)}`)
|
||||
.join("\n");
|
||||
|
||||
return `${message}\n${failureLines}`;
|
||||
}
|
||||
|
||||
export function toUserFacingMessage(error: unknown, fallbackKey: TranslationKey, fallbackParams?: MessageParams): UserFacingMessage {
|
||||
if (isPluginUserError(error)) {
|
||||
return error.userMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return { rawMessage: error.message };
|
||||
}
|
||||
|
||||
return { key: fallbackKey, params: fallbackParams };
|
||||
}
|
||||
|
||||
export function renderUnknownUserFacingError(error: unknown, fallbackKey: TranslationKey): string {
|
||||
if (isPluginUserError(error)) {
|
||||
return renderUserMessage(error);
|
||||
}
|
||||
|
||||
return renderUserFacingMessage(toUserFacingMessage(error, fallbackKey));
|
||||
}
|
||||
|
||||
export function toPluginFileFailure(filePath: string, error: unknown): PluginFileFailure {
|
||||
if (isPluginUserError(error)) {
|
||||
return {
|
||||
filePath,
|
||||
...error.userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
filePath,
|
||||
rawMessage: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
rawMessage: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPluginFileFailuresInline(failures: PluginFileFailure[]): string {
|
||||
const locale = resolvePluginLocale();
|
||||
return formatList(
|
||||
locale,
|
||||
failures.map((failure) => `${failure.filePath} (${renderPluginFileFailure(failure)})`),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { validatePluginSettings } from "@/application/config/PluginSettings";
|
||||
import { PluginUserError, type PluginFileFailure } from "@/application/errors/PluginUserError";
|
||||
import type { AnkiGateway, AnkiGroupGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
|
|
@ -22,9 +23,9 @@ import { getDeckResolutionWarningKey } from "@/domain/manual-sync/value-objects/
|
|||
|
||||
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
||||
|
||||
export class CurrentFileOutOfScopeError extends Error {
|
||||
export class CurrentFileOutOfScopeError extends PluginUserError {
|
||||
constructor(filePath: string) {
|
||||
super(`当前文件不在插件作用范围内: ${filePath}`);
|
||||
super("errors.currentFileOutOfScope", { filePath });
|
||||
this.name = "CurrentFileOutOfScopeError";
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +119,7 @@ export class ManualSyncService {
|
|||
await this.pluginStateRepository.save(nextState);
|
||||
|
||||
if (writeBackResult.failureFiles.length > 0) {
|
||||
throw new Error(this.createWriteBackFailureMessage(writeBackResult.failureFiles));
|
||||
throw this.createWriteBackFailureError(writeBackResult.failureFiles);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -188,7 +189,7 @@ export class ManualSyncService {
|
|||
await this.pluginStateRepository.save(nextState);
|
||||
|
||||
if (writeBackResult.failureFiles.length > 0) {
|
||||
throw new Error(this.createWriteBackFailureMessage(writeBackResult.failureFiles));
|
||||
throw this.createWriteBackFailureError(writeBackResult.failureFiles);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -446,8 +447,12 @@ export class ManualSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
private createWriteBackFailureMessage(failures: Array<{ filePath: string; message: string }>): string {
|
||||
return `Markdown marker write-back failed for ${failures.length} file(s).\n${failures.map((failure) => `${failure.filePath}: ${failure.message}`).join("\n")}`;
|
||||
private createWriteBackFailureError(failures: PluginFileFailure[]): PluginUserError {
|
||||
return new PluginUserError("errors.writeBack.summary", {
|
||||
fileCount: failures.length,
|
||||
}, {
|
||||
failures,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import { toPluginFileFailure, type PluginFileFailure } from "@/application/errors/PluginUserError";
|
||||
import { CardMarkerRemovalService, type MarkerRemovalTarget } from "@/domain/manual-sync/services/CardMarkerRemovalService";
|
||||
|
||||
export interface MarkdownMarkerRemovalResult {
|
||||
removedMarkers: number;
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
failureFiles: PluginFileFailure[];
|
||||
}
|
||||
|
||||
export class MarkdownMarkerRemovalService {
|
||||
|
|
@ -28,7 +29,7 @@ export class MarkdownMarkerRemovalService {
|
|||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [{ filePath, message: `Markdown file not found: ${filePath}` }],
|
||||
failureFiles: [{ filePath, key: "errors.markerRemoval.markdownFileNotFound" }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -56,10 +57,7 @@ export class MarkdownMarkerRemovalService {
|
|||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [{
|
||||
filePath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}],
|
||||
failureFiles: [toPluginFileFailure(filePath, error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import { toPluginFileFailure, type PluginFileFailure } from "@/application/errors/PluginUserError";
|
||||
import { CardMarkerService } from "@/domain/manual-sync/services/CardMarkerService";
|
||||
import { GroupMarkerService } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export interface MarkdownSyncedMarkerRemovalResult {
|
|||
removedGroupMarkers: number;
|
||||
removedMarkers: number;
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
failureFiles: PluginFileFailure[];
|
||||
}
|
||||
|
||||
export class MarkdownSyncedMarkerRemovalService {
|
||||
|
|
@ -41,7 +42,7 @@ export class MarkdownSyncedMarkerRemovalService {
|
|||
if (!sourceFile) {
|
||||
return {
|
||||
...createEmptyRemovalResult(),
|
||||
failureFiles: [{ filePath, message: `Markdown file not found: ${filePath}` }],
|
||||
failureFiles: [{ filePath, key: "errors.markerRemoval.markdownFileNotFound" }],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -75,10 +76,7 @@ export class MarkdownSyncedMarkerRemovalService {
|
|||
|
||||
return {
|
||||
...createEmptyRemovalResult(),
|
||||
failureFiles: [{
|
||||
filePath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}],
|
||||
failureFiles: [toPluginFileFailure(filePath, error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import { PluginUserError, toPluginFileFailure, type PluginFileFailure } from "@/application/errors/PluginUserError";
|
||||
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
|
||||
import type { PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
|
||||
|
|
@ -9,7 +10,7 @@ import { GroupMarkerService, type GroupMarkerWriteRequest, serializeGroupMarker
|
|||
export interface MarkdownWriteBackResult {
|
||||
writtenSyncKeys: string[];
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
failureFiles: PluginFileFailure[];
|
||||
pendingEntries: PendingWriteBackState[];
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ export class MarkdownWriteBackService {
|
|||
const plannedByFile = new Map<string, Array<{ kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest }>>();
|
||||
const writtenSyncKeys: string[] = [];
|
||||
const conflictFiles: string[] = [];
|
||||
const failureFiles: Array<{ filePath: string; message: string }> = [];
|
||||
const failureFiles: PluginFileFailure[] = [];
|
||||
const pendingEntries: PendingWriteBackState[] = [];
|
||||
|
||||
for (const plannedCard of plannedCards) {
|
||||
|
|
@ -57,7 +58,7 @@ export class MarkdownWriteBackService {
|
|||
|
||||
if (!sourceContent || !indexedFile) {
|
||||
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile?.fileHash ?? "")));
|
||||
failureFiles.push({ filePath, message: `Missing scanned source content for ${filePath}.` });
|
||||
failureFiles.push({ filePath, key: "errors.writeBack.missingSourceContent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -95,10 +96,7 @@ export class MarkdownWriteBackService {
|
|||
continue;
|
||||
}
|
||||
|
||||
failureFiles.push({
|
||||
filePath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
failureFiles.push(toPluginFileFailure(filePath, error));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +166,9 @@ function getRawBlockHashFromSyncKey(syncKey: string): string {
|
|||
|
||||
function requireNoteId(plannedCard: PlannedCard): number {
|
||||
if (plannedCard.noteId === undefined) {
|
||||
throw new Error(`Cannot write marker without noteId for block ${plannedCard.card.filePath}:${plannedCard.card.blockStartLine}.`);
|
||||
throw new PluginUserError("errors.writeBack.noteIdMissing", {
|
||||
blockStartLine: plannedCard.card.blockStartLine,
|
||||
});
|
||||
}
|
||||
|
||||
return plannedCard.noteId;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
import type { CardType } from "@/domain/card/entities/RenderedFields";
|
||||
|
||||
import { NoteFieldMappingService } from "./NoteFieldMappingService";
|
||||
|
|
@ -26,6 +27,17 @@ function createCard(overrides: Partial<RenderedCardInput>): RenderedCardInput {
|
|||
};
|
||||
}
|
||||
|
||||
function expectPluginUserError(action: () => void): PluginUserError {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PluginUserError);
|
||||
return error as PluginUserError;
|
||||
}
|
||||
|
||||
throw new Error("Expected PluginUserError");
|
||||
}
|
||||
|
||||
describe("NoteFieldMappingService", () => {
|
||||
it("maps a basic card using a saved title/body mapping", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
|
@ -83,15 +95,18 @@ describe("NoteFieldMappingService", () => {
|
|||
it("throws when a mapping is missing", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
expect(() =>
|
||||
const error = expectPluginUserError(() =>
|
||||
service.map(createCard({}), { fieldNames: ["Front", "Back"], isCloze: false }, {}),
|
||||
).toThrow("Open plugin settings and read fields from Anki first");
|
||||
);
|
||||
|
||||
expect(error.userMessage.key).toBe("errors.noteFieldMapping.missingSavedMapping.basic");
|
||||
expect(error.userMessage.params).toEqual({ modelName: "Basic" });
|
||||
});
|
||||
|
||||
it("throws when a saved mapping is stale", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
expect(() =>
|
||||
const error = expectPluginUserError(() =>
|
||||
service.map(
|
||||
createCard({}),
|
||||
{ fieldNames: ["Front", "Body"], isCloze: false },
|
||||
|
|
@ -106,13 +121,19 @@ describe("NoteFieldMappingService", () => {
|
|||
},
|
||||
},
|
||||
),
|
||||
).toThrow("is stale because these fields no longer exist in Anki");
|
||||
);
|
||||
|
||||
expect(error.userMessage.key).toBe("errors.noteFieldMapping.stale");
|
||||
expect(error.userMessage.params).toEqual({
|
||||
modelName: "Basic",
|
||||
fields: ["\"Back\""],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when a basic mapping reuses the same field for title and body", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
expect(() =>
|
||||
const error = expectPluginUserError(() =>
|
||||
service.map(
|
||||
createCard({}),
|
||||
{ fieldNames: ["Front", "Back"], isCloze: false },
|
||||
|
|
@ -127,6 +148,9 @@ describe("NoteFieldMappingService", () => {
|
|||
},
|
||||
},
|
||||
),
|
||||
).toThrow('must use different title and body fields');
|
||||
);
|
||||
|
||||
expect(error.userMessage.key).toBe("errors.noteFieldMapping.titleBodyMustDiffer.basic");
|
||||
expect(error.userMessage.params).toEqual({ modelName: "Basic" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields";
|
||||
|
||||
interface RenderedCardInput {
|
||||
|
|
@ -58,35 +59,45 @@ export class NoteFieldMappingService {
|
|||
|
||||
if (isBasicLikeCardType(mapping.cardType)) {
|
||||
if (!mapping.titleField || !mapping.bodyField) {
|
||||
throw new Error(
|
||||
`Saved field mapping for ${describeBasicLikeCardType(mapping.cardType)} note type "${mapping.modelName}" is incomplete. Open plugin settings and save both title and body fields.`,
|
||||
);
|
||||
throw new PluginUserError(getIncompleteSavedMappingKey(mapping.cardType), {
|
||||
modelName: mapping.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (mapping.titleField === mapping.bodyField) {
|
||||
throw new Error(`${capitalizeFirstLetter(describeBasicLikeCardType(mapping.cardType))} note type "${mapping.modelName}" must use different title and body fields.`);
|
||||
throw new PluginUserError(getTitleBodyMustDifferKey(mapping.cardType), {
|
||||
modelName: mapping.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
const missingFields = [mapping.titleField, mapping.bodyField].filter((fieldName) => !availableFields.has(fieldName));
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(this.createStaleMappingError(mapping.modelName, missingFields));
|
||||
throw new PluginUserError("errors.noteFieldMapping.stale", {
|
||||
modelName: mapping.modelName,
|
||||
fields: missingFields.map((fieldName) => `"${fieldName}"`),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mapping.mainField) {
|
||||
throw new Error(
|
||||
`Saved field mapping for cloze note type "${mapping.modelName}" is incomplete. Open plugin settings and save the main field.`,
|
||||
);
|
||||
throw new PluginUserError("errors.noteFieldMapping.incompleteSavedMapping.cloze", {
|
||||
modelName: mapping.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (!noteModelDetails.isCloze) {
|
||||
throw new Error(`Cloze note type "${mapping.modelName}" is not cloze-compatible in Anki.`);
|
||||
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
|
||||
modelName: mapping.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (!availableFields.has(mapping.mainField)) {
|
||||
throw new Error(this.createStaleMappingError(mapping.modelName, [mapping.mainField]));
|
||||
throw new PluginUserError("errors.noteFieldMapping.stale", {
|
||||
modelName: mapping.modelName,
|
||||
fields: [`"${mapping.mainField}"`],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +106,9 @@ export class NoteFieldMappingService {
|
|||
const bodyFieldName = mapping.bodyField;
|
||||
|
||||
if (!titleFieldName || !bodyFieldName) {
|
||||
throw new Error(`Saved field mapping for basic note type "${card.noteModel}" is incomplete.`);
|
||||
throw new PluginUserError(getIncompleteSavedMappingKey(card.type), {
|
||||
modelName: card.noteModel,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -106,11 +119,15 @@ export class NoteFieldMappingService {
|
|||
|
||||
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
|
||||
if (!noteModelDetails.isCloze) {
|
||||
throw new Error(`Cloze note type "${card.noteModel}" is not cloze-compatible in Anki.`);
|
||||
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
|
||||
modelName: card.noteModel,
|
||||
});
|
||||
}
|
||||
|
||||
if (!mapping.mainField) {
|
||||
throw new Error(`Saved field mapping for cloze note type "${card.noteModel}" is incomplete.`);
|
||||
throw new PluginUserError("errors.noteFieldMapping.incompleteSavedMapping.cloze", {
|
||||
modelName: card.noteModel,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -125,34 +142,39 @@ export class NoteFieldMappingService {
|
|||
const mapping = noteFieldMappings[createNoteFieldMappingKey(card.type, card.noteModel)];
|
||||
|
||||
if (!mapping) {
|
||||
throw new Error(
|
||||
`No saved field mapping found for ${describeCardType(card.type)} note type "${card.noteModel}". Open plugin settings and read fields from Anki first.`,
|
||||
);
|
||||
throw new PluginUserError(getMissingSavedMappingKey(card.type), {
|
||||
modelName: card.noteModel,
|
||||
});
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private createStaleMappingError(modelName: string, missingFields: string[]): string {
|
||||
const quotedMissingFields = missingFields.map((fieldName) => `"${fieldName}"`).join(", ");
|
||||
return `Saved field mapping for note type "${modelName}" is stale because these fields no longer exist in Anki: ${quotedMissingFields}. Open plugin settings and read fields from Anki again.`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCardType(cardType: CardType): string {
|
||||
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" {
|
||||
if (cardType === "cloze") {
|
||||
return "cloze";
|
||||
return "errors.noteFieldMapping.missingSavedMapping.cloze";
|
||||
}
|
||||
|
||||
return describeBasicLikeCardType(cardType);
|
||||
return cardType === "semantic-qa"
|
||||
? "errors.noteFieldMapping.missingSavedMapping.semanticQa"
|
||||
: "errors.noteFieldMapping.missingSavedMapping.basic";
|
||||
}
|
||||
|
||||
function describeBasicLikeCardType(cardType: Extract<CardType, "basic" | "semantic-qa">): string {
|
||||
return cardType === "semantic-qa" ? "semantic QA" : "basic";
|
||||
function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" {
|
||||
if (cardType === "cloze") {
|
||||
return "errors.noteFieldMapping.incompleteSavedMapping.cloze";
|
||||
}
|
||||
|
||||
return cardType === "semantic-qa"
|
||||
? "errors.noteFieldMapping.incompleteSavedMapping.semanticQa"
|
||||
: "errors.noteFieldMapping.incompleteSavedMapping.basic";
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
function getTitleBodyMustDifferKey(cardType: Extract<CardType, "basic" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
|
||||
return cardType === "semantic-qa"
|
||||
? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa"
|
||||
: "errors.noteFieldMapping.titleBodyMustDiffer.basic";
|
||||
}
|
||||
|
||||
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/dom
|
|||
import type { GroupBlockState, PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { GroupMarkerService, type GroupMarkerWriteRequest } from "@/domain/manual-sync/services/GroupMarkerService";
|
||||
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
|
||||
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
import { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
|
||||
import { buildQaGroupNoteFields, formatQaGroupSlot, QA_GROUP_MODEL_NAME, QA_GROUP_SLOT_COUNT } from "./QaGroupModelDefinition";
|
||||
|
|
@ -101,7 +101,7 @@ export class QaGroupSyncService {
|
|||
deckWarnings: block.deckWarnings,
|
||||
} as never, settings.defaultDeck, settings.folderDeckMode);
|
||||
for (const warning of deckResolution.warnings) {
|
||||
warningMap.set(`${warning.filePath}:${warning.code}:${warning.message}`, warning);
|
||||
warningMap.set(getDeckResolutionWarningKey(warning), warning);
|
||||
}
|
||||
|
||||
const deck = deckResolution.resolvedDeck.value;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { PluginFileFailure } from "@/application/errors/PluginUserError";
|
||||
|
||||
export interface ClearCurrentFileSyncedCardsResult {
|
||||
trackedCards: number;
|
||||
trackedGroups: number;
|
||||
|
|
@ -7,7 +9,7 @@ export interface ClearCurrentFileSyncedCardsResult {
|
|||
removedGroupMarkers: number;
|
||||
deletedLocalRecords: number;
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
failureFiles: PluginFileFailure[];
|
||||
}
|
||||
|
||||
export interface CleanupEmptyDecksResult {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export class DeckExtractionService {
|
|||
warnings.push({
|
||||
filePath: sourceFile.path,
|
||||
code: "deck_conflict_yaml_body",
|
||||
message: "检测到同一文件同时在 YAML 和正文中声明了不同的 TARGET DECK,本次已按 YAML 值同步,请清理冲突配置。",
|
||||
params: {
|
||||
marker,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -131,7 +133,9 @@ export class DeckExtractionService {
|
|||
warnings.push({
|
||||
filePath,
|
||||
code: "deck_multiple_body_declarations",
|
||||
message: "检测到同一文件存在多个 TARGET DECK 声明,本次只使用第一个正文 deck 声明。",
|
||||
params: {
|
||||
marker,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
|
||||
import { DeckNormalizationService } from "./DeckNormalizationService";
|
||||
|
||||
describe("DeckNormalizationService", () => {
|
||||
|
|
@ -14,6 +16,14 @@ describe("DeckNormalizationService", () => {
|
|||
it("rejects empty final deck values", () => {
|
||||
const service = new DeckNormalizationService();
|
||||
|
||||
expect(() => service.normalize(" / :: ")).toThrow("Deck 不能为空");
|
||||
try {
|
||||
service.normalize(" / :: ");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PluginUserError);
|
||||
expect((error as PluginUserError).userMessage.key).toBe("errors.deck.emptyName");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Expected PluginUserError for empty deck normalization");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
|
||||
export class DeckNormalizationService {
|
||||
normalize(deckName: string): string {
|
||||
const normalizedSeparators = deckName.trim().replace(/\\/g, "/").replace(/\/+?/g, "::");
|
||||
|
|
@ -7,7 +9,7 @@ export class DeckNormalizationService {
|
|||
.filter(Boolean);
|
||||
|
||||
if (segments.length === 0) {
|
||||
throw new Error("Deck 不能为空,请检查 TARGET DECK 或默认 deck 配置。");
|
||||
throw new PluginUserError("errors.deck.emptyName");
|
||||
}
|
||||
|
||||
return segments.join("::");
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export class DeckResolutionService {
|
|||
{
|
||||
filePath: card.filePath,
|
||||
code: "deck_fallback_default",
|
||||
message: "该文件没有显式 deck,且所在位置无法生成文件夹牌组,已回退到默认 deck。",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export class FolderDeckMappingService {
|
|||
warnings: [{
|
||||
filePath,
|
||||
code: "deck_invalid_folder_segment",
|
||||
message: "检测到文件夹名或文件名包含 ::,本次已放弃文件夹映射并回退到默认 deck。",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export type DeckResolutionWarningCode =
|
|||
| "deck_invalid_folder_segment"
|
||||
| "deck_fallback_default";
|
||||
|
||||
export type DeckResolutionWarningParams = Record<string, string | number | boolean>;
|
||||
|
||||
export interface ResolvedDeck {
|
||||
value: string;
|
||||
source: DeckResolutionSource;
|
||||
|
|
@ -14,7 +16,7 @@ export interface ResolvedDeck {
|
|||
export interface DeckResolutionWarning {
|
||||
filePath: string;
|
||||
code: DeckResolutionWarningCode;
|
||||
message: string;
|
||||
params?: DeckResolutionWarningParams;
|
||||
}
|
||||
|
||||
export interface ExplicitDeckExtractionResult {
|
||||
|
|
@ -31,5 +33,25 @@ export interface DeckResolutionResult {
|
|||
}
|
||||
|
||||
export function getDeckResolutionWarningKey(warning: DeckResolutionWarning): string {
|
||||
return `${warning.filePath}\u0000${warning.code}\u0000${warning.message}`;
|
||||
return `${warning.filePath}\u0000${warning.code}\u0000${stableStringify(warning.params)}`;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
|
|
@ -14,7 +14,9 @@ import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGat
|
|||
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository";
|
||||
import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository";
|
||||
import { registerCommands } from "@/presentation/commands/registerCommands";
|
||||
import { renderUnknownUserFacingError, renderUserMessage } from "@/application/errors/PluginUserError";
|
||||
import { EmptyDeckSelectionModal } from "@/presentation/modals/EmptyDeckSelectionModal";
|
||||
import { t } from "@/presentation/i18n";
|
||||
import { NoticeService } from "@/presentation/notices/NoticeService";
|
||||
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
|
||||
import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService";
|
||||
|
|
@ -48,7 +50,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
} catch (error) {
|
||||
console.error("Failed to load plugin settings, falling back to defaults.", error);
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory.");
|
||||
this.noticeService.error(t("notice.invalidSettingsRestored"));
|
||||
}
|
||||
|
||||
const manualSyncService = new ManualSyncService(vaultGateway, pluginStateRepository, this.ankiGateway);
|
||||
|
|
@ -76,7 +78,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
await this.pluginConfigRepository.save(nextSettings);
|
||||
this.settings = nextSettings;
|
||||
} catch (error) {
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Failed to save plugin settings.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.failedSavePluginSettings"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,56 +102,56 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
|
||||
this.noticeService.error("No active Markdown file is available for sync.");
|
||||
this.noticeService.error(t("notice.noActiveMarkdownForSync"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.syncCurrentFileUseCase) {
|
||||
this.noticeService.error("Sync use case is not initialized.");
|
||||
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings);
|
||||
this.noticeService.showSyncSummary("当前文件同步完成", result);
|
||||
this.noticeService.showSyncSummary("currentFile", result);
|
||||
} catch (error) {
|
||||
if (error instanceof CurrentFileOutOfScopeError) {
|
||||
this.noticeService.info("当前文件不在插件作用范围内");
|
||||
this.noticeService.info(renderUserMessage(error));
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Current file sync failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.currentFileSyncFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async runSyncVault(): Promise<void> {
|
||||
if (!this.syncVaultUseCase) {
|
||||
this.noticeService.error("Sync use case is not initialized.");
|
||||
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.syncVaultUseCase.execute(this.settings);
|
||||
this.noticeService.showSyncSummary("全库同步完成", result);
|
||||
this.noticeService.showSyncSummary("vault", result);
|
||||
} catch (error) {
|
||||
console.error("Vault sync failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.vaultSyncFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async runRebuildCardIndex(): Promise<void> {
|
||||
if (!this.rebuildCardIndexUseCase) {
|
||||
this.noticeService.error("Sync use case is not initialized.");
|
||||
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.rebuildCardIndexUseCase.execute(this.settings);
|
||||
this.noticeService.showRebuildSummary("卡片索引重建完成", result);
|
||||
this.noticeService.showRebuildSummary(result);
|
||||
} catch (error) {
|
||||
console.error("Card index rebuild failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Card index rebuild failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.rebuildFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,19 +159,19 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
|
||||
this.noticeService.error("No active Markdown file is available for deck template insertion.");
|
||||
this.noticeService.error(t("notice.noActiveMarkdownForDeckTemplateInsertion"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.vaultGateway) {
|
||||
this.noticeService.error("Vault gateway is not initialized.");
|
||||
this.noticeService.error(t("notice.vaultGatewayNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceFile = await this.vaultGateway.readMarkdownFile(activeFile.path);
|
||||
if (!sourceFile) {
|
||||
this.noticeService.error(`Markdown file not found: ${activeFile.path}`);
|
||||
this.noticeService.error(t("notice.markdownFileNotFound", { filePath: activeFile.path }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -181,15 +183,15 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
);
|
||||
|
||||
if (insertionResult.nextContent === insertionResult.expectedContent) {
|
||||
this.noticeService.info("当前文件中的 deck 模板已是最新,无需重复写入。");
|
||||
this.noticeService.info(t("notice.deckTemplateAlreadyCurrent"));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.vaultGateway.replaceMarkdownFile(activeFile.path, insertionResult.expectedContent, insertionResult.nextContent);
|
||||
this.noticeService.info(`已向当前文件写入 deck 模板:${insertionResult.insertedDeck}`);
|
||||
this.noticeService.info(t("notice.deckTemplateInserted", { deck: insertionResult.insertedDeck }));
|
||||
} catch (error) {
|
||||
console.error("Deck template insertion failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Deck template insertion failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.deckTemplateInsertionFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,54 +199,54 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
|
||||
this.noticeService.error("No active Markdown file is available for reset.");
|
||||
this.noticeService.error(t("notice.noActiveMarkdownForReset"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.clearCurrentFileSyncedCardsUseCase) {
|
||||
this.noticeService.error("Clear-current-file use case is not initialized.");
|
||||
this.noticeService.error(t("notice.clearCurrentFileUseCaseNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasTrackedCards = await this.clearCurrentFileSyncedCardsUseCase.hasTrackedCards(activeFile.path);
|
||||
if (!hasTrackedCards) {
|
||||
this.noticeService.info("当前文件没有已同步卡片");
|
||||
this.noticeService.info(t("notice.noTrackedCardsInCurrentFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.clearCurrentFileSyncedCardsUseCase.execute(activeFile.path);
|
||||
this.noticeService.showClearCurrentFileSummary("当前文件已同步卡片清空完成", result);
|
||||
this.noticeService.showClearCurrentFileSummary(result);
|
||||
} catch (error) {
|
||||
console.error("Clear current file synced cards failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Clear current file synced cards failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.clearCurrentFileFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async runCleanupEmptyDecks(): Promise<void> {
|
||||
if (!this.cleanupEmptyDecksUseCase) {
|
||||
this.noticeService.error("Empty-deck cleanup use case is not initialized.");
|
||||
this.noticeService.error(t("notice.cleanupEmptyDecksUseCaseNotInitialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidateDeckNames = await this.cleanupEmptyDecksUseCase.listCandidates();
|
||||
if (candidateDeckNames.length === 0) {
|
||||
this.noticeService.info("未发现可清理的空牌组");
|
||||
this.noticeService.info(t("notice.noEmptyDeckCandidates"));
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDeckNames = await new EmptyDeckSelectionModal(this.app, candidateDeckNames).openAndGetSelection();
|
||||
if (selectedDeckNames === null) {
|
||||
this.noticeService.info("已取消空牌组清理");
|
||||
this.noticeService.info(t("notice.cleanupEmptyDecksCancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.cleanupEmptyDecksUseCase.execute(selectedDeckNames, candidateDeckNames);
|
||||
this.noticeService.showCleanupEmptyDecksSummary("空牌组清理完成", result);
|
||||
this.noticeService.showCleanupEmptyDecksSummary(result);
|
||||
} catch (error) {
|
||||
console.error("Cleanup empty decks failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Cleanup empty decks failed.");
|
||||
this.noticeService.error(renderUnknownUserFacingError(error, "notice.cleanupEmptyDecksFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
|
||||
import { t } from "@/presentation/i18n";
|
||||
|
||||
export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: "sync-current-file-to-anki",
|
||||
name: "同步当前文件到 Anki",
|
||||
name: t("commands.syncCurrentFileToAnki"),
|
||||
callback: () => {
|
||||
void plugin.runSyncCurrentFile();
|
||||
},
|
||||
|
|
@ -11,7 +12,7 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
|||
|
||||
plugin.addCommand({
|
||||
id: "sync-vault-to-anki",
|
||||
name: "同步全库到 Anki",
|
||||
name: t("commands.syncVaultToAnki"),
|
||||
callback: () => {
|
||||
void plugin.runSyncVault();
|
||||
},
|
||||
|
|
@ -19,7 +20,7 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
|||
|
||||
plugin.addCommand({
|
||||
id: "rebuild-card-index",
|
||||
name: "重建卡片索引",
|
||||
name: t("commands.rebuildCardIndex"),
|
||||
callback: () => {
|
||||
void plugin.runRebuildCardIndex();
|
||||
},
|
||||
|
|
@ -27,7 +28,7 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
|||
|
||||
plugin.addCommand({
|
||||
id: "clear-current-file-synced-cards",
|
||||
name: "清空当前文件已同步卡片",
|
||||
name: t("commands.clearCurrentFileSyncedCards"),
|
||||
callback: () => {
|
||||
void plugin.runClearCurrentFileSyncedCards();
|
||||
},
|
||||
|
|
@ -35,7 +36,7 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
|||
|
||||
plugin.addCommand({
|
||||
id: "cleanup-empty-decks",
|
||||
name: "清理空牌组",
|
||||
name: t("commands.cleanupEmptyDecks"),
|
||||
callback: () => {
|
||||
void plugin.runCleanupEmptyDecks();
|
||||
},
|
||||
|
|
|
|||
59
src/presentation/i18n/index.ts
Normal file
59
src/presentation/i18n/index.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { resolvePluginLocale, formatList, type PluginLocale } from "./locale";
|
||||
import { en } from "./messages/en";
|
||||
import { zh } from "./messages/zh";
|
||||
|
||||
type PrimitiveMessageValue = string | number | boolean | undefined;
|
||||
type MessageParamValue = PrimitiveMessageValue | PrimitiveMessageValue[];
|
||||
|
||||
type NestedTranslationKeys<T> = {
|
||||
[K in keyof T & string]: T[K] extends string ? K : `${K}.${NestedTranslationKeys<T[K]>}`
|
||||
}[keyof T & string];
|
||||
|
||||
export type TranslationKey = NestedTranslationKeys<typeof en>;
|
||||
export type MessageParams = Record<string, MessageParamValue>;
|
||||
|
||||
function lookupMessage(dictionary: Record<string, unknown>, key: TranslationKey): string | undefined {
|
||||
const segments = key.split(".");
|
||||
let current: unknown = dictionary;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object" || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params: MessageParams | undefined, locale: PluginLocale): string {
|
||||
if (!params) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return formatList(locale, value.filter((entry): entry is PrimitiveMessageValue => entry !== undefined).map((entry) => String(entry)));
|
||||
}
|
||||
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function t(key: TranslationKey, params?: MessageParams): string {
|
||||
const locale = resolvePluginLocale();
|
||||
const dictionary = locale === "zh" ? zh : en;
|
||||
const template = lookupMessage(dictionary as Record<string, unknown>, key)
|
||||
?? lookupMessage(en as Record<string, unknown>, key)
|
||||
?? key;
|
||||
|
||||
return interpolate(template, params, locale);
|
||||
}
|
||||
|
||||
export { formatList, resolvePluginLocale, type PluginLocale };
|
||||
30
src/presentation/i18n/locale.test.ts
Normal file
30
src/presentation/i18n/locale.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import * as obsidian from "obsidian";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { formatList, resolvePluginLocale } from "./locale";
|
||||
|
||||
describe("locale", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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("formats lists with locale-specific separators", () => {
|
||||
expect(formatList("en", ["A", "B", "C"])).toBe("A, B, C");
|
||||
expect(formatList("zh", ["甲", "乙", "丙"])).toBe("甲、乙、丙");
|
||||
});
|
||||
});
|
||||
11
src/presentation/i18n/locale.ts
Normal file
11
src/presentation/i18n/locale.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { getLanguage } from "obsidian";
|
||||
|
||||
export type PluginLocale = "en" | "zh";
|
||||
|
||||
export function resolvePluginLocale(): PluginLocale {
|
||||
return getLanguage() === "zh" ? "zh" : "en";
|
||||
}
|
||||
|
||||
export function formatList(locale: PluginLocale, items: string[]): string {
|
||||
return items.join(locale === "zh" ? "、" : ", ");
|
||||
}
|
||||
305
src/presentation/i18n/messages/en.ts
Normal file
305
src/presentation/i18n/messages/en.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
export type MessageTree = {
|
||||
[key: string]: string | MessageTree;
|
||||
};
|
||||
|
||||
export const en = {
|
||||
commands: {
|
||||
syncCurrentFileToAnki: "Sync current file to Anki",
|
||||
syncVaultToAnki: "Sync vault to Anki",
|
||||
rebuildCardIndex: "Rebuild card index",
|
||||
clearCurrentFileSyncedCards: "Clear synced cards in current file",
|
||||
cleanupEmptyDecks: "Clean up empty decks",
|
||||
},
|
||||
settings: {
|
||||
pluginTitle: "Anki Heading Sync",
|
||||
ankiConnectUrl: {
|
||||
name: "AnkiConnect URL",
|
||||
desc: "Default is http://127.0.0.1:8765",
|
||||
placeholder: "http://127.0.0.1:8765",
|
||||
},
|
||||
qaHeadingLevel: {
|
||||
name: "QA heading level",
|
||||
desc: "Default is H4",
|
||||
},
|
||||
clozeHeadingLevel: {
|
||||
name: "Cloze heading level",
|
||||
desc: "Default is H5",
|
||||
},
|
||||
qaGroup: {
|
||||
title: "QA Group 12",
|
||||
marker: {
|
||||
name: "QA Group marker",
|
||||
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.",
|
||||
placeholder: "#anki-list",
|
||||
},
|
||||
managedNoteType: "Managed note type: {{modelName}}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.",
|
||||
managedModelContract: "Managed model contract: {{fieldCount}} fields and {{templateCount}} templates are checked automatically during sync.",
|
||||
},
|
||||
semanticQa: {
|
||||
title: "Semantic QA",
|
||||
marker: {
|
||||
name: "Semantic QA marker",
|
||||
desc: "When a QA heading ends with this hashtag marker, first-level list items with indented child content become separate cards.",
|
||||
placeholder: "#anki-list-qa",
|
||||
},
|
||||
previewTitle: "Semantic QA preview",
|
||||
triggerHeadingExample: "Trigger heading example: {{heading}}",
|
||||
previewUnavailable: "Preview unavailable. Use a trailing hashtag marker such as #anki-list-qa.",
|
||||
questionPreview: "Question preview: {{question}}",
|
||||
answerPreview: "Answer preview: {{answer}}",
|
||||
},
|
||||
syncOptions: {
|
||||
addObsidianBacklink: {
|
||||
name: "Add Obsidian backlink",
|
||||
desc: "Append a backlink to the source heading into synced cards.",
|
||||
},
|
||||
highlightsToCloze: {
|
||||
name: "Highlights to Cloze",
|
||||
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
|
||||
},
|
||||
},
|
||||
mapping: {
|
||||
title: "Note type field mappings",
|
||||
refresh: {
|
||||
name: "Refresh note types from Anki",
|
||||
button: "Refresh note types",
|
||||
},
|
||||
status: {
|
||||
idle: "Refresh note types from Anki to load the available note types.",
|
||||
loadedCount: "Loaded {{count}} note types from Anki.",
|
||||
empty: "Anki returned no note types.",
|
||||
failedLoadNoteTypes: "Failed to load note types from Anki.",
|
||||
loadedSavedMapping: "Loaded saved mapping for {{modelName}}.",
|
||||
selectedModel: "Selected {{modelName}}. Read fields from Anki to create or refresh its mapping.",
|
||||
loadedFieldsForModel: "Loaded fields for {{modelName}}. Review the suggested mapping and save it.",
|
||||
failedLoadFields: "Failed to load fields for {{modelName}}.",
|
||||
noLoadedFields: "No loaded fields for {{modelName}}. Read fields from Anki first.",
|
||||
savedMapping: "Saved mapping for {{modelName}}.",
|
||||
failedSaveMapping: "Failed to save mapping for {{modelName}}.",
|
||||
savedMappingReady: "Saved mapping is ready for {{title}}.",
|
||||
noSavedMapping: "No saved mapping for {{title}}. Read fields from Anki first.",
|
||||
},
|
||||
section: {
|
||||
basic: {
|
||||
title: "QA / Basic",
|
||||
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
|
||||
},
|
||||
cloze: {
|
||||
title: "Cloze",
|
||||
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
|
||||
},
|
||||
semanticQa: {
|
||||
title: "Semantic QA",
|
||||
description: "Choose the semantic QA note type, read its fields from Anki, then confirm the title/body mapping for child cards.",
|
||||
},
|
||||
},
|
||||
noteTypeLabel: "{{title}} note type",
|
||||
noteTypeDesc: "Loaded from Anki note types. Refresh if the latest models are not shown.",
|
||||
fieldsLabel: "{{title}} fields",
|
||||
fieldsDesc: "Load the current note type fields from Anki, then review the suggested mapping.",
|
||||
readFieldsButton: "Read fields from Anki",
|
||||
loadedFields: "Loaded fields: {{fields}}",
|
||||
loadedFieldsNone: "Loaded fields: none. Read fields from Anki first.",
|
||||
mappingLabel: "{{title}} mapping",
|
||||
mappingDesc: "Save the current field mapping for this specific note type.",
|
||||
saveMappingButton: "Save mapping",
|
||||
titleFieldLabel: "{{title}} title field",
|
||||
titleFieldDesc: "Which Anki field should receive the heading/title fragment.",
|
||||
bodyFieldLabel: "{{title}} body field",
|
||||
bodyFieldDesc: "Which Anki field should receive the body fragment.",
|
||||
mainField: {
|
||||
name: "Cloze main field",
|
||||
desc: "The selected field receives title + <br><br> + body during sync.",
|
||||
},
|
||||
selectFieldPlaceholder: "-- Select field --",
|
||||
},
|
||||
deck: {
|
||||
defaultSectionTitle: "Default deck",
|
||||
defaultDeck: {
|
||||
name: "Default deck",
|
||||
desc: "Lowest priority: used when neither a file-level deck nor a folder mapping matches.",
|
||||
},
|
||||
fileDeckSectionTitle: "File-level custom deck",
|
||||
fileDeckEnabled: {
|
||||
name: "Enable file-level custom deck",
|
||||
desc: "When enabled, a shared marker can define one deck for the whole note in YAML or body.",
|
||||
},
|
||||
marker: {
|
||||
name: "Deck marker name",
|
||||
desc: "YAML key and body marker share the same identifier. The default value is TARGET DECK.",
|
||||
},
|
||||
template: {
|
||||
name: "Default deck template",
|
||||
desc: "Only the filename variable is supported. It expands to the current file name without .md when inserted.",
|
||||
},
|
||||
insertLocation: {
|
||||
name: "Template insert location",
|
||||
desc: "Choose whether the deck template is written into YAML frontmatter or the body header area.",
|
||||
options: {
|
||||
body: "Body header",
|
||||
yaml: "YAML frontmatter",
|
||||
},
|
||||
},
|
||||
insertTemplate: {
|
||||
name: "Insert deck template into the current file",
|
||||
desc: "Write the deck declaration into the current active Markdown file using the current marker, template, and insert location.",
|
||||
button: "Insert deck template",
|
||||
},
|
||||
folderMappingSectionTitle: "Advanced: folder mapping",
|
||||
folderDeckMode: {
|
||||
name: "Folder mapping mode",
|
||||
desc: "Off / parent folders only / parent folders plus current file name.",
|
||||
options: {
|
||||
off: "Off",
|
||||
folder: "Folder-based mapping",
|
||||
folderAndFile: "Folder-and-file mapping",
|
||||
},
|
||||
},
|
||||
folderExample: "Folder-based mapping example: 数学/第一章/第一节.md -> 数学::第一章",
|
||||
folderAndFileExample: "Folder-and-file mapping example: 数学/第一章/第一节.md -> 数学::第一章::第一节",
|
||||
priorityTitle: "Final priority",
|
||||
priorityDesc: "Final deck priority: file-level custom deck > folder-mapped deck > default deck. Existing-card deck behavior stays aligned with the current sync implementation.",
|
||||
},
|
||||
scope: {
|
||||
name: "Run scope",
|
||||
summary: {
|
||||
all: "Process Markdown files across the whole vault",
|
||||
include: "Only process Markdown files in the checked folders below",
|
||||
exclude: "Process the whole vault, but skip Markdown files in the checked folders below",
|
||||
},
|
||||
option: {
|
||||
all: "All files",
|
||||
include: "Only in selected folders",
|
||||
exclude: "Exclude selected folders",
|
||||
},
|
||||
treeDescription: {
|
||||
include: "Only in selected folders: only process Markdown files in the checked folders below",
|
||||
exclude: "Exclude selected folders: process the whole vault, but skip Markdown files in the checked folders below",
|
||||
},
|
||||
loading: "Loading folders from the current vault...",
|
||||
empty: "There are no selectable folders in the current vault.",
|
||||
failedLoad: "Failed to load folders from the current vault.",
|
||||
expandFolder: "Expand {{name}}",
|
||||
collapseFolder: "Collapse {{name}}",
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
emptyDeck: {
|
||||
title: "Clean up empty decks",
|
||||
description: "Select the empty decks to delete. A deck is deleted only if it is still empty at deletion time.",
|
||||
selectAll: "Select all",
|
||||
clearAll: "Clear all",
|
||||
invert: "Invert selection",
|
||||
cancel: "Cancel",
|
||||
deleteSelected: "Delete selected empty decks",
|
||||
selectionCount: "Selected {{selected}} / {{total}} empty decks",
|
||||
},
|
||||
},
|
||||
notice: {
|
||||
invalidSettingsRestored: "Invalid plugin settings were detected. Default settings were restored in memory.",
|
||||
failedSavePluginSettings: "Failed to save plugin settings.",
|
||||
noActiveMarkdownForSync: "No active Markdown file is available for sync.",
|
||||
syncUseCaseNotInitialized: "Sync use case is not initialized.",
|
||||
currentFileSyncFailed: "Current file sync failed.",
|
||||
vaultSyncFailed: "Vault sync failed.",
|
||||
rebuildFailed: "Card index rebuild failed.",
|
||||
noActiveMarkdownForDeckTemplateInsertion: "No active Markdown file is available for deck template insertion.",
|
||||
vaultGatewayNotInitialized: "Vault gateway is not initialized.",
|
||||
markdownFileNotFound: "Markdown file not found: {{filePath}}",
|
||||
deckTemplateAlreadyCurrent: "The deck template in the current file is already up to date.",
|
||||
deckTemplateInserted: "Inserted deck template into the current file: {{deck}}",
|
||||
deckTemplateInsertionFailed: "Deck template insertion failed.",
|
||||
noActiveMarkdownForReset: "No active Markdown file is available for reset.",
|
||||
clearCurrentFileUseCaseNotInitialized: "Clear-current-file use case is not initialized.",
|
||||
noTrackedCardsInCurrentFile: "There are no synced cards in the current file.",
|
||||
clearCurrentFileFailed: "Clear current file synced cards failed.",
|
||||
cleanupEmptyDecksUseCaseNotInitialized: "Empty-deck cleanup use case is not initialized.",
|
||||
noEmptyDeckCandidates: "No empty decks were found for cleanup.",
|
||||
cleanupEmptyDecksCancelled: "Empty-deck cleanup was cancelled.",
|
||||
cleanupEmptyDecksFailed: "Cleanup empty decks failed.",
|
||||
summary: {
|
||||
currentFileSync: "Current file sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
|
||||
vaultSync: "Vault sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
|
||||
rebuild: "Card index rebuild completed: files {{scannedFiles}}, cards {{scannedCards}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, rewritten markers {{rewrittenMarkers}}, skipped {{skippedUnchangedCards}}.",
|
||||
clearCurrentFile: "Current file synced cards cleared: tracked cards {{trackedCards}}, tracked groups {{trackedGroups}}, deleted notes {{deletedNotes}}, removed markers {{removedMarkers}}, removed ID markers {{removedCardMarkers}}, removed GI markers {{removedGroupMarkers}}, deleted local records {{deletedLocalRecords}}.",
|
||||
cleanupEmptyDecks: "Empty-deck cleanup completed: candidates {{candidateCount}}, selected {{selectedCount}}, deleted {{deletedCount}}, skipped {{skippedCount}}.",
|
||||
markerWriteConflicts: "Marker write conflicts: {{files}}.",
|
||||
warningsCount: "Warnings: {{count}}.",
|
||||
markerRemovalConflicts: "Marker removal conflicts: {{files}}.",
|
||||
failures: "Failures: {{items}}.",
|
||||
skippedDecks: "Skipped decks: {{decks}}.",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
currentFileOutOfScope: "The current file is outside the plugin scope: {{filePath}}",
|
||||
deck: {
|
||||
emptyName: "Deck name cannot be empty. Check the file-level deck declaration or default deck setting.",
|
||||
},
|
||||
settings: {
|
||||
headingLevelsRange: "Heading levels must be integers between 1 and 6.",
|
||||
headingLevelsDifferent: "QA and Cloze heading levels must be different.",
|
||||
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.",
|
||||
clozeNoteTypeRequired: "Cloze note type is required.",
|
||||
semanticQaMarkerRequired: "Semantic QA marker is required.",
|
||||
semanticQaMarkerInvalid: "Semantic QA marker must be a hashtag-style token like #anki-list-qa.",
|
||||
qaGroupMarkerConflict: "QA Group marker must be different from Semantic QA marker.",
|
||||
semanticQaNoteTypeRequired: "Semantic QA note type is required.",
|
||||
defaultDeckRequired: "Default deck is required.",
|
||||
fileDeckEnabledBoolean: "File deck enabled 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.",
|
||||
folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.",
|
||||
scopeModeInvalid: "Scope mode must be one of all, include, or exclude.",
|
||||
includeFoldersArray: "Include folders must be an array.",
|
||||
includeFoldersStrings: "Include folders must only contain strings.",
|
||||
excludeFoldersArray: "Exclude folders must be an array.",
|
||||
excludeFoldersStrings: "Exclude folders must only contain strings.",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL is required.",
|
||||
noteFieldMappingsObject: "Note field mappings must be an object.",
|
||||
noteFieldMappingsCardType: "Note field mappings must use a supported card type.",
|
||||
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.",
|
||||
},
|
||||
noteFieldMapping: {
|
||||
missingSavedMapping: {
|
||||
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
cloze: "No saved field mapping found for cloze note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
semanticQa: "No saved field mapping found for semantic QA note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
},
|
||||
incompleteSavedMapping: {
|
||||
basic: "Saved field mapping for basic note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
|
||||
semanticQa: "Saved field mapping for semantic QA note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
|
||||
cloze: "Saved field mapping for cloze note type \"{{modelName}}\" is incomplete. Open plugin settings and save the main field.",
|
||||
},
|
||||
titleBodyMustDiffer: {
|
||||
basic: "Basic note type \"{{modelName}}\" must use different title and body fields.",
|
||||
semanticQa: "Semantic QA note type \"{{modelName}}\" must use different title and body fields.",
|
||||
},
|
||||
stale: "Saved field mapping for note type \"{{modelName}}\" is stale because these fields no longer exist in Anki: {{fields}}. Open plugin settings and read fields from Anki again.",
|
||||
clozeIncompatible: "Cloze note type \"{{modelName}}\" is not cloze-compatible in Anki.",
|
||||
},
|
||||
writeBack: {
|
||||
summary: "Markdown marker write-back failed for {{fileCount}} file(s).",
|
||||
markdownFileNotFound: "Markdown file was not found.",
|
||||
missingSourceContent: "Scanned source content is missing for marker write-back.",
|
||||
noteIdMissing: "Cannot write a marker because note ID is missing for the block at line {{blockStartLine}}.",
|
||||
},
|
||||
markerRemoval: {
|
||||
markdownFileNotFound: "Markdown file was not found.",
|
||||
},
|
||||
},
|
||||
warnings: {
|
||||
deck: {
|
||||
conflictYamlBody: "Conflicting {{marker}} declarations were found in YAML and body. This sync used the YAML value.",
|
||||
multipleBodyDeclarations: "Multiple {{marker}} declarations were found in the file. This sync used the first body declaration.",
|
||||
invalidFolderSegment: "A folder or file name contains ::, so folder-based deck mapping was skipped and the default deck was used.",
|
||||
fallbackDefault: "No explicit deck was found and a folder-based deck could not be generated, so the default deck was used.",
|
||||
},
|
||||
},
|
||||
} satisfies MessageTree;
|
||||
|
||||
export default en;
|
||||
303
src/presentation/i18n/messages/zh.ts
Normal file
303
src/presentation/i18n/messages/zh.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import type { en } from "./en";
|
||||
|
||||
export const zh = {
|
||||
commands: {
|
||||
syncCurrentFileToAnki: "同步当前文件到 Anki",
|
||||
syncVaultToAnki: "同步全库到 Anki",
|
||||
rebuildCardIndex: "重建卡片索引",
|
||||
clearCurrentFileSyncedCards: "清空当前文件已同步卡片",
|
||||
cleanupEmptyDecks: "清理空牌组",
|
||||
},
|
||||
settings: {
|
||||
pluginTitle: "Anki Heading Sync",
|
||||
ankiConnectUrl: {
|
||||
name: "AnkiConnect URL",
|
||||
desc: "默认是 http://127.0.0.1:8765",
|
||||
placeholder: "http://127.0.0.1:8765",
|
||||
},
|
||||
qaHeadingLevel: {
|
||||
name: "QA 标题层级",
|
||||
desc: "默认是 H4",
|
||||
},
|
||||
clozeHeadingLevel: {
|
||||
name: "Cloze 标题层级",
|
||||
desc: "默认是 H5",
|
||||
},
|
||||
qaGroup: {
|
||||
title: "QA Group 12",
|
||||
marker: {
|
||||
name: "QA Group 标记",
|
||||
desc: "当 QA 标题以这个 hashtag 标记结尾时,整个标题块会作为一张 ObsiAnki QA Group 12 笔记同步。",
|
||||
placeholder: "#anki-list",
|
||||
},
|
||||
managedNoteType: "托管笔记类型:{{modelName}}。QA Group 同步会直接写入 Stem / GroupId / Src / S01..S12。如果你想在其他同步路径里复用它,它也会出现在下面的字段映射面板中。",
|
||||
managedModelContract: "托管模型约束:同步时会自动检查 {{fieldCount}} 个字段和 {{templateCount}} 个模板。",
|
||||
},
|
||||
semanticQa: {
|
||||
title: "语义 QA",
|
||||
marker: {
|
||||
name: "语义 QA 标记",
|
||||
desc: "当 QA 标题以这个 hashtag 标记结尾时,带缩进子内容的一级列表项会拆分成独立卡片。",
|
||||
placeholder: "#anki-list-qa",
|
||||
},
|
||||
previewTitle: "语义 QA 预览",
|
||||
triggerHeadingExample: "触发标题示例:{{heading}}",
|
||||
previewUnavailable: "当前无法生成预览。请使用类似 #anki-list-qa 的尾随 hashtag 标记。",
|
||||
questionPreview: "问题预览:{{question}}",
|
||||
answerPreview: "答案预览:{{answer}}",
|
||||
},
|
||||
syncOptions: {
|
||||
addObsidianBacklink: {
|
||||
name: "添加 Obsidian 回链",
|
||||
desc: "把来源标题的回链追加到同步后的卡片中。",
|
||||
},
|
||||
highlightsToCloze: {
|
||||
name: "高亮转 Cloze",
|
||||
desc: "把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。",
|
||||
},
|
||||
},
|
||||
mapping: {
|
||||
title: "笔记类型字段映射",
|
||||
refresh: {
|
||||
name: "从 Anki 刷新笔记类型",
|
||||
button: "刷新笔记类型",
|
||||
},
|
||||
status: {
|
||||
idle: "请先从 Anki 刷新笔记类型,以加载可用的笔记类型列表。",
|
||||
loadedCount: "已从 Anki 加载 {{count}} 个笔记类型。",
|
||||
empty: "Anki 没有返回任何笔记类型。",
|
||||
failedLoadNoteTypes: "从 Anki 加载笔记类型失败。",
|
||||
loadedSavedMapping: "已加载 {{modelName}} 的已保存映射。",
|
||||
selectedModel: "已选择 {{modelName}}。请从 Anki 读取字段以创建或刷新它的映射。",
|
||||
loadedFieldsForModel: "已加载 {{modelName}} 的字段。请检查建议映射后保存。",
|
||||
failedLoadFields: "加载 {{modelName}} 的字段失败。",
|
||||
noLoadedFields: "{{modelName}} 还没有已加载字段。请先从 Anki 读取字段。",
|
||||
savedMapping: "已保存 {{modelName}} 的映射。",
|
||||
failedSaveMapping: "保存 {{modelName}} 的映射失败。",
|
||||
savedMappingReady: "{{title}} 的已保存映射已就绪。",
|
||||
noSavedMapping: "{{title}} 还没有已保存映射。请先从 Anki 读取字段。",
|
||||
},
|
||||
section: {
|
||||
basic: {
|
||||
title: "QA / 基础卡",
|
||||
description: "选择 QA 笔记类型,从 Anki 读取字段,然后确认标题/正文映射。",
|
||||
},
|
||||
cloze: {
|
||||
title: "Cloze",
|
||||
description: "选择 Cloze 笔记类型,从 Anki 读取字段,然后确认主字段映射。",
|
||||
},
|
||||
semanticQa: {
|
||||
title: "语义 QA",
|
||||
description: "选择语义 QA 笔记类型,从 Anki 读取字段,然后确认子卡片的标题/正文映射。",
|
||||
},
|
||||
},
|
||||
noteTypeLabel: "{{title}} 笔记类型",
|
||||
noteTypeDesc: "从 Anki 的笔记类型列表加载。如果最新模型没有显示,请先刷新。",
|
||||
fieldsLabel: "{{title}} 字段",
|
||||
fieldsDesc: "从 Anki 读取当前笔记类型的字段,然后检查建议映射。",
|
||||
readFieldsButton: "从 Anki 读取字段",
|
||||
loadedFields: "已加载字段:{{fields}}",
|
||||
loadedFieldsNone: "已加载字段:无。请先从 Anki 读取字段。",
|
||||
mappingLabel: "{{title}} 映射",
|
||||
mappingDesc: "保存当前笔记类型的字段映射。",
|
||||
saveMappingButton: "保存映射",
|
||||
titleFieldLabel: "{{title}} 标题字段",
|
||||
titleFieldDesc: "选择接收标题片段的 Anki 字段。",
|
||||
bodyFieldLabel: "{{title}} 正文字段",
|
||||
bodyFieldDesc: "选择接收正文片段的 Anki 字段。",
|
||||
mainField: {
|
||||
name: "Cloze 主字段",
|
||||
desc: "同步时,所选字段会接收 标题 + <br><br> + 正文。",
|
||||
},
|
||||
selectFieldPlaceholder: "-- 选择字段 --",
|
||||
},
|
||||
deck: {
|
||||
defaultSectionTitle: "默认牌组",
|
||||
defaultDeck: {
|
||||
name: "默认牌组",
|
||||
desc: "优先级最低:当文件级 deck 与文件夹映射都未命中时使用。",
|
||||
},
|
||||
fileDeckSectionTitle: "文件级自定义牌组",
|
||||
fileDeckEnabled: {
|
||||
name: "开启文件级自定义牌组",
|
||||
desc: "开启后,可在 YAML 或正文中用统一 marker 为整篇笔记指定一个 deck。",
|
||||
},
|
||||
marker: {
|
||||
name: "牌组识别名",
|
||||
desc: "YAML key 与正文 marker 共用同一个识别名。默认值是 TARGET DECK。",
|
||||
},
|
||||
template: {
|
||||
name: "默认牌组模板",
|
||||
desc: "只支持 filename 变量。插入时会展开为当前文件名(不含 .md)。",
|
||||
},
|
||||
insertLocation: {
|
||||
name: "模板插入位置",
|
||||
desc: "选择将 deck 模板写入 YAML frontmatter 还是正文前部。",
|
||||
options: {
|
||||
body: "正文前部",
|
||||
yaml: "YAML frontmatter",
|
||||
},
|
||||
},
|
||||
insertTemplate: {
|
||||
name: "向当前文件插入 deck 模板",
|
||||
desc: "按当前 marker、模板和插入位置,把 deck 声明写入当前活动 Markdown 文件。",
|
||||
button: "插入 deck 模板",
|
||||
},
|
||||
folderMappingSectionTitle: "高级:文件夹映射",
|
||||
folderDeckMode: {
|
||||
name: "文件夹映射模式",
|
||||
desc: "关闭 / 仅父文件夹 / 父文件夹加当前文件名。",
|
||||
options: {
|
||||
off: "关闭",
|
||||
folder: "文件夹级映射",
|
||||
folderAndFile: "文件夹及文件名级映射",
|
||||
},
|
||||
},
|
||||
folderExample: "文件夹级映射示例:数学/第一章/第一节.md -> 数学::第一章",
|
||||
folderAndFileExample: "文件夹及文件名级映射示例:数学/第一章/第一节.md -> 数学::第一章::第一节",
|
||||
priorityTitle: "最终优先级说明",
|
||||
priorityDesc: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡的 deck 行为保持与当前同步实现一致。",
|
||||
},
|
||||
scope: {
|
||||
name: "运行范围",
|
||||
summary: {
|
||||
all: "处理整个 vault 中的 Markdown 文件",
|
||||
include: "仅处理下方勾选文件夹中的 Markdown 文件",
|
||||
exclude: "处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件",
|
||||
},
|
||||
option: {
|
||||
all: "全部文件",
|
||||
include: "仅在指定文件夹",
|
||||
exclude: "排除指定文件夹",
|
||||
},
|
||||
treeDescription: {
|
||||
include: "仅在指定文件夹:只处理下方勾选文件夹中的 Markdown 文件",
|
||||
exclude: "排除指定文件夹:处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件",
|
||||
},
|
||||
loading: "正在读取当前 vault 文件夹...",
|
||||
empty: "当前 vault 中没有可选文件夹。",
|
||||
failedLoad: "读取当前 vault 文件夹失败。",
|
||||
expandFolder: "展开 {{name}}",
|
||||
collapseFolder: "收起 {{name}}",
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
emptyDeck: {
|
||||
title: "清理空牌组",
|
||||
description: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。",
|
||||
selectAll: "全选全部",
|
||||
clearAll: "全部不选",
|
||||
invert: "反选",
|
||||
cancel: "取消",
|
||||
deleteSelected: "删除所选空牌组",
|
||||
selectionCount: "已选 {{selected}} / 共 {{total}} 个空牌组",
|
||||
},
|
||||
},
|
||||
notice: {
|
||||
invalidSettingsRestored: "检测到无效的插件设置,已在内存中恢复默认设置。",
|
||||
failedSavePluginSettings: "保存插件设置失败。",
|
||||
noActiveMarkdownForSync: "当前没有可用于同步的活动 Markdown 文件。",
|
||||
syncUseCaseNotInitialized: "同步用例尚未初始化。",
|
||||
currentFileSyncFailed: "当前文件同步失败。",
|
||||
vaultSyncFailed: "全库同步失败。",
|
||||
rebuildFailed: "卡片索引重建失败。",
|
||||
noActiveMarkdownForDeckTemplateInsertion: "当前没有可用于插入 deck 模板的活动 Markdown 文件。",
|
||||
vaultGatewayNotInitialized: "Vault 网关尚未初始化。",
|
||||
markdownFileNotFound: "未找到 Markdown 文件:{{filePath}}",
|
||||
deckTemplateAlreadyCurrent: "当前文件中的 deck 模板已是最新,无需重复写入。",
|
||||
deckTemplateInserted: "已向当前文件写入 deck 模板:{{deck}}",
|
||||
deckTemplateInsertionFailed: "插入 deck 模板失败。",
|
||||
noActiveMarkdownForReset: "当前没有可用于重置的活动 Markdown 文件。",
|
||||
clearCurrentFileUseCaseNotInitialized: "清空当前文件用例尚未初始化。",
|
||||
noTrackedCardsInCurrentFile: "当前文件没有已同步卡片。",
|
||||
clearCurrentFileFailed: "清空当前文件已同步卡片失败。",
|
||||
cleanupEmptyDecksUseCaseNotInitialized: "空牌组清理用例尚未初始化。",
|
||||
noEmptyDeckCandidates: "未发现可清理的空牌组。",
|
||||
cleanupEmptyDecksCancelled: "已取消空牌组清理。",
|
||||
cleanupEmptyDecksFailed: "清理空牌组失败。",
|
||||
summary: {
|
||||
currentFileSync: "当前文件同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
|
||||
vaultSync: "全库同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
|
||||
rebuild: "卡片索引重建完成:文件 {{scannedFiles}},卡片 {{scannedCards}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},重写标记 {{rewrittenMarkers}},跳过 {{skippedUnchangedCards}}。",
|
||||
clearCurrentFile: "当前文件已同步卡片清空完成:已跟踪卡片 {{trackedCards}},已跟踪分组 {{trackedGroups}},删除笔记 {{deletedNotes}},移除标记 {{removedMarkers}},移除 ID 标记 {{removedCardMarkers}},移除 GI 标记 {{removedGroupMarkers}},删除本地记录 {{deletedLocalRecords}}。",
|
||||
cleanupEmptyDecks: "空牌组清理完成:候选 {{candidateCount}},已选 {{selectedCount}},已删 {{deletedCount}},跳过 {{skippedCount}}。",
|
||||
markerWriteConflicts: "标记写回冲突:{{files}}。",
|
||||
warningsCount: "警告:{{count}}。",
|
||||
markerRemovalConflicts: "标记移除冲突:{{files}}。",
|
||||
failures: "失败项:{{items}}。",
|
||||
skippedDecks: "跳过的牌组:{{decks}}。",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
currentFileOutOfScope: "当前文件不在插件作用范围内:{{filePath}}",
|
||||
deck: {
|
||||
emptyName: "Deck 不能为空,请检查文件级 deck 声明或默认 deck 设置。",
|
||||
},
|
||||
settings: {
|
||||
headingLevelsRange: "标题层级必须是 1 到 6 之间的整数。",
|
||||
headingLevelsDifferent: "QA 和 Cloze 的标题层级必须不同。",
|
||||
qaNoteTypeRequired: "QA 笔记类型不能为空。",
|
||||
qaGroupMarkerRequired: "QA Group 标记不能为空。",
|
||||
qaGroupMarkerInvalid: "QA Group 标记必须是类似 #anki-list 的 hashtag 样式 token。",
|
||||
clozeNoteTypeRequired: "Cloze 笔记类型不能为空。",
|
||||
semanticQaMarkerRequired: "语义 QA 标记不能为空。",
|
||||
semanticQaMarkerInvalid: "语义 QA 标记必须是类似 #anki-list-qa 的 hashtag 样式 token。",
|
||||
qaGroupMarkerConflict: "QA Group 标记必须和语义 QA 标记不同。",
|
||||
semanticQaNoteTypeRequired: "语义 QA 笔记类型不能为空。",
|
||||
defaultDeckRequired: "默认牌组不能为空。",
|
||||
fileDeckEnabledBoolean: "文件级 deck 开关必须是布尔值。",
|
||||
fileDeckMarkerString: "文件级 deck marker 必须是字符串。",
|
||||
fileDeckTemplateString: "文件级 deck 模板必须是字符串。",
|
||||
fileDeckInsertLocationInvalid: "文件级 deck 模板插入位置只能是 yaml 或 body。",
|
||||
folderDeckModeInvalid: "文件夹 deck 模式只能是 off、folder 或 folder-and-file。",
|
||||
scopeModeInvalid: "运行范围模式只能是 all、include 或 exclude。",
|
||||
includeFoldersArray: "包含文件夹必须是数组。",
|
||||
includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
|
||||
excludeFoldersArray: "排除文件夹必须是数组。",
|
||||
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
|
||||
noteFieldMappingsObject: "字段映射必须是对象。",
|
||||
noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。",
|
||||
noteFieldMappingsModelName: "字段映射必须包含模型名称。",
|
||||
noteFieldMappingsLoadedFieldNames: "字段映射必须包含已加载字段名。",
|
||||
noteFieldMappingsLoadedAt: "字段映射必须包含已加载时间戳。",
|
||||
},
|
||||
noteFieldMapping: {
|
||||
missingSavedMapping: {
|
||||
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
cloze: "找不到 Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
semanticQa: "找不到语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
},
|
||||
incompleteSavedMapping: {
|
||||
basic: "基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
|
||||
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
|
||||
cloze: "Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存主字段。",
|
||||
},
|
||||
titleBodyMustDiffer: {
|
||||
basic: "基础卡笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
|
||||
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
|
||||
},
|
||||
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
|
||||
clozeIncompatible: "Cloze 笔记类型 \"{{modelName}}\" 在 Anki 中不是 cloze 兼容模型。",
|
||||
},
|
||||
writeBack: {
|
||||
summary: "Markdown 标记写回失败,共 {{fileCount}} 个文件。",
|
||||
markdownFileNotFound: "Markdown 文件不存在。",
|
||||
missingSourceContent: "缺少用于写回标记的扫描源码内容。",
|
||||
noteIdMissing: "无法为第 {{blockStartLine}} 行所在块写入标记,因为缺少 note ID。",
|
||||
},
|
||||
markerRemoval: {
|
||||
markdownFileNotFound: "Markdown 文件不存在。",
|
||||
},
|
||||
},
|
||||
warnings: {
|
||||
deck: {
|
||||
conflictYamlBody: "检测到 YAML 和正文中的 {{marker}} 声明互相冲突,本次同步已使用 YAML 的值。",
|
||||
multipleBodyDeclarations: "检测到文件中存在多个 {{marker}} 声明,本次同步只使用第一个正文声明。",
|
||||
invalidFolderSegment: "检测到文件夹名或文件名包含 ::,已跳过文件夹映射并回退到默认 deck。",
|
||||
fallbackDefault: "该文件没有显式 deck,且无法生成文件夹映射 deck,已回退到默认 deck。",
|
||||
},
|
||||
},
|
||||
} satisfies typeof en;
|
||||
|
||||
export default zh;
|
||||
|
|
@ -2,10 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
const {
|
||||
FakeButtonComponent,
|
||||
getLanguageMock,
|
||||
FakeModal,
|
||||
FakeSetting,
|
||||
FakeToggleComponent,
|
||||
} = vi.hoisted(() => {
|
||||
const hoistedGetLanguage = vi.fn(() => "en");
|
||||
|
||||
class HoistedFakeElement {
|
||||
public children: HoistedFakeElement[] = [];
|
||||
public classes: string[] = [];
|
||||
|
|
@ -148,10 +151,12 @@ const {
|
|||
FakeModal: HoistedFakeModal,
|
||||
FakeSetting: HoistedFakeSetting,
|
||||
FakeToggleComponent: HoistedFakeToggleComponent,
|
||||
getLanguageMock: hoistedGetLanguage,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("obsidian", () => ({
|
||||
getLanguage: getLanguageMock,
|
||||
Modal: FakeModal,
|
||||
Setting: FakeSetting,
|
||||
}));
|
||||
|
|
@ -183,24 +188,46 @@ 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");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const contentEl = getFakeContentEl(modal);
|
||||
const buttons = getFooterButtons(contentEl);
|
||||
|
||||
expect(contentEl.elements.map((element) => element.textContent)).toContain("Clean up empty decks");
|
||||
expect(contentEl.elements.map((element) => element.textContent)).toContain("Select the empty decks to delete. A deck is deleted only if it is still empty at deletion time.");
|
||||
expect(buttons.map((button) => button.text)).toEqual([
|
||||
"Select all",
|
||||
"Clear all",
|
||||
"Invert selection",
|
||||
"Cancel",
|
||||
"Delete selected empty decks",
|
||||
]);
|
||||
expect(getFooterCountText(contentEl)).toBe("Selected 0 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("starts with zero selected count and a disabled delete button", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const buttons = getFooterButtons(getFakeContentEl(modal));
|
||||
const deleteButton = buttons.find((button) => button.text === "删除所选空牌组");
|
||||
const deleteButton = buttons.find((button) => button.text === "Delete selected empty decks");
|
||||
|
||||
expect(deleteButton?.disabled).toBe(true);
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("已选 0 / 共 3 个空牌组");
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("Selected 0 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("selects every candidate when the select-all button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
const selectionPromise = modal.openAndGetSelection();
|
||||
const buttons = getFooterButtons(getFakeContentEl(modal));
|
||||
const selectAllButton = buttons.find((button) => button.text === "全选全部");
|
||||
const deleteButton = buttons.find((button) => button.text === "删除所选空牌组");
|
||||
const selectAllButton = buttons.find((button) => button.text === "Select all");
|
||||
const deleteButton = buttons.find((button) => button.text === "Delete selected empty decks");
|
||||
|
||||
expect(selectAllButton).toBeDefined();
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
|
@ -209,7 +236,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
|
||||
expect(getDeckToggles(getFakeContentEl(modal)).map((toggle) => toggle.value)).toEqual([true, true, true]);
|
||||
expect(deleteButton?.disabled).toBe(false);
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("已选 3 / 共 3 个空牌组");
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("Selected 3 / 3 empty decks");
|
||||
|
||||
await deleteButton?.click();
|
||||
|
||||
|
|
@ -217,58 +244,62 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("clears every candidate when the clear-all button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const buttons = getFooterButtons(getFakeContentEl(modal));
|
||||
const selectAllButton = buttons.find((button) => button.text === "全选全部");
|
||||
const clearAllButton = buttons.find((button) => button.text === "全部不选");
|
||||
const deleteButton = buttons.find((button) => button.text === "删除所选空牌组");
|
||||
const selectAllButton = buttons.find((button) => button.text === "Select all");
|
||||
const clearAllButton = buttons.find((button) => button.text === "Clear all");
|
||||
const deleteButton = buttons.find((button) => button.text === "Delete selected empty decks");
|
||||
|
||||
await selectAllButton?.click();
|
||||
await clearAllButton?.click();
|
||||
|
||||
expect(getDeckToggles(getFakeContentEl(modal)).map((toggle) => toggle.value)).toEqual([false, false, false]);
|
||||
expect(deleteButton?.disabled).toBe(true);
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("已选 0 / 共 3 个空牌组");
|
||||
expect(getFooterCountText(getFakeContentEl(modal))).toBe("Selected 0 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("inverts selected candidates when the invert button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const contentEl = getFakeContentEl(modal);
|
||||
const toggles = getDeckToggles(contentEl);
|
||||
const buttons = getFooterButtons(contentEl);
|
||||
const invertButton = buttons.find((button) => button.text === "反选");
|
||||
const deleteButton = buttons.find((button) => button.text === "删除所选空牌组");
|
||||
const invertButton = buttons.find((button) => button.text === "Invert selection");
|
||||
const deleteButton = buttons.find((button) => button.text === "Delete selected empty decks");
|
||||
|
||||
await toggles[0]?.triggerChange(true);
|
||||
await invertButton?.click();
|
||||
|
||||
expect(getDeckToggles(contentEl).map((toggle) => toggle.value)).toEqual([false, true, true]);
|
||||
expect(deleteButton?.disabled).toBe(false);
|
||||
expect(getFooterCountText(contentEl)).toBe("已选 2 / 共 3 个空牌组");
|
||||
expect(getFooterCountText(contentEl)).toBe("Selected 2 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("enables and disables the delete button as manual selection changes", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const contentEl = getFakeContentEl(modal);
|
||||
const toggle = getDeckToggles(contentEl)[0];
|
||||
const deleteButton = getFooterButtons(contentEl).find((button) => button.text === "删除所选空牌组");
|
||||
const deleteButton = getFooterButtons(contentEl).find((button) => button.text === "Delete selected empty decks");
|
||||
|
||||
await toggle?.triggerChange(true);
|
||||
expect(deleteButton?.disabled).toBe(false);
|
||||
expect(getFooterCountText(contentEl)).toBe("已选 1 / 共 1 个空牌组");
|
||||
expect(getFooterCountText(contentEl)).toBe("Selected 1 / 1 empty decks");
|
||||
|
||||
await toggle?.triggerChange(false);
|
||||
expect(deleteButton?.disabled).toBe(true);
|
||||
expect(getFooterCountText(contentEl)).toBe("已选 0 / 共 1 个空牌组");
|
||||
expect(getFooterCountText(contentEl)).toBe("Selected 0 / 1 empty decks");
|
||||
});
|
||||
|
||||
it("returns null when cancel is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
|
||||
|
||||
const selectionPromise = modal.openAndGetSelection();
|
||||
|
|
@ -278,4 +309,20 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
|
||||
await expect(selectionPromise).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("renders selection text in Simplified Chinese when Obsidian language is zh", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
const contentEl = getFakeContentEl(modal);
|
||||
const toggle = getDeckToggles(contentEl)[0];
|
||||
|
||||
expect(contentEl.elements.map((element) => element.textContent)).toContain("清理空牌组");
|
||||
expect(contentEl.elements.map((element) => element.textContent)).toContain("勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。");
|
||||
expect(getFooterCountText(contentEl)).toBe("已选 0 / 共 2 个空牌组");
|
||||
|
||||
await toggle?.triggerChange(true);
|
||||
expect(getFooterCountText(contentEl)).toBe("已选 1 / 共 2 个空牌组");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Modal, Setting, type App, type ButtonComponent, type ToggleComponent } from "obsidian";
|
||||
|
||||
import { t } from "@/presentation/i18n";
|
||||
|
||||
export class EmptyDeckSelectionModal extends Modal {
|
||||
private readonly deckToggles = new Map<string, ToggleComponent>();
|
||||
private readonly selectedDeckNames = new Set<string>();
|
||||
|
|
@ -22,8 +24,8 @@ export class EmptyDeckSelectionModal extends Modal {
|
|||
override onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl("h2", { text: "清理空牌组" });
|
||||
contentEl.createEl("p", { text: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。" });
|
||||
contentEl.createEl("h2", { text: t("modal.emptyDeck.title") });
|
||||
contentEl.createEl("p", { text: t("modal.emptyDeck.description") });
|
||||
|
||||
for (const deckName of this.candidateDeckNames) {
|
||||
new Setting(contentEl)
|
||||
|
|
@ -43,28 +45,28 @@ export class EmptyDeckSelectionModal extends Modal {
|
|||
|
||||
const footerSetting = new Setting(contentEl)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("全选全部").onClick(() => {
|
||||
button.setButtonText(t("modal.emptyDeck.selectAll")).onClick(() => {
|
||||
this.selectAllDecks();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText("全部不选").onClick(() => {
|
||||
button.setButtonText(t("modal.emptyDeck.clearAll")).onClick(() => {
|
||||
this.clearSelectedDecks();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText("反选").onClick(() => {
|
||||
button.setButtonText(t("modal.emptyDeck.invert")).onClick(() => {
|
||||
this.invertSelectedDecks();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText("取消").onClick(() => {
|
||||
button.setButtonText(t("modal.emptyDeck.cancel")).onClick(() => {
|
||||
this.finish(null);
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
this.deleteButton = button;
|
||||
button.setButtonText("删除所选空牌组").setCta().onClick(() => {
|
||||
button.setButtonText(t("modal.emptyDeck.deleteSelected")).setCta().onClick(() => {
|
||||
this.finish(Array.from(this.selectedDeckNames));
|
||||
});
|
||||
});
|
||||
|
|
@ -142,6 +144,9 @@ export class EmptyDeckSelectionModal extends Modal {
|
|||
}
|
||||
|
||||
private getSelectionCountText(): string {
|
||||
return `已选 ${this.selectedDeckNames.size} / 共 ${this.candidateDeckNames.length} 个空牌组`;
|
||||
return t("modal.emptyDeck.selectionCount", {
|
||||
selected: this.selectedDeckNames.size,
|
||||
total: this.candidateDeckNames.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
src/presentation/notices/NoticeService.test.ts
Normal file
144
src/presentation/notices/NoticeService.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getLanguageMock, noticeRecords } = vi.hoisted(() => {
|
||||
const hoistedGetLanguage = vi.fn(() => "en");
|
||||
const hoistedNoticeRecords: Array<{ message: string; timeout?: number }> = [];
|
||||
|
||||
class HoistedNotice {
|
||||
constructor(message: string, timeout?: number) {
|
||||
hoistedNoticeRecords.push({ message, timeout });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getLanguageMock: hoistedGetLanguage,
|
||||
noticeRecords: hoistedNoticeRecords,
|
||||
HoistedNotice,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("obsidian", () => ({
|
||||
getLanguage: getLanguageMock,
|
||||
Notice: class {
|
||||
constructor(message: string, timeout?: number) {
|
||||
noticeRecords.push({ message, timeout });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import type { ClearCurrentFileSyncedCardsResult, CleanupEmptyDecksResult } from "@/application/use-cases/cleanupResetTypes";
|
||||
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
||||
|
||||
import { NoticeService } from "./NoticeService";
|
||||
|
||||
function createManualSyncResult(overrides: Partial<ManualSyncResult> = {}): ManualSyncResult {
|
||||
return {
|
||||
scannedFiles: 3,
|
||||
scannedCards: 8,
|
||||
created: 2,
|
||||
updated: 1,
|
||||
migratedDecks: 1,
|
||||
orphaned: 0,
|
||||
uploadedMedia: 4,
|
||||
skippedUnchangedCards: 5,
|
||||
rewrittenMarkers: 2,
|
||||
markerWriteConflictFiles: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createClearResult(overrides: Partial<ClearCurrentFileSyncedCardsResult> = {}): ClearCurrentFileSyncedCardsResult {
|
||||
return {
|
||||
trackedCards: 2,
|
||||
trackedGroups: 1,
|
||||
deletedNotes: 3,
|
||||
removedMarkers: 4,
|
||||
removedCardMarkers: 2,
|
||||
removedGroupMarkers: 2,
|
||||
deletedLocalRecords: 3,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCleanupResult(overrides: Partial<CleanupEmptyDecksResult> = {}): CleanupEmptyDecksResult {
|
||||
return {
|
||||
candidateCount: 4,
|
||||
selectedCount: 3,
|
||||
deletedCount: 2,
|
||||
deletedDeckNames: ["Deck A", "Deck B"],
|
||||
skippedDeckNames: ["Deck C"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NoticeService", () => {
|
||||
beforeEach(() => {
|
||||
noticeRecords.length = 0;
|
||||
getLanguageMock.mockReset();
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
});
|
||||
|
||||
it("renders sync summary and deck warnings in English", () => {
|
||||
const service = new NoticeService();
|
||||
|
||||
service.showSyncSummary("currentFile", createManualSyncResult({
|
||||
markerWriteConflictFiles: ["notes/a.md", "notes/b.md"],
|
||||
warnings: [
|
||||
{ filePath: "notes/a.md", code: "deck_conflict_yaml_body", params: { marker: "TARGET DECK" } },
|
||||
{ filePath: "notes/b.md", code: "deck_fallback_default" },
|
||||
],
|
||||
}));
|
||||
|
||||
expect(noticeRecords.map((entry) => entry.message)).toEqual([
|
||||
"Current file sync completed: files 3, cards 8, created 2, updated 1, migrated decks 1, orphaned 0, media 4, skipped 5. Marker write conflicts: notes/a.md, notes/b.md. Warnings: 2.",
|
||||
"Conflicting TARGET DECK declarations were found in YAML and body. This sync used the YAML value.",
|
||||
"No explicit deck was found and a folder-based deck could not be generated, so the default deck was used.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders rebuild summary and warnings in Simplified Chinese", () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
const service = new NoticeService();
|
||||
|
||||
service.showRebuildSummary(createManualSyncResult({
|
||||
warnings: [{ filePath: "notes/a.md", code: "deck_multiple_body_declarations", params: { marker: "MY DECK" } }],
|
||||
}));
|
||||
|
||||
expect(noticeRecords.map((entry) => entry.message)).toEqual([
|
||||
"卡片索引重建完成:文件 3,卡片 8,迁移牌组 1,孤儿 0,重写标记 2,跳过 5。 警告:1。",
|
||||
"检测到文件中存在多个 MY DECK 声明,本次同步只使用第一个正文声明。",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders clear-current-file summaries with localized failures", () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
const service = new NoticeService();
|
||||
|
||||
service.showClearCurrentFileSummary(createClearResult({
|
||||
conflictFiles: ["notes/reset.md"],
|
||||
failureFiles: [
|
||||
{ filePath: "notes/a.md", key: "errors.markerRemoval.markdownFileNotFound" },
|
||||
{ filePath: "notes/b.md", rawMessage: "permission denied" },
|
||||
],
|
||||
}));
|
||||
|
||||
expect(noticeRecords.map((entry) => entry.message)).toEqual([
|
||||
"当前文件已同步卡片清空完成:已跟踪卡片 2,已跟踪分组 1,删除笔记 3,移除标记 4,移除 ID 标记 2,移除 GI 标记 2,删除本地记录 3。 标记移除冲突:notes/reset.md。 失败项:notes/a.md (Markdown 文件不存在。)、notes/b.md (permission denied)。",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders cleanup summary and skipped decks in English", () => {
|
||||
const service = new NoticeService();
|
||||
|
||||
service.showCleanupEmptyDecksSummary(createCleanupResult({
|
||||
skippedDeckNames: ["Deck C", "Deck D"],
|
||||
}));
|
||||
|
||||
expect(noticeRecords.map((entry) => entry.message)).toEqual([
|
||||
"Empty-deck cleanup completed: candidates 4, selected 3, deleted 2, skipped 2. Skipped decks: Deck C, Deck D.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import { renderPluginFileFailuresInline } from "@/application/errors/PluginUserError";
|
||||
import type { ClearCurrentFileSyncedCardsResult, CleanupEmptyDecksResult } from "@/application/use-cases/cleanupResetTypes";
|
||||
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
||||
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
|
||||
import { formatList, resolvePluginLocale, t, type TranslationKey } from "@/presentation/i18n";
|
||||
|
||||
export class NoticeService {
|
||||
info(message: string): void {
|
||||
|
|
@ -12,50 +15,120 @@ export class NoticeService {
|
|||
new Notice(message, 8000);
|
||||
}
|
||||
|
||||
showSyncSummary(prefix: string, result: ManualSyncResult): void {
|
||||
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, created ${result.created}, updated ${result.updated}, migrated decks ${result.migratedDecks}, orphaned ${result.orphaned}, media ${result.uploadedMedia}, skipped ${result.skippedUnchangedCards}.`;
|
||||
const conflicts = result.markerWriteConflictFiles.length > 0
|
||||
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
|
||||
: "";
|
||||
const warnings = result.warnings.length > 0 ? ` Warnings: ${result.warnings.length}.` : "";
|
||||
showSyncSummary(kind: "currentFile" | "vault", result: ManualSyncResult): void {
|
||||
const locale = resolvePluginLocale();
|
||||
const summaryKey = kind === "currentFile" ? "notice.summary.currentFileSync" : "notice.summary.vaultSync";
|
||||
const summary = t(summaryKey, {
|
||||
scannedFiles: result.scannedFiles,
|
||||
scannedCards: result.scannedCards,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
migratedDecks: result.migratedDecks,
|
||||
orphaned: result.orphaned,
|
||||
uploadedMedia: result.uploadedMedia,
|
||||
skippedUnchangedCards: result.skippedUnchangedCards,
|
||||
});
|
||||
|
||||
this.info(`${summary}${conflicts}${warnings}`);
|
||||
const segments = [summary];
|
||||
if (result.markerWriteConflictFiles.length > 0) {
|
||||
segments.push(t("notice.summary.markerWriteConflicts", {
|
||||
files: formatList(locale, result.markerWriteConflictFiles),
|
||||
}));
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
segments.push(t("notice.summary.warningsCount", { count: result.warnings.length }));
|
||||
}
|
||||
|
||||
this.info(segments.join(" "));
|
||||
for (const warning of result.warnings.slice(0, 3)) {
|
||||
this.info(warning.message);
|
||||
this.info(this.renderDeckWarning(warning));
|
||||
}
|
||||
}
|
||||
|
||||
showRebuildSummary(prefix: string, result: ManualSyncResult): void {
|
||||
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, migrated decks ${result.migratedDecks}, orphaned ${result.orphaned}, rewritten markers ${result.rewrittenMarkers}, skipped ${result.skippedUnchangedCards}.`;
|
||||
const conflicts = result.markerWriteConflictFiles.length > 0
|
||||
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
|
||||
: "";
|
||||
const warnings = result.warnings.length > 0 ? ` Warnings: ${result.warnings.length}.` : "";
|
||||
showRebuildSummary(result: ManualSyncResult): void {
|
||||
const locale = resolvePluginLocale();
|
||||
const segments = [t("notice.summary.rebuild", {
|
||||
scannedFiles: result.scannedFiles,
|
||||
scannedCards: result.scannedCards,
|
||||
migratedDecks: result.migratedDecks,
|
||||
orphaned: result.orphaned,
|
||||
rewrittenMarkers: result.rewrittenMarkers,
|
||||
skippedUnchangedCards: result.skippedUnchangedCards,
|
||||
})];
|
||||
|
||||
this.info(`${summary}${conflicts}${warnings}`);
|
||||
if (result.markerWriteConflictFiles.length > 0) {
|
||||
segments.push(t("notice.summary.markerWriteConflicts", {
|
||||
files: formatList(locale, result.markerWriteConflictFiles),
|
||||
}));
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
segments.push(t("notice.summary.warningsCount", { count: result.warnings.length }));
|
||||
}
|
||||
|
||||
this.info(segments.join(" "));
|
||||
for (const warning of result.warnings.slice(0, 3)) {
|
||||
this.info(warning.message);
|
||||
this.info(this.renderDeckWarning(warning));
|
||||
}
|
||||
}
|
||||
|
||||
showClearCurrentFileSummary(prefix: string, result: ClearCurrentFileSyncedCardsResult): void {
|
||||
const summary = `${prefix}: tracked cards ${result.trackedCards}, tracked groups ${result.trackedGroups}, deleted notes ${result.deletedNotes}, removed markers ${result.removedMarkers}, removed ID markers ${result.removedCardMarkers}, removed GI markers ${result.removedGroupMarkers}, deleted local records ${result.deletedLocalRecords}.`;
|
||||
const conflicts = result.conflictFiles.length > 0
|
||||
? ` Marker removal conflicts: ${result.conflictFiles.join(", ")}.`
|
||||
: "";
|
||||
const failures = result.failureFiles.length > 0
|
||||
? ` Failures: ${result.failureFiles.map((entry) => `${entry.filePath} (${entry.message})`).join(", ")}.`
|
||||
: "";
|
||||
showClearCurrentFileSummary(result: ClearCurrentFileSyncedCardsResult): void {
|
||||
const locale = resolvePluginLocale();
|
||||
const segments = [t("notice.summary.clearCurrentFile", {
|
||||
trackedCards: result.trackedCards,
|
||||
trackedGroups: result.trackedGroups,
|
||||
deletedNotes: result.deletedNotes,
|
||||
removedMarkers: result.removedMarkers,
|
||||
removedCardMarkers: result.removedCardMarkers,
|
||||
removedGroupMarkers: result.removedGroupMarkers,
|
||||
deletedLocalRecords: result.deletedLocalRecords,
|
||||
})];
|
||||
|
||||
this.info(`${summary}${conflicts}${failures}`);
|
||||
if (result.conflictFiles.length > 0) {
|
||||
segments.push(t("notice.summary.markerRemovalConflicts", {
|
||||
files: formatList(locale, result.conflictFiles),
|
||||
}));
|
||||
}
|
||||
if (result.failureFiles.length > 0) {
|
||||
segments.push(t("notice.summary.failures", {
|
||||
items: renderPluginFileFailuresInline(result.failureFiles),
|
||||
}));
|
||||
}
|
||||
|
||||
this.info(segments.join(" "));
|
||||
}
|
||||
|
||||
showCleanupEmptyDecksSummary(prefix: string, result: CleanupEmptyDecksResult): void {
|
||||
const summary = `${prefix}: candidates ${result.candidateCount}, selected ${result.selectedCount}, deleted ${result.deletedCount}, skipped ${result.skippedDeckNames.length}.`;
|
||||
const skipped = result.skippedDeckNames.length > 0
|
||||
? ` Skipped decks: ${result.skippedDeckNames.join(", ")}.`
|
||||
: "";
|
||||
showCleanupEmptyDecksSummary(result: CleanupEmptyDecksResult): void {
|
||||
const locale = resolvePluginLocale();
|
||||
const segments = [t("notice.summary.cleanupEmptyDecks", {
|
||||
candidateCount: result.candidateCount,
|
||||
selectedCount: result.selectedCount,
|
||||
deletedCount: result.deletedCount,
|
||||
skippedCount: result.skippedDeckNames.length,
|
||||
})];
|
||||
|
||||
this.info(`${summary}${skipped}`);
|
||||
if (result.skippedDeckNames.length > 0) {
|
||||
segments.push(t("notice.summary.skippedDecks", {
|
||||
decks: formatList(locale, result.skippedDeckNames),
|
||||
}));
|
||||
}
|
||||
|
||||
this.info(segments.join(" "));
|
||||
}
|
||||
|
||||
private renderDeckWarning(warning: DeckResolutionWarning): string {
|
||||
return t(getDeckWarningMessageKey(warning.code), warning.params);
|
||||
}
|
||||
}
|
||||
|
||||
function getDeckWarningMessageKey(code: DeckResolutionWarning["code"]): TranslationKey {
|
||||
switch (code) {
|
||||
case "deck_conflict_yaml_body":
|
||||
return "warnings.deck.conflictYamlBody";
|
||||
case "deck_multiple_body_declarations":
|
||||
return "warnings.deck.multipleBodyDeclarations";
|
||||
case "deck_invalid_folder_segment":
|
||||
return "warnings.deck.invalidFolderSegment";
|
||||
case "deck_fallback_default":
|
||||
return "warnings.deck.fallbackDefault";
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,12 @@ const {
|
|||
FakeButtonComponent,
|
||||
FakeDropdownComponent,
|
||||
FakeElement,
|
||||
getLanguageMock,
|
||||
FakePluginSettingTab,
|
||||
FakeSetting,
|
||||
} = vi.hoisted(() => {
|
||||
const hoistedGetLanguage = vi.fn(() => "en");
|
||||
|
||||
class HoistedFakeElement {
|
||||
public readonly children: HoistedFakeElement[] = [];
|
||||
public readonly dataset: Record<string, string> = {};
|
||||
|
|
@ -242,12 +245,14 @@ const {
|
|||
FakeButtonComponent: HoistedFakeButtonComponent,
|
||||
FakeDropdownComponent: HoistedFakeDropdownComponent,
|
||||
FakeElement: HoistedFakeElement,
|
||||
getLanguageMock: hoistedGetLanguage,
|
||||
FakePluginSettingTab: HoistedFakePluginSettingTab,
|
||||
FakeSetting: HoistedFakeSetting,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("obsidian", () => ({
|
||||
getLanguage: getLanguageMock,
|
||||
PluginSettingTab: FakePluginSettingTab,
|
||||
Setting: FakeSetting,
|
||||
}));
|
||||
|
|
@ -462,6 +467,57 @@ async function flushAsync(): Promise<void> {
|
|||
describe("AnkiHeadingSyncSettingTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getLanguageMock.mockReset();
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
});
|
||||
|
||||
it("renders key settings labels, descriptions, and aria text in English", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = {
|
||||
...plugin.settings,
|
||||
scopeMode: "include",
|
||||
};
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
const container = tab.containerEl as unknown as FakeContainerElInstance;
|
||||
|
||||
tab.display();
|
||||
expect(container.textNodes).toContain("Anki Heading Sync");
|
||||
expect(findSetting(container, "AnkiConnect URL").desc).toBe("Default is http://127.0.0.1:8765");
|
||||
expect(findSetting(container, "Run scope").desc).toBe("Only process Markdown files in the checked folders below");
|
||||
|
||||
await flushAsync();
|
||||
tab.display();
|
||||
|
||||
const toggle = getFolderToggle(container, "notes");
|
||||
expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("Expand notes");
|
||||
|
||||
await toggle.trigger("click");
|
||||
expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("Collapse notes");
|
||||
});
|
||||
|
||||
it("renders key settings labels, descriptions, and aria text in Simplified Chinese", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = {
|
||||
...plugin.settings,
|
||||
scopeMode: "include",
|
||||
};
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
const container = tab.containerEl as unknown as FakeContainerElInstance;
|
||||
|
||||
tab.display();
|
||||
expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4");
|
||||
expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件");
|
||||
expect(findSetting(container, "默认牌组").desc).toBe("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。");
|
||||
|
||||
await flushAsync();
|
||||
tab.display();
|
||||
|
||||
const toggle = getFolderToggle(container, "notes");
|
||||
expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("展开 notes");
|
||||
|
||||
await toggle.trigger("click");
|
||||
expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("收起 notes");
|
||||
});
|
||||
|
||||
it("refreshes note type list from Anki into the dropdowns", async () => {
|
||||
|
|
@ -631,7 +687,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
expect(querySetting(container, "Include folders")).toBeUndefined();
|
||||
expect(querySetting(container, "Exclude folders")).toBeUndefined();
|
||||
expect(findSetting(container, "运行范围")).toBeDefined();
|
||||
expect(findSetting(container, "Run scope")).toBeDefined();
|
||||
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -813,7 +869,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
tab.display();
|
||||
await flushAsync();
|
||||
await getDropdown(findSetting(container, "运行范围")).triggerChange("include");
|
||||
await getDropdown(findSetting(container, "Run scope")).triggerChange("include");
|
||||
await flushAsync();
|
||||
|
||||
const parentCheckbox = getCheckboxByPath(container, "notes");
|
||||
|
|
@ -823,7 +879,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
expect(plugin.settings.includeFolders).toEqual(["notes"]);
|
||||
|
||||
await getDropdown(findSetting(container, "运行范围")).triggerChange("all");
|
||||
await getDropdown(findSetting(container, "Run scope")).triggerChange("all");
|
||||
await flushAsync();
|
||||
|
||||
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
|
||||
|
|
@ -836,19 +892,19 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
tab.display();
|
||||
|
||||
expect(container.textNodes).toContain("默认牌组");
|
||||
expect(container.textNodes).toContain("文件级自定义牌组");
|
||||
expect(container.textNodes).toContain("高级:文件夹映射");
|
||||
expect(container.textNodes).toContain("最终优先级说明");
|
||||
expect(container.textNodes).toContain("Default deck");
|
||||
expect(container.textNodes).toContain("File-level custom deck");
|
||||
expect(container.textNodes).toContain("Advanced: folder mapping");
|
||||
expect(container.textNodes).toContain("Final priority");
|
||||
|
||||
await getToggle(findSetting(container, "开启文件级自定义牌组")).triggerChange(true);
|
||||
await getToggle(findSetting(container, "Enable file-level custom deck")).triggerChange(true);
|
||||
await flushAsync();
|
||||
|
||||
await getText(findSetting(container, "牌组识别名")).triggerChange("MY DECK");
|
||||
await getText(findSetting(container, "默认牌组模板")).triggerChange("vault::filename");
|
||||
await getDropdown(findSetting(container, "模板插入位置")).triggerChange("yaml");
|
||||
await getDropdown(findSetting(container, "文件夹映射模式")).triggerChange("folder-and-file");
|
||||
await getText(findSetting(container, "默认牌组")).triggerChange("Deck::Default");
|
||||
await getText(findSetting(container, "Deck marker name")).triggerChange("MY DECK");
|
||||
await getText(findSetting(container, "Default deck template")).triggerChange("vault::filename");
|
||||
await getDropdown(findSetting(container, "Template insert location")).triggerChange("yaml");
|
||||
await getDropdown(findSetting(container, "Folder mapping mode")).triggerChange("folder-and-file");
|
||||
await getText(findSetting(container, "Default deck")).triggerChange("Deck::Default");
|
||||
await flushAsync();
|
||||
|
||||
expect(plugin.settings.fileDeckEnabled).toBe(true);
|
||||
|
|
@ -860,12 +916,12 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
|
||||
tab.display();
|
||||
|
||||
expect(getToggle(findSetting(container, "开启文件级自定义牌组")).value).toBe(true);
|
||||
expect(getText(findSetting(container, "牌组识别名")).value).toBe("MY DECK");
|
||||
expect(getText(findSetting(container, "默认牌组模板")).value).toBe("vault::filename");
|
||||
expect(getDropdown(findSetting(container, "模板插入位置")).value).toBe("yaml");
|
||||
expect(getDropdown(findSetting(container, "文件夹映射模式")).value).toBe("folder-and-file");
|
||||
expect(getText(findSetting(container, "默认牌组")).value).toBe("Deck::Default");
|
||||
expect(getToggle(findSetting(container, "Enable file-level custom deck")).value).toBe(true);
|
||||
expect(getText(findSetting(container, "Deck marker name")).value).toBe("MY DECK");
|
||||
expect(getText(findSetting(container, "Default deck template")).value).toBe("vault::filename");
|
||||
expect(getDropdown(findSetting(container, "Template insert location")).value).toBe("yaml");
|
||||
expect(getDropdown(findSetting(container, "Folder mapping mode")).value).toBe("folder-and-file");
|
||||
expect(getText(findSetting(container, "Default deck")).value).toBe("Deck::Default");
|
||||
});
|
||||
|
||||
it("exposes the deck template insertion action when file deck mode is enabled", async () => {
|
||||
|
|
@ -878,7 +934,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
|
|||
const container = tab.containerEl as unknown as FakeContainerElInstance;
|
||||
|
||||
tab.display();
|
||||
await getButton(findSetting(container, "向当前文件插入 deck 模板")).click();
|
||||
await getButton(findSetting(container, "Insert deck template into the current file")).click();
|
||||
|
||||
expect(plugin.insertDeckTemplateCalls).toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,21 +4,21 @@ import { isValidHashtagMarker, isValidSemanticQaMarker, type FileDeckInsertLocat
|
|||
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
|
||||
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import { type UserFacingMessage, renderUserFacingMessage, toUserFacingMessage } from "@/application/errors/PluginUserError";
|
||||
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
|
||||
import { buildQaGroupModelDefinition } from "@/application/services/QaGroupModelDefinition";
|
||||
import type { CardType } from "@/domain/card/entities/RenderedFields";
|
||||
import { SemanticQaListParser } from "@/domain/manual-sync/services/SemanticQaListParser";
|
||||
import { t } from "@/presentation/i18n";
|
||||
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
|
||||
|
||||
import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSelectionNode } from "./FolderScopeTree";
|
||||
|
||||
const NOTE_TYPE_STATUS_IDLE = "Refresh note types from Anki to load the available note types.";
|
||||
const FOLDER_TREE_STATUS_LOADING = "正在读取当前 vault 文件夹...";
|
||||
const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status.idle" };
|
||||
const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" };
|
||||
|
||||
interface MappingSectionConfig {
|
||||
cardType: CardType;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type SimpleDropdown = {
|
||||
|
|
@ -31,12 +31,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private readonly noteFieldMappingService = new NoteFieldMappingService();
|
||||
private readonly semanticQaListParser = new SemanticQaListParser();
|
||||
private availableNoteModels: string[] = [];
|
||||
private noteTypeStatus = NOTE_TYPE_STATUS_IDLE;
|
||||
private noteTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE;
|
||||
private readonly draftMappings: Record<string, NoteModelFieldMapping> = {};
|
||||
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
|
||||
private readonly sectionStatuses: Partial<Record<CardType, string>> = {};
|
||||
private readonly sectionStatuses: Partial<Record<CardType, UserFacingMessage>> = {};
|
||||
private folderTree: FolderTreeNode[] = [];
|
||||
private folderTreeStatus = FOLDER_TREE_STATUS_LOADING;
|
||||
private folderTreeStatus: UserFacingMessage = FOLDER_TREE_STATUS_LOADING;
|
||||
private folderTreeLoadPromise: Promise<void> | null = null;
|
||||
private hasLoadedFolderTree = false;
|
||||
private readonly expandedFolderPaths = new Set<string>();
|
||||
|
|
@ -59,13 +59,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const settings = this.plugin.settings;
|
||||
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "Anki Heading Sync" });
|
||||
containerEl.createEl("h2", { text: t("settings.pluginTitle") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AnkiConnect URL")
|
||||
.setDesc("Default is http://127.0.0.1:8765")
|
||||
.setName(t("settings.ankiConnectUrl.name"))
|
||||
.setDesc(t("settings.ankiConnectUrl.desc"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("http://127.0.0.1:8765").setValue(settings.ankiConnectUrl).onChange((value) => {
|
||||
text.setPlaceholder(t("settings.ankiConnectUrl.placeholder")).setValue(settings.ankiConnectUrl).onChange((value) => {
|
||||
void this.plugin.updateSettings({ ankiConnectUrl: value.trim() || settings.ankiConnectUrl });
|
||||
});
|
||||
});
|
||||
|
|
@ -73,8 +73,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.renderDeckSection(containerEl, settings);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("QA heading level")
|
||||
.setDesc("Default is H4")
|
||||
.setName(t("settings.qaHeadingLevel.name"))
|
||||
.setDesc(t("settings.qaHeadingLevel.desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
for (let level = 1; level <= 6; level += 1) {
|
||||
dropdown.addOption(String(level), `H${level}`);
|
||||
|
|
@ -86,8 +86,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Cloze heading level")
|
||||
.setDesc("Default is H5")
|
||||
.setName(t("settings.clozeHeadingLevel.name"))
|
||||
.setDesc(t("settings.clozeHeadingLevel.desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
for (let level = 1; level <= 6; level += 1) {
|
||||
dropdown.addOption(String(level), `H${level}`);
|
||||
|
|
@ -98,13 +98,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h3", { text: "QA Group 12" });
|
||||
containerEl.createEl("h3", { text: t("settings.qaGroup.title") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("QA Group marker")
|
||||
.setDesc("When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.")
|
||||
.setName(t("settings.qaGroup.marker.name"))
|
||||
.setDesc(t("settings.qaGroup.marker.desc"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("#anki-list").setValue(settings.qaGroupMarker).onChange(async (value) => {
|
||||
text.setPlaceholder(t("settings.qaGroup.marker.placeholder")).setValue(settings.qaGroupMarker).onChange(async (value) => {
|
||||
const nextValue = value.trim();
|
||||
if (!nextValue || !isValidHashtagMarker(nextValue) || nextValue === settings.semanticQaMarker) {
|
||||
return;
|
||||
|
|
@ -116,13 +116,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
this.renderQaGroupModelStatus(containerEl);
|
||||
|
||||
containerEl.createEl("h3", { text: "Semantic QA" });
|
||||
containerEl.createEl("h3", { text: t("settings.semanticQa.title") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Semantic QA marker")
|
||||
.setDesc("When a QA heading ends with this hashtag marker, first-level list items with indented child content become separate cards.")
|
||||
.setName(t("settings.semanticQa.marker.name"))
|
||||
.setDesc(t("settings.semanticQa.marker.desc"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("#anki-list-qa").setValue(settings.semanticQaMarker).onChange(async (value) => {
|
||||
text.setPlaceholder(t("settings.semanticQa.marker.placeholder")).setValue(settings.semanticQaMarker).onChange(async (value) => {
|
||||
const nextValue = value.trim();
|
||||
if (!nextValue || !isValidSemanticQaMarker(nextValue)) {
|
||||
return;
|
||||
|
|
@ -138,8 +138,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.renderScopeSection(containerEl, settings);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Add Obsidian backlink")
|
||||
.setDesc("Append a backlink to the source heading into synced cards.")
|
||||
.setName(t("settings.syncOptions.addObsidianBacklink.name"))
|
||||
.setDesc(t("settings.syncOptions.addObsidianBacklink.desc"))
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settings.addObsidianBacklink).onChange((value) => {
|
||||
void this.plugin.updateSettings({ addObsidianBacklink: value });
|
||||
|
|
@ -147,53 +147,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Highlights to Cloze")
|
||||
.setDesc("Convert ==highlight== segments into cloze deletions for cloze cards.")
|
||||
.setName(t("settings.syncOptions.highlightsToCloze.name"))
|
||||
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settings.convertHighlightsToCloze).onChange((value) => {
|
||||
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h3", { text: "Note type field mappings" });
|
||||
containerEl.createEl("h3", { text: t("settings.mapping.title") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Refresh note types from Anki")
|
||||
.setDesc(this.noteTypeStatus)
|
||||
.setName(t("settings.mapping.refresh.name"))
|
||||
.setDesc(renderUserFacingMessage(this.noteTypeStatus))
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Refresh note types").onClick(() => {
|
||||
button.setButtonText(t("settings.mapping.refresh.button")).onClick(() => {
|
||||
void this.refreshNoteTypes();
|
||||
});
|
||||
});
|
||||
|
||||
this.renderMappingSection(containerEl, {
|
||||
cardType: "basic",
|
||||
title: "QA / Basic",
|
||||
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
|
||||
});
|
||||
this.renderMappingSection(containerEl, {
|
||||
cardType: "cloze",
|
||||
title: "Cloze",
|
||||
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
|
||||
});
|
||||
this.renderMappingSection(containerEl, {
|
||||
cardType: "semantic-qa",
|
||||
title: "Semantic QA",
|
||||
description: "Choose the semantic QA note type, read its fields from Anki, then confirm the title/body mapping for child cards.",
|
||||
});
|
||||
this.renderMappingSection(containerEl, { cardType: "basic" });
|
||||
this.renderMappingSection(containerEl, { cardType: "cloze" });
|
||||
this.renderMappingSection(containerEl, { cardType: "semantic-qa" });
|
||||
}
|
||||
|
||||
private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void {
|
||||
const sectionText = getMappingSectionText(config.cardType);
|
||||
const sectionTitle = sectionText.title;
|
||||
const selectedModelName = this.getSelectedMappingNoteType(config.cardType);
|
||||
const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName);
|
||||
const currentMapping = this.getCurrentMapping(mappingKey);
|
||||
|
||||
containerEl.createEl("h4", { text: config.title });
|
||||
containerEl.createEl("p", { text: config.description });
|
||||
containerEl.createEl("h4", { text: sectionTitle });
|
||||
containerEl.createEl("p", { text: sectionText.description });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(`${config.title} note type`)
|
||||
.setDesc("Loaded from Anki note types. Refresh if the latest models are not shown.")
|
||||
.setName(t("settings.mapping.noteTypeLabel", { title: sectionTitle }))
|
||||
.setDesc(t("settings.mapping.noteTypeDesc"))
|
||||
.addDropdown((dropdown) => {
|
||||
for (const noteModel of this.getSelectableNoteModels(config.cardType, selectedModelName)) {
|
||||
dropdown.addOption(noteModel, noteModel);
|
||||
|
|
@ -205,10 +195,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(`${config.title} fields`)
|
||||
.setDesc("Load the current note type fields from Anki, then review the suggested mapping.")
|
||||
.setName(t("settings.mapping.fieldsLabel", { title: sectionTitle }))
|
||||
.setDesc(t("settings.mapping.fieldsDesc"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Read fields from Anki").onClick(() => {
|
||||
button.setButtonText(t("settings.mapping.readFieldsButton")).onClick(() => {
|
||||
void this.loadFieldsFromAnki(config.cardType);
|
||||
});
|
||||
});
|
||||
|
|
@ -216,31 +206,31 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
containerEl.createEl("p", {
|
||||
text:
|
||||
currentMapping?.loadedFieldNames.length
|
||||
? `Loaded fields: ${currentMapping.loadedFieldNames.join(", ")}`
|
||||
: "Loaded fields: none. Read fields from Anki first.",
|
||||
? t("settings.mapping.loadedFields", { fields: currentMapping.loadedFieldNames })
|
||||
: t("settings.mapping.loadedFieldsNone"),
|
||||
});
|
||||
|
||||
if (config.cardType === "cloze") {
|
||||
this.renderClozeFieldSelector(containerEl, selectedModelName, currentMapping);
|
||||
} else {
|
||||
this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping, config.title);
|
||||
this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping, config.cardType, sectionTitle);
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(`${config.title} mapping`)
|
||||
.setDesc("Save the current field mapping for this specific note type.")
|
||||
.setName(t("settings.mapping.mappingLabel", { title: sectionTitle }))
|
||||
.setDesc(t("settings.mapping.mappingDesc"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Save mapping").onClick(() => {
|
||||
button.setButtonText(t("settings.mapping.saveMappingButton")).onClick(() => {
|
||||
void this.saveMapping(config.cardType);
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("p", {
|
||||
text:
|
||||
this.sectionStatuses[config.cardType] ??
|
||||
(this.sectionStatuses[config.cardType] ? renderUserFacingMessage(this.sectionStatuses[config.cardType] as UserFacingMessage) : undefined) ??
|
||||
(this.plugin.settings.noteFieldMappings[mappingKey]
|
||||
? `Saved mapping is ready for ${config.title}.`
|
||||
: `No saved mapping for ${config.title}. Read fields from Anki first.`),
|
||||
? t("settings.mapping.status.savedMappingReady", { title: sectionTitle })
|
||||
: t("settings.mapping.status.noSavedMapping", { title: sectionTitle })),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -248,27 +238,28 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
containerEl: HTMLElement,
|
||||
selectedModelName: string,
|
||||
mapping: NoteModelFieldMapping | undefined,
|
||||
cardType: Extract<CardType, "basic" | "semantic-qa">,
|
||||
sectionTitle: string,
|
||||
): void {
|
||||
const fieldNames = mapping?.loadedFieldNames ?? [];
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(`${sectionTitle} title field`)
|
||||
.setDesc("Which Anki field should receive the heading/title fragment.")
|
||||
.setName(t("settings.mapping.titleFieldLabel", { title: sectionTitle }))
|
||||
.setDesc(t("settings.mapping.titleFieldDesc"))
|
||||
.addDropdown((dropdown) => {
|
||||
this.populateFieldDropdown(dropdown, fieldNames, mapping?.titleField);
|
||||
dropdown.onChange((value) => {
|
||||
this.updateDraftMapping(mapping?.cardType ?? inferBasicLikeCardType(sectionTitle), selectedModelName, { titleField: value || undefined });
|
||||
this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { titleField: value || undefined });
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(`${sectionTitle} body field`)
|
||||
.setDesc("Which Anki field should receive the body fragment.")
|
||||
.setName(t("settings.mapping.bodyFieldLabel", { title: sectionTitle }))
|
||||
.setDesc(t("settings.mapping.bodyFieldDesc"))
|
||||
.addDropdown((dropdown) => {
|
||||
this.populateFieldDropdown(dropdown, fieldNames, mapping?.bodyField);
|
||||
dropdown.onChange((value) => {
|
||||
this.updateDraftMapping(mapping?.cardType ?? inferBasicLikeCardType(sectionTitle), selectedModelName, { bodyField: value || undefined });
|
||||
this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { bodyField: value || undefined });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -281,8 +272,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const fieldNames = mapping?.loadedFieldNames ?? [];
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Cloze main field")
|
||||
.setDesc("The selected field receives title + <br><br> + body during sync.")
|
||||
.setName(t("settings.mapping.mainField.name"))
|
||||
.setDesc(t("settings.mapping.mainField.desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
this.populateFieldDropdown(dropdown, fieldNames, mapping?.mainField);
|
||||
dropdown.onChange((value) => {
|
||||
|
|
@ -292,7 +283,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private populateFieldDropdown(dropdown: SimpleDropdown, fieldNames: string[], selectedValue: string | undefined): void {
|
||||
dropdown.addOption("", "-- Select field --");
|
||||
dropdown.addOption("", t("settings.mapping.selectFieldPlaceholder"));
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
dropdown.addOption(fieldName, fieldName);
|
||||
|
|
@ -307,10 +298,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.availableNoteModels = [...noteModels].sort((left, right) => left.localeCompare(right));
|
||||
this.noteTypeStatus =
|
||||
this.availableNoteModels.length > 0
|
||||
? `Loaded ${this.availableNoteModels.length} note types from Anki.`
|
||||
: "Anki returned no note types.";
|
||||
? { key: "settings.mapping.status.loadedCount", params: { count: this.availableNoteModels.length } }
|
||||
: { key: "settings.mapping.status.empty" };
|
||||
} catch (error) {
|
||||
this.noteTypeStatus = error instanceof Error ? error.message : "Failed to load note types from Anki.";
|
||||
this.noteTypeStatus = toUserFacingMessage(error, "settings.mapping.status.failedLoadNoteTypes");
|
||||
}
|
||||
|
||||
this.display();
|
||||
|
|
@ -327,8 +318,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
|
||||
this.sectionStatuses[cardType] = this.plugin.settings.noteFieldMappings[mappingKey]
|
||||
? `Loaded saved mapping for ${modelName}.`
|
||||
: `Selected ${modelName}. Read fields from Anki to create or refresh its mapping.`;
|
||||
? { key: "settings.mapping.status.loadedSavedMapping", params: { modelName } }
|
||||
: { key: "settings.mapping.status.selectedModel", params: { modelName } };
|
||||
this.display();
|
||||
}
|
||||
|
||||
|
|
@ -342,9 +333,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
this.draftMappings[mappingKey] = mapping;
|
||||
this.loadedModelDetails[mappingKey] = modelDetails;
|
||||
this.sectionStatuses[cardType] = `Loaded fields for ${modelName}. Review the suggested mapping and save it.`;
|
||||
this.sectionStatuses[cardType] = { key: "settings.mapping.status.loadedFieldsForModel", params: { modelName } };
|
||||
} catch (error) {
|
||||
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`;
|
||||
this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedLoadFields", { modelName });
|
||||
}
|
||||
|
||||
this.display();
|
||||
|
|
@ -356,7 +347,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const mapping = this.getCurrentMapping(mappingKey);
|
||||
|
||||
if (!mapping) {
|
||||
this.sectionStatuses[cardType] = `No loaded fields for ${modelName}. Read fields from Anki first.`;
|
||||
this.sectionStatuses[cardType] = { key: "settings.mapping.status.noLoadedFields", params: { modelName } };
|
||||
this.display();
|
||||
return;
|
||||
}
|
||||
|
|
@ -377,9 +368,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
},
|
||||
});
|
||||
|
||||
this.sectionStatuses[cardType] = `Saved mapping for ${modelName}.`;
|
||||
this.sectionStatuses[cardType] = { key: "settings.mapping.status.savedMapping", params: { modelName } };
|
||||
} catch (error) {
|
||||
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to save mapping for ${modelName}.`;
|
||||
this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedSaveMapping", { modelName });
|
||||
}
|
||||
|
||||
this.display();
|
||||
|
|
@ -450,19 +441,26 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private renderQaGroupModelStatus(containerEl: HTMLElement): void {
|
||||
const definition = buildQaGroupModelDefinition();
|
||||
containerEl.createEl("p", {
|
||||
text: `Managed note type: ${definition.modelName}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.`,
|
||||
text: t("settings.qaGroup.managedNoteType", {
|
||||
modelName: definition.modelName,
|
||||
}),
|
||||
});
|
||||
containerEl.createEl("p", {
|
||||
text: `Managed model contract: ${definition.fieldNames.length} fields and ${definition.templates.length} templates are checked automatically during sync.`,
|
||||
text: t("settings.qaGroup.managedModelContract", {
|
||||
fieldCount: definition.fieldNames.length,
|
||||
templateCount: definition.templates.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private renderSemanticQaPreview(containerEl: HTMLElement, marker: string): void {
|
||||
containerEl.createEl("h4", { text: "Semantic QA preview" });
|
||||
containerEl.createEl("p", { text: `Trigger heading example: 城市更新 ${marker}` });
|
||||
const sampleHeading = `城市更新 ${marker}`;
|
||||
|
||||
containerEl.createEl("h4", { text: t("settings.semanticQa.previewTitle") });
|
||||
containerEl.createEl("p", { text: t("settings.semanticQa.triggerHeadingExample", { heading: sampleHeading }) });
|
||||
|
||||
const previewCards = this.semanticQaListParser.parse({
|
||||
parentHeadingText: `城市更新 ${marker}`,
|
||||
parentHeadingText: sampleHeading,
|
||||
marker,
|
||||
bodyLines: [
|
||||
"- 核心产品",
|
||||
|
|
@ -474,32 +472,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
if (previewCards.length === 0) {
|
||||
containerEl.createEl("p", { text: "Preview unavailable. Use a trailing hashtag marker such as #anki-list-qa." });
|
||||
containerEl.createEl("p", { text: t("settings.semanticQa.previewUnavailable") });
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPreview = previewCards[0];
|
||||
containerEl.createEl("p", { text: `Question preview: ${firstPreview.heading}` });
|
||||
containerEl.createEl("p", { text: `Answer preview: ${firstPreview.bodyMarkdown}` });
|
||||
containerEl.createEl("p", { text: t("settings.semanticQa.questionPreview", { question: firstPreview.heading }) });
|
||||
containerEl.createEl("p", { text: t("settings.semanticQa.answerPreview", { answer: firstPreview.bodyMarkdown }) });
|
||||
}
|
||||
|
||||
private renderDeckSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
|
||||
containerEl.createEl("h3", { text: "默认牌组" });
|
||||
containerEl.createEl("h3", { text: t("settings.deck.defaultSectionTitle") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("默认牌组")
|
||||
.setDesc("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。")
|
||||
.setName(t("settings.deck.defaultDeck.name"))
|
||||
.setDesc(t("settings.deck.defaultDeck.desc"))
|
||||
.addText((text) => {
|
||||
text.setValue(settings.defaultDeck).onChange((value) => {
|
||||
void this.plugin.updateSettings({ defaultDeck: value });
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h3", { text: "文件级自定义牌组" });
|
||||
containerEl.createEl("h3", { text: t("settings.deck.fileDeckSectionTitle") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("开启文件级自定义牌组")
|
||||
.setDesc("开启后,允许用统一 marker 在 YAML 或正文中为整篇笔记指定 deck。")
|
||||
.setName(t("settings.deck.fileDeckEnabled.name"))
|
||||
.setDesc(t("settings.deck.fileDeckEnabled.desc"))
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settings.fileDeckEnabled).onChange(async (value) => {
|
||||
await this.plugin.updateSettings({ fileDeckEnabled: value });
|
||||
|
|
@ -509,8 +507,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
if (settings.fileDeckEnabled) {
|
||||
new Setting(containerEl)
|
||||
.setName("牌组识别名")
|
||||
.setDesc("YAML key 与正文 marker 共用同一个识别名。默认是 TARGET DECK。")
|
||||
.setName(t("settings.deck.marker.name"))
|
||||
.setDesc(t("settings.deck.marker.desc"))
|
||||
.addText((text) => {
|
||||
text.setValue(settings.fileDeckMarker).onChange((value) => {
|
||||
const nextValue = value.trim();
|
||||
|
|
@ -523,8 +521,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("默认牌组模板")
|
||||
.setDesc("只支持 filename 变量,插入时会展开为当前文件名(不含 .md)。")
|
||||
.setName(t("settings.deck.template.name"))
|
||||
.setDesc(t("settings.deck.template.desc"))
|
||||
.addText((text) => {
|
||||
text.setValue(settings.fileDeckTemplate).onChange((value) => {
|
||||
const nextValue = value.trim();
|
||||
|
|
@ -537,8 +535,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("模板插入位置")
|
||||
.setDesc("选择将 deck 模板写入 YAML frontmatter 还是正文前部。")
|
||||
.setName(t("settings.deck.insertLocation.name"))
|
||||
.setDesc(t("settings.deck.insertLocation.desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
this.populateFileDeckInsertLocationDropdown(dropdown, settings.fileDeckInsertLocation);
|
||||
dropdown.onChange((value) => {
|
||||
|
|
@ -551,20 +549,20 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("向当前文件插入 deck 模板")
|
||||
.setDesc("按当前 marker、模板和插入位置,把 deck 声明写入当前活动 Markdown 文件。")
|
||||
.setName(t("settings.deck.insertTemplate.name"))
|
||||
.setDesc(t("settings.deck.insertTemplate.desc"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText("插入 deck 模板").onClick(() => {
|
||||
button.setButtonText(t("settings.deck.insertTemplate.button")).onClick(() => {
|
||||
void this.plugin.insertDeckTemplateToCurrentFile();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
containerEl.createEl("h3", { text: "高级:文件夹映射" });
|
||||
containerEl.createEl("h3", { text: t("settings.deck.folderMappingSectionTitle") });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("文件夹映射模式")
|
||||
.setDesc("关闭 / 仅父文件夹 / 父文件夹加当前文件名。")
|
||||
.setName(t("settings.deck.folderDeckMode.name"))
|
||||
.setDesc(t("settings.deck.folderDeckMode.desc"))
|
||||
.addDropdown((dropdown) => {
|
||||
this.populateFolderDeckModeDropdown(dropdown, settings.folderDeckMode);
|
||||
dropdown.onChange((value) => {
|
||||
|
|
@ -576,37 +574,37 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("p", { text: "文件夹级映射示例:数学/第一章/第一节.md -> 数学::第一章" });
|
||||
containerEl.createEl("p", { text: "文件夹及文件名级映射示例:数学/第一章/第一节.md -> 数学::第一章::第一节" });
|
||||
containerEl.createEl("p", { text: t("settings.deck.folderExample") });
|
||||
containerEl.createEl("p", { text: t("settings.deck.folderAndFileExample") });
|
||||
|
||||
containerEl.createEl("h3", { text: "最终优先级说明" });
|
||||
containerEl.createEl("h3", { text: t("settings.deck.priorityTitle") });
|
||||
containerEl.createEl("p", {
|
||||
text: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡 deck 行为保持当前实现。",
|
||||
text: t("settings.deck.priorityDesc"),
|
||||
});
|
||||
}
|
||||
|
||||
private populateFileDeckInsertLocationDropdown(dropdown: SimpleDropdown, selectedValue: FileDeckInsertLocation): void {
|
||||
dropdown.addOption("body", "正文前部");
|
||||
dropdown.addOption("yaml", "YAML frontmatter");
|
||||
dropdown.addOption("body", t("settings.deck.insertLocation.options.body"));
|
||||
dropdown.addOption("yaml", t("settings.deck.insertLocation.options.yaml"));
|
||||
dropdown.setValue(selectedValue);
|
||||
}
|
||||
|
||||
private populateFolderDeckModeDropdown(dropdown: SimpleDropdown, selectedValue: FolderDeckMode): void {
|
||||
dropdown.addOption("off", "关闭");
|
||||
dropdown.addOption("folder", "文件夹级映射");
|
||||
dropdown.addOption("folder-and-file", "文件夹及文件名级映射");
|
||||
dropdown.addOption("off", t("settings.deck.folderDeckMode.options.off"));
|
||||
dropdown.addOption("folder", t("settings.deck.folderDeckMode.options.folder"));
|
||||
dropdown.addOption("folder-and-file", t("settings.deck.folderDeckMode.options.folderAndFile"));
|
||||
dropdown.setValue(selectedValue);
|
||||
}
|
||||
|
||||
private renderScopeSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
|
||||
new Setting(containerEl)
|
||||
.setName("运行范围")
|
||||
.setName(t("settings.scope.name"))
|
||||
.setDesc(getScopeModeSummary(settings.scopeMode))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("all", "全部文件")
|
||||
.addOption("include", "仅在指定文件夹")
|
||||
.addOption("exclude", "排除指定文件夹")
|
||||
.addOption("all", t("settings.scope.option.all"))
|
||||
.addOption("include", t("settings.scope.option.include"))
|
||||
.addOption("exclude", t("settings.scope.option.exclude"))
|
||||
.setValue(settings.scopeMode)
|
||||
.onChange((value) => {
|
||||
if (value !== "all" && value !== "include" && value !== "exclude") {
|
||||
|
|
@ -627,12 +625,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
scopeContainer.createEl("p", { text: getScopeModeTreeDescription(settings.scopeMode) });
|
||||
|
||||
if (this.folderTreeLoadPromise) {
|
||||
scopeContainer.createEl("p", { text: this.folderTreeStatus });
|
||||
scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.folderTree.length === 0) {
|
||||
scopeContainer.createEl("p", { text: this.folderTreeStatus });
|
||||
scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -676,7 +674,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
toggleControl.style.cursor = hasChildren ? "pointer" : "default";
|
||||
|
||||
if (hasChildren) {
|
||||
toggleControl.setAttr("aria-label", expanded ? `收起 ${node.name}` : `展开 ${node.name}`);
|
||||
toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
|
||||
toggleControl.setAttr("aria-expanded", String(expanded));
|
||||
toggleControl.addEventListener("click", () => {
|
||||
this.toggleFolderExpanded(node.path);
|
||||
|
|
@ -721,11 +719,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
.listFolderTree()
|
||||
.then((folderTree) => {
|
||||
this.folderTree = folderTree;
|
||||
this.folderTreeStatus = folderTree.length > 0 ? "" : "当前 vault 中没有可选文件夹。";
|
||||
this.folderTreeStatus = folderTree.length > 0 ? { rawMessage: "" } : { key: "settings.scope.empty" };
|
||||
})
|
||||
.catch((error) => {
|
||||
this.folderTree = [];
|
||||
this.folderTreeStatus = error instanceof Error ? error.message : "读取 vault 文件夹失败。";
|
||||
this.folderTreeStatus = toUserFacingMessage(error, "settings.scope.failedLoad");
|
||||
})
|
||||
.finally(() => {
|
||||
this.hasLoadedFolderTree = true;
|
||||
|
|
@ -758,26 +756,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
function inferBasicLikeCardType(sectionTitle: string): Extract<CardType, "basic" | "semantic-qa"> {
|
||||
return sectionTitle === "Semantic QA" ? "semantic-qa" : "basic";
|
||||
function getMappingSectionText(cardType: CardType): { title: string; description: string } {
|
||||
if (cardType === "cloze") {
|
||||
return {
|
||||
title: t("settings.mapping.section.cloze.title"),
|
||||
description: t("settings.mapping.section.cloze.description"),
|
||||
};
|
||||
}
|
||||
|
||||
if (cardType === "semantic-qa") {
|
||||
return {
|
||||
title: t("settings.mapping.section.semanticQa.title"),
|
||||
description: t("settings.mapping.section.semanticQa.description"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("settings.mapping.section.basic.title"),
|
||||
description: t("settings.mapping.section.basic.description"),
|
||||
};
|
||||
}
|
||||
|
||||
function getScopeModeSummary(scopeMode: ScopeMode): string {
|
||||
if (scopeMode === "include") {
|
||||
return "仅处理下方勾选文件夹中的 Markdown 文件";
|
||||
return t("settings.scope.summary.include");
|
||||
}
|
||||
|
||||
if (scopeMode === "exclude") {
|
||||
return "处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件";
|
||||
return t("settings.scope.summary.exclude");
|
||||
}
|
||||
|
||||
return "处理整个 vault 中的 Markdown 文件";
|
||||
return t("settings.scope.summary.all");
|
||||
}
|
||||
|
||||
function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
|
||||
if (scopeMode === "include") {
|
||||
return "仅在指定文件夹:只处理下方勾选文件夹中的 Markdown 文件";
|
||||
return t("settings.scope.treeDescription.include");
|
||||
}
|
||||
|
||||
return "排除指定文件夹:处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件";
|
||||
return t("settings.scope.treeDescription.exclude");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ export async function requestUrl(): Promise<never> {
|
|||
throw new Error("obsidian.requestUrl stub was called without a test mock.");
|
||||
}
|
||||
|
||||
export function getLanguage(): string {
|
||||
return "en";
|
||||
}
|
||||
|
||||
export class Plugin {
|
||||
public app: unknown;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue