diff --git a/docs/i18n-decisions.md b/docs/i18n-decisions.md new file mode 100644 index 0000000..1fb6105 --- /dev/null +++ b/docs/i18n-decisions.md @@ -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`。 \ No newline at end of file diff --git a/docs/i18n-gap-report.md b/docs/i18n-gap-report.md new file mode 100644 index 0000000..1940af0 --- /dev/null +++ b/docs/i18n-gap-report.md @@ -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、多组测试,必须一次性改全,否则类型和序列化都会断。 \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index a489789..629396f 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -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"); }); }); \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 615bcb2..4f1124c 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -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): 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"); } } } \ No newline at end of file diff --git a/src/application/errors/PluginUserError.test.ts b/src/application/errors/PluginUserError.test.ts new file mode 100644 index 0000000..36d6ee0 --- /dev/null +++ b/src/application/errors/PluginUserError.test.ts @@ -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."); + }); +}); \ No newline at end of file diff --git a/src/application/errors/PluginUserError.ts b/src/application/errors/PluginUserError.ts new file mode 100644 index 0000000..4825e1e --- /dev/null +++ b/src/application/errors/PluginUserError.ts @@ -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)})`), + ); +} \ No newline at end of file diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index 2a4a1c2..7d21672 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -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, + }); } } diff --git a/src/application/services/MarkdownMarkerRemovalService.ts b/src/application/services/MarkdownMarkerRemovalService.ts index be140e0..75aeb05 100644 --- a/src/application/services/MarkdownMarkerRemovalService.ts +++ b/src/application/services/MarkdownMarkerRemovalService.ts @@ -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)], }; } } diff --git a/src/application/services/MarkdownSyncedMarkerRemovalService.ts b/src/application/services/MarkdownSyncedMarkerRemovalService.ts index c545d11..7c6119b 100644 --- a/src/application/services/MarkdownSyncedMarkerRemovalService.ts +++ b/src/application/services/MarkdownSyncedMarkerRemovalService.ts @@ -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)], }; } } diff --git a/src/application/services/MarkdownWriteBackService.ts b/src/application/services/MarkdownWriteBackService.ts index 9c29428..f83aed7 100644 --- a/src/application/services/MarkdownWriteBackService.ts +++ b/src/application/services/MarkdownWriteBackService.ts @@ -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>(); 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; diff --git a/src/application/services/NoteFieldMappingService.test.ts b/src/application/services/NoteFieldMappingService.test.ts index d865d3a..ed3e8e1 100644 --- a/src/application/services/NoteFieldMappingService.test.ts +++ b/src/application/services/NoteFieldMappingService.test.ts @@ -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 { }; } +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" }); }); }); \ No newline at end of file diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts index c2950ce..60a8867 100644 --- a/src/application/services/NoteFieldMappingService.ts +++ b/src/application/services/NoteFieldMappingService.ts @@ -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 { 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): 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): "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 { diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index 7614dd2..2069d74 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -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; diff --git a/src/application/use-cases/cleanupResetTypes.ts b/src/application/use-cases/cleanupResetTypes.ts index 8a5e4e2..55ab39b 100644 --- a/src/application/use-cases/cleanupResetTypes.ts +++ b/src/application/use-cases/cleanupResetTypes.ts @@ -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 { diff --git a/src/domain/manual-sync/services/DeckExtractionService.ts b/src/domain/manual-sync/services/DeckExtractionService.ts index d9b95f6..3a0113e 100644 --- a/src/domain/manual-sync/services/DeckExtractionService.ts +++ b/src/domain/manual-sync/services/DeckExtractionService.ts @@ -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, + }, }); } diff --git a/src/domain/manual-sync/services/DeckNormalizationService.test.ts b/src/domain/manual-sync/services/DeckNormalizationService.test.ts index f5964e9..ccf0242 100644 --- a/src/domain/manual-sync/services/DeckNormalizationService.test.ts +++ b/src/domain/manual-sync/services/DeckNormalizationService.test.ts @@ -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"); }); }); \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckNormalizationService.ts b/src/domain/manual-sync/services/DeckNormalizationService.ts index dc7c96d..1b7dd92 100644 --- a/src/domain/manual-sync/services/DeckNormalizationService.ts +++ b/src/domain/manual-sync/services/DeckNormalizationService.ts @@ -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("::"); diff --git a/src/domain/manual-sync/services/DeckResolutionService.ts b/src/domain/manual-sync/services/DeckResolutionService.ts index e10149d..ece58f3 100644 --- a/src/domain/manual-sync/services/DeckResolutionService.ts +++ b/src/domain/manual-sync/services/DeckResolutionService.ts @@ -44,7 +44,6 @@ export class DeckResolutionService { { filePath: card.filePath, code: "deck_fallback_default", - message: "该文件没有显式 deck,且所在位置无法生成文件夹牌组,已回退到默认 deck。", }, ], }; diff --git a/src/domain/manual-sync/services/FolderDeckMappingService.ts b/src/domain/manual-sync/services/FolderDeckMappingService.ts index 4752706..24a4492 100644 --- a/src/domain/manual-sync/services/FolderDeckMappingService.ts +++ b/src/domain/manual-sync/services/FolderDeckMappingService.ts @@ -39,7 +39,6 @@ export class FolderDeckMappingService { warnings: [{ filePath, code: "deck_invalid_folder_segment", - message: "检测到文件夹名或文件名包含 ::,本次已放弃文件夹映射并回退到默认 deck。", }], }; } diff --git a/src/domain/manual-sync/value-objects/DeckResolution.ts b/src/domain/manual-sync/value-objects/DeckResolution.ts index 962e157..98beda2 100644 --- a/src/domain/manual-sync/value-objects/DeckResolution.ts +++ b/src/domain/manual-sync/value-objects/DeckResolution.ts @@ -6,6 +6,8 @@ export type DeckResolutionWarningCode = | "deck_invalid_folder_segment" | "deck_fallback_default"; +export type DeckResolutionWarningParams = Record; + 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) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + + return `{${entries.join(",")}}`; } \ No newline at end of file diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index 12c1297..91b9667 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -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 { 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 { 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 { 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")); } } } \ No newline at end of file diff --git a/src/presentation/commands/registerCommands.ts b/src/presentation/commands/registerCommands.ts index ef8add1..c792ee5 100644 --- a/src/presentation/commands/registerCommands.ts +++ b/src/presentation/commands/registerCommands.ts @@ -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(); }, diff --git a/src/presentation/i18n/index.ts b/src/presentation/i18n/index.ts new file mode 100644 index 0000000..bb492f7 --- /dev/null +++ b/src/presentation/i18n/index.ts @@ -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 = { + [K in keyof T & string]: T[K] extends string ? K : `${K}.${NestedTranslationKeys}` +}[keyof T & string]; + +export type TranslationKey = NestedTranslationKeys; +export type MessageParams = Record; + +function lookupMessage(dictionary: Record, 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)[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, key) + ?? lookupMessage(en as Record, key) + ?? key; + + return interpolate(template, params, locale); +} + +export { formatList, resolvePluginLocale, type PluginLocale }; \ No newline at end of file diff --git a/src/presentation/i18n/locale.test.ts b/src/presentation/i18n/locale.test.ts new file mode 100644 index 0000000..a4531a0 --- /dev/null +++ b/src/presentation/i18n/locale.test.ts @@ -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("甲、乙、丙"); + }); +}); \ No newline at end of file diff --git a/src/presentation/i18n/locale.ts b/src/presentation/i18n/locale.ts new file mode 100644 index 0000000..d92abcd --- /dev/null +++ b/src/presentation/i18n/locale.ts @@ -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" ? "、" : ", "); +} \ No newline at end of file diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts new file mode 100644 index 0000000..8f0097f --- /dev/null +++ b/src/presentation/i18n/messages/en.ts @@ -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 +

+ 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; \ No newline at end of file diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts new file mode 100644 index 0000000..522d488 --- /dev/null +++ b/src/presentation/i18n/messages/zh.ts @@ -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: "同步时,所选字段会接收 标题 +

+ 正文。", + }, + 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; \ No newline at end of file diff --git a/src/presentation/modals/EmptyDeckSelectionModal.test.ts b/src/presentation/modals/EmptyDeckSelectionModal.test.ts index f38a28c..8fe4455 100644 --- a/src/presentation/modals/EmptyDeckSelectionModal.test.ts +++ b/src/presentation/modals/EmptyDeckSelectionModal.test.ts @@ -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 个空牌组"); + }); }); diff --git a/src/presentation/modals/EmptyDeckSelectionModal.ts b/src/presentation/modals/EmptyDeckSelectionModal.ts index 0387728..d3f28b9 100644 --- a/src/presentation/modals/EmptyDeckSelectionModal.ts +++ b/src/presentation/modals/EmptyDeckSelectionModal.ts @@ -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(); private readonly selectedDeckNames = new Set(); @@ -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, + }); } } diff --git a/src/presentation/notices/NoticeService.test.ts b/src/presentation/notices/NoticeService.test.ts new file mode 100644 index 0000000..58e992f --- /dev/null +++ b/src/presentation/notices/NoticeService.test.ts @@ -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 { + 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 { + return { + trackedCards: 2, + trackedGroups: 1, + deletedNotes: 3, + removedMarkers: 4, + removedCardMarkers: 2, + removedGroupMarkers: 2, + deletedLocalRecords: 3, + conflictFiles: [], + failureFiles: [], + ...overrides, + }; +} + +function createCleanupResult(overrides: Partial = {}): 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.", + ]); + }); +}); \ No newline at end of file diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 3880d1a..996d560 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -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"; } } \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index c1e781d..ffe35bf 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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 = {}; @@ -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 { 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); }); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index d95190f..a377017 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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 = {}; private readonly loadedModelDetails: Record = {}; - private readonly sectionStatuses: Partial> = {}; + private readonly sectionStatuses: Partial> = {}; private folderTree: FolderTreeNode[] = []; - private folderTreeStatus = FOLDER_TREE_STATUS_LOADING; + private folderTreeStatus: UserFacingMessage = FOLDER_TREE_STATUS_LOADING; private folderTreeLoadPromise: Promise | null = null; private hasLoadedFolderTree = false; private readonly expandedFolderPaths = new Set(); @@ -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, 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 +

+ 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 { - 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"); } diff --git a/src/test-support/obsidianStub.ts b/src/test-support/obsidianStub.ts index 7244f51..91a895b 100644 --- a/src/test-support/obsidianStub.ts +++ b/src/test-support/obsidianStub.ts @@ -2,6 +2,10 @@ export async function requestUrl(): Promise { throw new Error("obsidian.requestUrl stub was called without a test mock."); } +export function getLanguage(): string { + return "en"; +} + export class Plugin { public app: unknown;