feat: implement full bilingual i18n and structured error rendering

- 新增完整双语 i18n 支持,支持按 Obsidian 语言自动切换
- 实现结构化 warning / user error 渲染
- Added full bilingual i18n support with automatic switching based on Obsidian language settings
- Implemented structured warning and user error rendering
This commit is contained in:
Dusk 2026-04-21 13:34:27 +08:00
parent a3c540a493
commit 67854cf154
34 changed files with 1829 additions and 357 deletions

64
docs/i18n-decisions.md Normal file
View file

@ -0,0 +1,64 @@
# i18n Decisions
## 1. 实现入口
本轮继续沿用现有分层,不做额外框架化改造:
1. `src/presentation/i18n/` 放 locale、消息字典、`t()`、`formatList()`。
2. 展示层入口继续是 `AnkiHeadingSyncPlugin`、`registerCommands`、`PluginSettingTab`、`EmptyDeckSelectionModal`、`NoticeService`。
3. 结构化用户错误定义放在 `src/application/errors/`,供 application / presentation 共用。
## 2. locale 策略
1. 运行时唯一来源是 `obsidian.getLanguage()`
2. 仅当返回值严格等于 `zh` 时使用简体中文。
3. 其他所有语言码都回退到英文,包括 `en`、`en-GB`、`ja`、`zh-TW`。
4. 不缓存 locale 常量;`t()` 与 `renderUserMessage()` 每次调用都重新解析当前语言。
## 3. message 结构
按计划固定以下 key 分组:
1. `commands.*`
2. `settings.*`
3. `modal.emptyDeck.*`
4. `notice.*`
5. `errors.*`
6. `warnings.deck.*`
`zh.ts` 通过 `satisfies typeof en` 约束字典 shape避免漏 key。
## 4. 用户错误模型
1. 新增 `PluginUserError`,仅承载 `key``params`
2. 新增 `isPluginUserError()``renderUserMessage()`
3. 插件自有、会冒泡到 Notice/UI 的错误统一抛 `PluginUserError`
4. 未结构化的第三方或底层异常继续允许透传 `error.message` 作为诊断信息。
## 5. warning / failure 结构
1. `DeckResolutionWarning` 改为 `{ filePath, code, params? }`
2. `getDeckResolutionWarningKey()` 使用 `filePath + code + stable(params)` 去重,不再依赖最终展示文案。
3. deck 相关领域服务只输出 warning code/params最终显示由 `NoticeService` 渲染。
4. clear/reset/write-back 类失败项也改为结构化 `code + params`,避免 lower layer 直接生成最终字符串。
## 6. 设置页集成方式
1. 保持现有 imperative render不引入新的设置元描述层。
2. 所有标题、说明、按钮、状态文案、preview、dropdown label、scope 文案、aria-label 直接切到 `t()`
3. 动态状态统一改成 key + params而不是继续内联拼接。
4. note type、field name、deck name、folder path 等业务数据原样透传,不做翻译。
## 7. 仓库兼容性偏差
相对草案,采用以下更贴合仓库的落地方式:
1. `PluginUserError` 放在 application 层而不是 presentation 层,因为 `validatePluginSettings()`、`ManualSyncService`、`NoteFieldMappingService` 都在 application。
2. `NoticeService` 负责 summary、warning 列表和结构化 failure 的最终格式化,避免在 plugin 主类里重复拼接。
3. 对 `pluginState` 中持久化的 `deckWarnings` 直接升级为结构化 payload不做兼容旧 message 字段的额外迁移逻辑,因为本任务明确不要求 settings schema 迁移,且 warning 不属于设置 schema。
## 8. 验证策略
1. 先做 locale / i18n 单测,确保基础设施稳定。
2. 再补 settings / modal / notice 的双语表现测试。
3. 最后更新 error / warning / persistence 相关测试,跑完整 `npm test`、`npm run build`、`npm run lint`。

67
docs/i18n-gap-report.md Normal file
View file

@ -0,0 +1,67 @@
# i18n Gap Report
## 审查范围
本次按真实运行链路审查以下实现入口:
1. `src/presentation/AnkiHeadingSyncPlugin.ts`
2. `src/presentation/commands/registerCommands.ts`
3. `src/presentation/settings/PluginSettingTab.ts`
4. `src/presentation/modals/EmptyDeckSelectionModal.ts`
5. `src/presentation/notices/NoticeService.ts`
6. `src/application/config/PluginSettings.ts`
7. `src/application/services/NoteFieldMappingService.ts`
8. `src/application/services/ManualSyncService.ts`
9. `src/domain/manual-sync/services/DeckExtractionService.ts`
10. `src/domain/manual-sync/services/FolderDeckMappingService.ts`
11. `src/domain/manual-sync/services/DeckResolutionService.ts`
## 当前实现结论
### 真实运行路径
1. 插件启动时由 `AnkiHeadingSyncPlugin.onload()` 读取设置、注册命令、挂载设置页。
2. 命令名全部在 `registerCommands.ts` 中注册期硬编码。
3. 设置页所有 UI 文案都由 `PluginSettingTab.display()` 及其私有渲染方法直接写入 `Setting`、`createEl()`、`aria-label`。
4. 空牌组清理弹窗由 `EmptyDeckSelectionModal` 直接创建标题、说明、按钮、计数文案。
5. 所有 summary notice 由 `NoticeService` 直接拼接最终字符串warning 明细直接展示 `warning.message`
6. 多个应用层与领域层直接抛出最终用户字符串,展示层通常直接透传 `error.message`
### 已确认的缺口
1. 仓库内没有统一 i18n 层,也没有 `locale -> message -> render` 的基础设施。
2. 当前语言来源没有统一入口,代码未调用 Obsidian `getLanguage()`
3. 设置页存在明显中英混排前半段大多英文deck/scope 部分大多中文。
4. 命令名当前全部是中文,且无法随 Obsidian 语言切换。
5. `EmptyDeckSelectionModal` 当前全部是中文硬编码。
6. `NoticeService` 当前 summary 文案是英文模板,但插件主类传入的 prefix 又常常是中文,形成混用输出。
7. `DeckResolutionWarning` 当前结构为 `{ filePath, code, message }`warning key 也依赖最终 `message`,不适合国际化。
8. `DeckExtractionService`、`FolderDeckMappingService`、`DeckResolutionService` 在领域层直接生成中文 warning message。
9. `CurrentFileOutOfScopeError`、`validatePluginSettings()`、`NoteFieldMappingService`、`ManualSyncService.createWriteBackFailureMessage()` 当前都直接生成最终用户字符串。
10. `AnkiHeadingSyncPlugin` 对多个 fallback notice 直接写死最终字符串,没有统一渲染层。
11. `PluginSettingTab` 当前大量动态状态文字直接拼接字符串,例如 note type 加载状态、field 列表、mapping 保存状态、scope 描述、folder tree 状态等。
12. `aria-label` 目前只有文件夹树展开/收起文字,且为中文硬编码。
### 与计划相比的仓库兼容性约束
1. 设置页并不是 schema/JSON 驱动,而是手写 imperative render所以国际化必须直接切到这些 render 方法,而不是抽象新的设置描述 DSL。
2. 命令名注册发生在 `onload()`,仓库内没有语言变化事件订阅,也没有命令热更新基础设施;这里只能按当前语言注册,后续靠插件重载刷新。
3. 当前 clear/reset/cleanup 结果类型里有 `failureFiles: Array<{ filePath, message }>`,若要避免下层携带最终字符串,需要把这类 failure 改为结构化数据并在 notice 层渲染。
4. `pluginState` 会持久化 `deckWarnings`;因此 warning 结构调整必须同时兼容状态仓储读写与现有测试。
## 本轮修复边界
本轮按计划收敛到以下范围:
1. 新增统一 i18n 基础设施,只支持 `en` 和简体中文。
2. 统一接管插件运行时所有用户可见文案。
3. 把用户错误与 deck warning 改成结构化数据,在上层按 locale 渲染。
4. 更新持久化与测试以适配新的 warning / error 结构。
5. 不修改 `PluginSettings` 持久化 schema。
6. 不改 README、manifest 元数据或发布物料。
## 风险点
1. `PluginSettingTab.test.ts``EmptyDeckSelectionModal.test.ts` 当前大量断言具体文本,改造后需要成组重写为中英双 locale 断言。
2. 由于 `obsidian` 在测试中普遍被 mock新增 `getLanguage()` 后需要同步更新 mock/stub避免测试在导入 i18n 时崩溃。
3. warning 结构变化会穿过 domain、application、persistence、多组测试必须一次性改全否则类型和序列化都会断。

View file

@ -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");
});
});

View file

@ -1,4 +1,5 @@
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
export type ScopeMode = "all" | "include" | "exclude";
export type FileDeckInsertLocation = "yaml" | "body";
@ -65,116 +66,116 @@ export function validatePluginSettings(settings: PluginSettings): void {
for (const level of headingLevels) {
if (!Number.isInteger(level) || level < 1 || level > 6) {
throw new Error("Heading levels must be integers between 1 and 6.");
throw new PluginUserError("errors.settings.headingLevelsRange");
}
}
if (settings.qaHeadingLevel === settings.clozeHeadingLevel) {
throw new Error("QA and Cloze heading levels must be different.");
throw new PluginUserError("errors.settings.headingLevelsDifferent");
}
if (!settings.qaNoteType.trim()) {
throw new Error("QA note type is required.");
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}
if (!settings.qaGroupMarker.trim()) {
throw new Error("QA Group marker is required.");
throw new PluginUserError("errors.settings.qaGroupMarkerRequired");
}
if (!isValidHashtagMarker(settings.qaGroupMarker.trim())) {
throw new Error("QA Group marker must be a hashtag-style token like #anki-list.");
throw new PluginUserError("errors.settings.qaGroupMarkerInvalid");
}
if (!settings.clozeNoteType.trim()) {
throw new Error("Cloze note type is required.");
throw new PluginUserError("errors.settings.clozeNoteTypeRequired");
}
if (!settings.semanticQaMarker.trim()) {
throw new Error("Semantic QA marker is required.");
throw new PluginUserError("errors.settings.semanticQaMarkerRequired");
}
if (!isValidSemanticQaMarker(settings.semanticQaMarker.trim())) {
throw new Error("Semantic QA marker must be a hashtag-style token like #anki-list-qa.");
throw new PluginUserError("errors.settings.semanticQaMarkerInvalid");
}
if (settings.qaGroupMarker.trim() === settings.semanticQaMarker.trim()) {
throw new Error("QA Group marker must be different from Semantic QA marker.");
throw new PluginUserError("errors.settings.qaGroupMarkerConflict");
}
if (!settings.semanticQaNoteType.trim()) {
throw new Error("Semantic QA note type is required.");
throw new PluginUserError("errors.settings.semanticQaNoteTypeRequired");
}
validateNoteFieldMappings(settings.noteFieldMappings);
if (!settings.defaultDeck.trim()) {
throw new Error("Default deck is required.");
throw new PluginUserError("errors.settings.defaultDeckRequired");
}
if (typeof settings.fileDeckEnabled !== "boolean") {
throw new Error("File deck enabled must be a boolean.");
throw new PluginUserError("errors.settings.fileDeckEnabledBoolean");
}
if (typeof settings.fileDeckMarker !== "string") {
throw new Error("File deck marker must be a string.");
throw new PluginUserError("errors.settings.fileDeckMarkerString");
}
if (typeof settings.fileDeckTemplate !== "string") {
throw new Error("File deck template must be a string.");
throw new PluginUserError("errors.settings.fileDeckTemplateString");
}
if (settings.fileDeckInsertLocation !== "yaml" && settings.fileDeckInsertLocation !== "body") {
throw new Error("File deck insert location must be yaml or body.");
throw new PluginUserError("errors.settings.fileDeckInsertLocationInvalid");
}
if (settings.folderDeckMode !== "off" && settings.folderDeckMode !== "folder" && settings.folderDeckMode !== "folder-and-file") {
throw new Error("Folder deck mode must be off, folder, or folder-and-file.");
throw new PluginUserError("errors.settings.folderDeckModeInvalid");
}
if (settings.scopeMode !== "all" && settings.scopeMode !== "include" && settings.scopeMode !== "exclude") {
throw new Error("Scope mode must be one of all, include, or exclude.");
throw new PluginUserError("errors.settings.scopeModeInvalid");
}
validateFolderList(settings.includeFolders, "Include folders");
validateFolderList(settings.excludeFolders, "Exclude folders");
validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude");
if (!settings.ankiConnectUrl.trim()) {
throw new Error("AnkiConnect URL is required.");
throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
}
}
function validateFolderList(folderList: string[], label: string): void {
function validateFolderList(folderList: string[], label: "include" | "exclude"): void {
if (!Array.isArray(folderList)) {
throw new Error(`${label} must be an array.`);
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray");
}
for (const folder of folderList) {
if (typeof folder !== "string") {
throw new Error(`${label} must only contain strings.`);
throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersStrings" : "errors.settings.excludeFoldersStrings");
}
}
}
function validateNoteFieldMappings(noteFieldMappings: Record<string, NoteModelFieldMapping>): void {
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
throw new Error("Note field mappings must be an object.");
throw new PluginUserError("errors.settings.noteFieldMappingsObject");
}
for (const mapping of Object.values(noteFieldMappings)) {
if (mapping.cardType !== "basic" && mapping.cardType !== "cloze" && mapping.cardType !== "semantic-qa") {
throw new Error("Note field mappings must use a supported card type.");
throw new PluginUserError("errors.settings.noteFieldMappingsCardType");
}
if (!mapping.modelName.trim()) {
throw new Error("Note field mappings must include a model name.");
throw new PluginUserError("errors.settings.noteFieldMappingsModelName");
}
if (!Array.isArray(mapping.loadedFieldNames)) {
throw new Error("Note field mappings must include loaded field names.");
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedFieldNames");
}
if (!Number.isFinite(mapping.loadedAt)) {
throw new Error("Note field mappings must include a loaded timestamp.");
throw new PluginUserError("errors.settings.noteFieldMappingsLoadedAt");
}
}
}

View file

@ -0,0 +1,71 @@
import * as obsidian from "obsidian";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PluginUserError, renderPluginFileFailure, renderPluginFileFailuresInline, renderUnknownUserFacingError, renderUserFacingMessage, renderUserMessage } from "./PluginUserError";
describe("PluginUserError", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders plugin-owned errors in English", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
expect(renderUserMessage(error)).toBe("The current file is outside the plugin scope: notes/example.md");
});
it("renders plugin-owned errors in Simplified Chinese", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
expect(renderUserMessage(error)).toBe("当前文件不在插件作用范围内notes/example.md");
});
it("renders write-back failure summaries with localized detail lines", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
const error = new PluginUserError(
"errors.writeBack.summary",
{ fileCount: 2 },
{
failures: [
{ filePath: "a.md", key: "errors.writeBack.missingSourceContent" },
{ filePath: "b.md", rawMessage: "permission denied" },
],
},
);
expect(renderUserMessage(error)).toBe([
"Markdown 标记写回失败,共 2 个文件。",
"a.md: 缺少用于写回标记的扫描源码内容。",
"b.md: permission denied",
].join("\n"));
});
it("renders raw and keyed user-facing messages plus inline failure lists", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
expect(renderUserFacingMessage({ key: "notice.failedSavePluginSettings" })).toBe("Failed to save plugin settings.");
expect(renderUserFacingMessage({ rawMessage: "raw failure" })).toBe("raw failure");
expect(renderPluginFileFailure({ filePath: "a.md", key: "errors.markerRemoval.markdownFileNotFound" })).toBe("Markdown file was not found.");
expect(renderPluginFileFailuresInline([
{ filePath: "a.md", key: "errors.markerRemoval.markdownFileNotFound" },
{ filePath: "b.md", rawMessage: "permission denied" },
])).toBe("a.md (Markdown file was not found.), b.md (permission denied)");
});
it("falls back to raw error messages for unknown errors", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
expect(renderUnknownUserFacingError(new Error("socket closed"), "notice.vaultSyncFailed")).toBe("socket closed");
});
it("falls back to a translated default when the value is not an Error", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
expect(renderUnknownUserFacingError(null, "notice.vaultSyncFailed")).toBe("Vault sync failed.");
});
});

View file

@ -0,0 +1,115 @@
import { formatList, resolvePluginLocale, t, type MessageParams, type TranslationKey } from "@/presentation/i18n";
export interface PluginUserMessage {
key: TranslationKey;
params?: MessageParams;
}
export interface RawUserMessage {
rawMessage: string;
}
export type UserFacingMessage = PluginUserMessage | RawUserMessage;
export type PluginFileFailure =
| ({ filePath: string } & PluginUserMessage)
| { filePath: string; rawMessage: string };
interface PluginUserErrorDetails {
failures?: PluginFileFailure[];
}
export class PluginUserError extends Error {
readonly userMessage: PluginUserMessage;
readonly details?: PluginUserErrorDetails;
constructor(key: TranslationKey, params?: MessageParams, details?: PluginUserErrorDetails) {
super(key);
this.name = "PluginUserError";
this.userMessage = { key, params };
this.details = details;
}
}
export function isPluginUserError(value: unknown): value is PluginUserError {
return value instanceof PluginUserError;
}
export function renderUserFacingMessage(message: UserFacingMessage): string {
if ("rawMessage" in message) {
return message.rawMessage;
}
return t(message.key, message.params);
}
export function renderPluginFileFailure(failure: PluginFileFailure): string {
if ("rawMessage" in failure) {
return failure.rawMessage;
}
return t(failure.key, failure.params);
}
export function renderUserMessage(error: PluginUserError): string {
const message = renderUserFacingMessage(error.userMessage);
if (!error.details?.failures?.length) {
return message;
}
const failureLines = error.details.failures
.map((failure) => `${failure.filePath}: ${renderPluginFileFailure(failure)}`)
.join("\n");
return `${message}\n${failureLines}`;
}
export function toUserFacingMessage(error: unknown, fallbackKey: TranslationKey, fallbackParams?: MessageParams): UserFacingMessage {
if (isPluginUserError(error)) {
return error.userMessage;
}
if (error instanceof Error) {
return { rawMessage: error.message };
}
return { key: fallbackKey, params: fallbackParams };
}
export function renderUnknownUserFacingError(error: unknown, fallbackKey: TranslationKey): string {
if (isPluginUserError(error)) {
return renderUserMessage(error);
}
return renderUserFacingMessage(toUserFacingMessage(error, fallbackKey));
}
export function toPluginFileFailure(filePath: string, error: unknown): PluginFileFailure {
if (isPluginUserError(error)) {
return {
filePath,
...error.userMessage,
};
}
if (error instanceof Error) {
return {
filePath,
rawMessage: error.message,
};
}
return {
filePath,
rawMessage: String(error),
};
}
export function renderPluginFileFailuresInline(failures: PluginFileFailure[]): string {
const locale = resolvePluginLocale();
return formatList(
locale,
failures.map((failure) => `${failure.filePath} (${renderPluginFileFailure(failure)})`),
);
}

View file

@ -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,
});
}
}

View file

@ -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)],
};
}
}

View file

@ -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)],
};
}
}

View file

@ -1,5 +1,6 @@
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import { PluginUserError, toPluginFileFailure, type PluginFileFailure } from "@/application/errors/PluginUserError";
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
import type { PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
@ -9,7 +10,7 @@ import { GroupMarkerService, type GroupMarkerWriteRequest, serializeGroupMarker
export interface MarkdownWriteBackResult {
writtenSyncKeys: string[];
conflictFiles: string[];
failureFiles: Array<{ filePath: string; message: string }>;
failureFiles: PluginFileFailure[];
pendingEntries: PendingWriteBackState[];
}
@ -28,7 +29,7 @@ export class MarkdownWriteBackService {
const plannedByFile = new Map<string, Array<{ kind: "card"; plannedCard: PlannedCard } | { kind: "group"; write: GroupMarkerWriteRequest }>>();
const writtenSyncKeys: string[] = [];
const conflictFiles: string[] = [];
const failureFiles: Array<{ filePath: string; message: string }> = [];
const failureFiles: PluginFileFailure[] = [];
const pendingEntries: PendingWriteBackState[] = [];
for (const plannedCard of plannedCards) {
@ -57,7 +58,7 @@ export class MarkdownWriteBackService {
if (!sourceContent || !indexedFile) {
pendingEntries.push(...fileWrites.map((fileWrite) => this.createPendingEntry(filePath, fileWrite, indexedFile?.fileHash ?? "")));
failureFiles.push({ filePath, message: `Missing scanned source content for ${filePath}.` });
failureFiles.push({ filePath, key: "errors.writeBack.missingSourceContent" });
continue;
}
@ -95,10 +96,7 @@ export class MarkdownWriteBackService {
continue;
}
failureFiles.push({
filePath,
message: error instanceof Error ? error.message : String(error),
});
failureFiles.push(toPluginFileFailure(filePath, error));
}
}
@ -168,7 +166,9 @@ function getRawBlockHashFromSyncKey(syncKey: string): string {
function requireNoteId(plannedCard: PlannedCard): number {
if (plannedCard.noteId === undefined) {
throw new Error(`Cannot write marker without noteId for block ${plannedCard.card.filePath}:${plannedCard.card.blockStartLine}.`);
throw new PluginUserError("errors.writeBack.noteIdMissing", {
blockStartLine: plannedCard.card.blockStartLine,
});
}
return plannedCard.noteId;

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import { PluginUserError } from "@/application/errors/PluginUserError";
import type { CardType } from "@/domain/card/entities/RenderedFields";
import { NoteFieldMappingService } from "./NoteFieldMappingService";
@ -26,6 +27,17 @@ function createCard(overrides: Partial<RenderedCardInput>): RenderedCardInput {
};
}
function expectPluginUserError(action: () => void): PluginUserError {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(PluginUserError);
return error as PluginUserError;
}
throw new Error("Expected PluginUserError");
}
describe("NoteFieldMappingService", () => {
it("maps a basic card using a saved title/body mapping", () => {
const service = new NoteFieldMappingService();
@ -83,15 +95,18 @@ describe("NoteFieldMappingService", () => {
it("throws when a mapping is missing", () => {
const service = new NoteFieldMappingService();
expect(() =>
const error = expectPluginUserError(() =>
service.map(createCard({}), { fieldNames: ["Front", "Back"], isCloze: false }, {}),
).toThrow("Open plugin settings and read fields from Anki first");
);
expect(error.userMessage.key).toBe("errors.noteFieldMapping.missingSavedMapping.basic");
expect(error.userMessage.params).toEqual({ modelName: "Basic" });
});
it("throws when a saved mapping is stale", () => {
const service = new NoteFieldMappingService();
expect(() =>
const error = expectPluginUserError(() =>
service.map(
createCard({}),
{ fieldNames: ["Front", "Body"], isCloze: false },
@ -106,13 +121,19 @@ describe("NoteFieldMappingService", () => {
},
},
),
).toThrow("is stale because these fields no longer exist in Anki");
);
expect(error.userMessage.key).toBe("errors.noteFieldMapping.stale");
expect(error.userMessage.params).toEqual({
modelName: "Basic",
fields: ["\"Back\""],
});
});
it("throws when a basic mapping reuses the same field for title and body", () => {
const service = new NoteFieldMappingService();
expect(() =>
const error = expectPluginUserError(() =>
service.map(
createCard({}),
{ fieldNames: ["Front", "Back"], isCloze: false },
@ -127,6 +148,9 @@ describe("NoteFieldMappingService", () => {
},
},
),
).toThrow('must use different title and body fields');
);
expect(error.userMessage.key).toBe("errors.noteFieldMapping.titleBodyMustDiffer.basic");
expect(error.userMessage.params).toEqual({ modelName: "Basic" });
});
});

View file

@ -1,5 +1,6 @@
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { PluginUserError } from "@/application/errors/PluginUserError";
import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields";
interface RenderedCardInput {
@ -58,35 +59,45 @@ export class NoteFieldMappingService {
if (isBasicLikeCardType(mapping.cardType)) {
if (!mapping.titleField || !mapping.bodyField) {
throw new Error(
`Saved field mapping for ${describeBasicLikeCardType(mapping.cardType)} note type "${mapping.modelName}" is incomplete. Open plugin settings and save both title and body fields.`,
);
throw new PluginUserError(getIncompleteSavedMappingKey(mapping.cardType), {
modelName: mapping.modelName,
});
}
if (mapping.titleField === mapping.bodyField) {
throw new Error(`${capitalizeFirstLetter(describeBasicLikeCardType(mapping.cardType))} note type "${mapping.modelName}" must use different title and body fields.`);
throw new PluginUserError(getTitleBodyMustDifferKey(mapping.cardType), {
modelName: mapping.modelName,
});
}
const missingFields = [mapping.titleField, mapping.bodyField].filter((fieldName) => !availableFields.has(fieldName));
if (missingFields.length > 0) {
throw new Error(this.createStaleMappingError(mapping.modelName, missingFields));
throw new PluginUserError("errors.noteFieldMapping.stale", {
modelName: mapping.modelName,
fields: missingFields.map((fieldName) => `"${fieldName}"`),
});
}
return;
}
if (!mapping.mainField) {
throw new Error(
`Saved field mapping for cloze note type "${mapping.modelName}" is incomplete. Open plugin settings and save the main field.`,
);
throw new PluginUserError("errors.noteFieldMapping.incompleteSavedMapping.cloze", {
modelName: mapping.modelName,
});
}
if (!noteModelDetails.isCloze) {
throw new Error(`Cloze note type "${mapping.modelName}" is not cloze-compatible in Anki.`);
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
modelName: mapping.modelName,
});
}
if (!availableFields.has(mapping.mainField)) {
throw new Error(this.createStaleMappingError(mapping.modelName, [mapping.mainField]));
throw new PluginUserError("errors.noteFieldMapping.stale", {
modelName: mapping.modelName,
fields: [`"${mapping.mainField}"`],
});
}
}
@ -95,7 +106,9 @@ export class NoteFieldMappingService {
const bodyFieldName = mapping.bodyField;
if (!titleFieldName || !bodyFieldName) {
throw new Error(`Saved field mapping for basic note type "${card.noteModel}" is incomplete.`);
throw new PluginUserError(getIncompleteSavedMappingKey(card.type), {
modelName: card.noteModel,
});
}
return {
@ -106,11 +119,15 @@ export class NoteFieldMappingService {
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
if (!noteModelDetails.isCloze) {
throw new Error(`Cloze note type "${card.noteModel}" is not cloze-compatible in Anki.`);
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
modelName: card.noteModel,
});
}
if (!mapping.mainField) {
throw new Error(`Saved field mapping for cloze note type "${card.noteModel}" is incomplete.`);
throw new PluginUserError("errors.noteFieldMapping.incompleteSavedMapping.cloze", {
modelName: card.noteModel,
});
}
return {
@ -125,34 +142,39 @@ export class NoteFieldMappingService {
const mapping = noteFieldMappings[createNoteFieldMappingKey(card.type, card.noteModel)];
if (!mapping) {
throw new Error(
`No saved field mapping found for ${describeCardType(card.type)} note type "${card.noteModel}". Open plugin settings and read fields from Anki first.`,
);
throw new PluginUserError(getMissingSavedMappingKey(card.type), {
modelName: card.noteModel,
});
}
return mapping;
}
private createStaleMappingError(modelName: string, missingFields: string[]): string {
const quotedMissingFields = missingFields.map((fieldName) => `"${fieldName}"`).join(", ");
return `Saved field mapping for note type "${modelName}" is stale because these fields no longer exist in Anki: ${quotedMissingFields}. Open plugin settings and read fields from Anki again.`;
}
}
function describeCardType(cardType: CardType): string {
function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" {
if (cardType === "cloze") {
return "cloze";
return "errors.noteFieldMapping.missingSavedMapping.cloze";
}
return describeBasicLikeCardType(cardType);
return cardType === "semantic-qa"
? "errors.noteFieldMapping.missingSavedMapping.semanticQa"
: "errors.noteFieldMapping.missingSavedMapping.basic";
}
function describeBasicLikeCardType(cardType: Extract<CardType, "basic" | "semantic-qa">): string {
return cardType === "semantic-qa" ? "semantic QA" : "basic";
function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" {
if (cardType === "cloze") {
return "errors.noteFieldMapping.incompleteSavedMapping.cloze";
}
return cardType === "semantic-qa"
? "errors.noteFieldMapping.incompleteSavedMapping.semanticQa"
: "errors.noteFieldMapping.incompleteSavedMapping.basic";
}
function capitalizeFirstLetter(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
function getTitleBodyMustDifferKey(cardType: Extract<CardType, "basic" | "semantic-qa">): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" {
return cardType === "semantic-qa"
? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa"
: "errors.noteFieldMapping.titleBodyMustDiffer.basic";
}
function findFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {

View file

@ -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;

View file

@ -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 {

View file

@ -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,
},
});
}

View file

@ -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");
});
});

View file

@ -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("::");

View file

@ -44,7 +44,6 @@ export class DeckResolutionService {
{
filePath: card.filePath,
code: "deck_fallback_default",
message: "该文件没有显式 deck且所在位置无法生成文件夹牌组已回退到默认 deck。",
},
],
};

View file

@ -39,7 +39,6 @@ export class FolderDeckMappingService {
warnings: [{
filePath,
code: "deck_invalid_folder_segment",
message: "检测到文件夹名或文件名包含 ::,本次已放弃文件夹映射并回退到默认 deck。",
}],
};
}

View file

@ -6,6 +6,8 @@ export type DeckResolutionWarningCode =
| "deck_invalid_folder_segment"
| "deck_fallback_default";
export type DeckResolutionWarningParams = Record<string, string | number | boolean>;
export interface ResolvedDeck {
value: string;
source: DeckResolutionSource;
@ -14,7 +16,7 @@ export interface ResolvedDeck {
export interface DeckResolutionWarning {
filePath: string;
code: DeckResolutionWarningCode;
message: string;
params?: DeckResolutionWarningParams;
}
export interface ExplicitDeckExtractionResult {
@ -31,5 +33,25 @@ export interface DeckResolutionResult {
}
export function getDeckResolutionWarningKey(warning: DeckResolutionWarning): string {
return `${warning.filePath}\u0000${warning.code}\u0000${warning.message}`;
return `${warning.filePath}\u0000${warning.code}\u0000${stableStringify(warning.params)}`;
}
function stableStringify(value: unknown): string {
if (value === undefined) {
return "";
}
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
return `{${entries.join(",")}}`;
}

View file

@ -14,7 +14,9 @@ import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGat
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository";
import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository";
import { registerCommands } from "@/presentation/commands/registerCommands";
import { renderUnknownUserFacingError, renderUserMessage } from "@/application/errors/PluginUserError";
import { EmptyDeckSelectionModal } from "@/presentation/modals/EmptyDeckSelectionModal";
import { t } from "@/presentation/i18n";
import { NoticeService } from "@/presentation/notices/NoticeService";
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService";
@ -48,7 +50,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
} catch (error) {
console.error("Failed to load plugin settings, falling back to defaults.", error);
this.settings = DEFAULT_SETTINGS;
this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory.");
this.noticeService.error(t("notice.invalidSettingsRestored"));
}
const manualSyncService = new ManualSyncService(vaultGateway, pluginStateRepository, this.ankiGateway);
@ -76,7 +78,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
await this.pluginConfigRepository.save(nextSettings);
this.settings = nextSettings;
} catch (error) {
this.noticeService.error(error instanceof Error ? error.message : "Failed to save plugin settings.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.failedSavePluginSettings"));
}
}
@ -100,56 +102,56 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
this.noticeService.error("No active Markdown file is available for sync.");
this.noticeService.error(t("notice.noActiveMarkdownForSync"));
return;
}
if (!this.syncCurrentFileUseCase) {
this.noticeService.error("Sync use case is not initialized.");
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
return;
}
try {
const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings);
this.noticeService.showSyncSummary("当前文件同步完成", result);
this.noticeService.showSyncSummary("currentFile", result);
} catch (error) {
if (error instanceof CurrentFileOutOfScopeError) {
this.noticeService.info("当前文件不在插件作用范围内");
this.noticeService.info(renderUserMessage(error));
return;
}
console.error("Current file sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.currentFileSyncFailed"));
}
}
async runSyncVault(): Promise<void> {
if (!this.syncVaultUseCase) {
this.noticeService.error("Sync use case is not initialized.");
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
return;
}
try {
const result = await this.syncVaultUseCase.execute(this.settings);
this.noticeService.showSyncSummary("全库同步完成", result);
this.noticeService.showSyncSummary("vault", result);
} catch (error) {
console.error("Vault sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.vaultSyncFailed"));
}
}
async runRebuildCardIndex(): Promise<void> {
if (!this.rebuildCardIndexUseCase) {
this.noticeService.error("Sync use case is not initialized.");
this.noticeService.error(t("notice.syncUseCaseNotInitialized"));
return;
}
try {
const result = await this.rebuildCardIndexUseCase.execute(this.settings);
this.noticeService.showRebuildSummary("卡片索引重建完成", result);
this.noticeService.showRebuildSummary(result);
} catch (error) {
console.error("Card index rebuild failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Card index rebuild failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.rebuildFailed"));
}
}
@ -157,19 +159,19 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
this.noticeService.error("No active Markdown file is available for deck template insertion.");
this.noticeService.error(t("notice.noActiveMarkdownForDeckTemplateInsertion"));
return;
}
if (!this.vaultGateway) {
this.noticeService.error("Vault gateway is not initialized.");
this.noticeService.error(t("notice.vaultGatewayNotInitialized"));
return;
}
try {
const sourceFile = await this.vaultGateway.readMarkdownFile(activeFile.path);
if (!sourceFile) {
this.noticeService.error(`Markdown file not found: ${activeFile.path}`);
this.noticeService.error(t("notice.markdownFileNotFound", { filePath: activeFile.path }));
return;
}
@ -181,15 +183,15 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
);
if (insertionResult.nextContent === insertionResult.expectedContent) {
this.noticeService.info("当前文件中的 deck 模板已是最新,无需重复写入。");
this.noticeService.info(t("notice.deckTemplateAlreadyCurrent"));
return;
}
await this.vaultGateway.replaceMarkdownFile(activeFile.path, insertionResult.expectedContent, insertionResult.nextContent);
this.noticeService.info(`已向当前文件写入 deck 模板:${insertionResult.insertedDeck}`);
this.noticeService.info(t("notice.deckTemplateInserted", { deck: insertionResult.insertedDeck }));
} catch (error) {
console.error("Deck template insertion failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Deck template insertion failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.deckTemplateInsertionFailed"));
}
}
@ -197,54 +199,54 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
this.noticeService.error("No active Markdown file is available for reset.");
this.noticeService.error(t("notice.noActiveMarkdownForReset"));
return;
}
if (!this.clearCurrentFileSyncedCardsUseCase) {
this.noticeService.error("Clear-current-file use case is not initialized.");
this.noticeService.error(t("notice.clearCurrentFileUseCaseNotInitialized"));
return;
}
try {
const hasTrackedCards = await this.clearCurrentFileSyncedCardsUseCase.hasTrackedCards(activeFile.path);
if (!hasTrackedCards) {
this.noticeService.info("当前文件没有已同步卡片");
this.noticeService.info(t("notice.noTrackedCardsInCurrentFile"));
return;
}
const result = await this.clearCurrentFileSyncedCardsUseCase.execute(activeFile.path);
this.noticeService.showClearCurrentFileSummary("当前文件已同步卡片清空完成", result);
this.noticeService.showClearCurrentFileSummary(result);
} catch (error) {
console.error("Clear current file synced cards failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Clear current file synced cards failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.clearCurrentFileFailed"));
}
}
async runCleanupEmptyDecks(): Promise<void> {
if (!this.cleanupEmptyDecksUseCase) {
this.noticeService.error("Empty-deck cleanup use case is not initialized.");
this.noticeService.error(t("notice.cleanupEmptyDecksUseCaseNotInitialized"));
return;
}
try {
const candidateDeckNames = await this.cleanupEmptyDecksUseCase.listCandidates();
if (candidateDeckNames.length === 0) {
this.noticeService.info("未发现可清理的空牌组");
this.noticeService.info(t("notice.noEmptyDeckCandidates"));
return;
}
const selectedDeckNames = await new EmptyDeckSelectionModal(this.app, candidateDeckNames).openAndGetSelection();
if (selectedDeckNames === null) {
this.noticeService.info("已取消空牌组清理");
this.noticeService.info(t("notice.cleanupEmptyDecksCancelled"));
return;
}
const result = await this.cleanupEmptyDecksUseCase.execute(selectedDeckNames, candidateDeckNames);
this.noticeService.showCleanupEmptyDecksSummary("空牌组清理完成", result);
this.noticeService.showCleanupEmptyDecksSummary(result);
} catch (error) {
console.error("Cleanup empty decks failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Cleanup empty decks failed.");
this.noticeService.error(renderUnknownUserFacingError(error, "notice.cleanupEmptyDecksFailed"));
}
}
}

View file

@ -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();
},

View file

@ -0,0 +1,59 @@
import { resolvePluginLocale, formatList, type PluginLocale } from "./locale";
import { en } from "./messages/en";
import { zh } from "./messages/zh";
type PrimitiveMessageValue = string | number | boolean | undefined;
type MessageParamValue = PrimitiveMessageValue | PrimitiveMessageValue[];
type NestedTranslationKeys<T> = {
[K in keyof T & string]: T[K] extends string ? K : `${K}.${NestedTranslationKeys<T[K]>}`
}[keyof T & string];
export type TranslationKey = NestedTranslationKeys<typeof en>;
export type MessageParams = Record<string, MessageParamValue>;
function lookupMessage(dictionary: Record<string, unknown>, key: TranslationKey): string | undefined {
const segments = key.split(".");
let current: unknown = dictionary;
for (const segment of segments) {
if (!current || typeof current !== "object" || !(segment in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(template: string, params: MessageParams | undefined, locale: PluginLocale): string {
if (!params) {
return template;
}
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, name: string) => {
const value = params[name];
if (value === undefined) {
return "";
}
if (Array.isArray(value)) {
return formatList(locale, value.filter((entry): entry is PrimitiveMessageValue => entry !== undefined).map((entry) => String(entry)));
}
return String(value);
});
}
export function t(key: TranslationKey, params?: MessageParams): string {
const locale = resolvePluginLocale();
const dictionary = locale === "zh" ? zh : en;
const template = lookupMessage(dictionary as Record<string, unknown>, key)
?? lookupMessage(en as Record<string, unknown>, key)
?? key;
return interpolate(template, params, locale);
}
export { formatList, resolvePluginLocale, type PluginLocale };

View file

@ -0,0 +1,30 @@
import * as obsidian from "obsidian";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatList, resolvePluginLocale } from "./locale";
describe("locale", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns zh only when Obsidian language is exactly zh", () => {
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
expect(resolvePluginLocale()).toBe("zh");
});
it("falls back to en for every other Obsidian language code", () => {
const getLanguage = vi.spyOn(obsidian, "getLanguage");
for (const language of ["en", "en-GB", "ja", "zh-TW"]) {
getLanguage.mockReturnValue(language);
expect(resolvePluginLocale()).toBe("en");
}
});
it("formats lists with locale-specific separators", () => {
expect(formatList("en", ["A", "B", "C"])).toBe("A, B, C");
expect(formatList("zh", ["甲", "乙", "丙"])).toBe("甲、乙、丙");
});
});

View file

@ -0,0 +1,11 @@
import { getLanguage } from "obsidian";
export type PluginLocale = "en" | "zh";
export function resolvePluginLocale(): PluginLocale {
return getLanguage() === "zh" ? "zh" : "en";
}
export function formatList(locale: PluginLocale, items: string[]): string {
return items.join(locale === "zh" ? "、" : ", ");
}

View file

@ -0,0 +1,305 @@
export type MessageTree = {
[key: string]: string | MessageTree;
};
export const en = {
commands: {
syncCurrentFileToAnki: "Sync current file to Anki",
syncVaultToAnki: "Sync vault to Anki",
rebuildCardIndex: "Rebuild card index",
clearCurrentFileSyncedCards: "Clear synced cards in current file",
cleanupEmptyDecks: "Clean up empty decks",
},
settings: {
pluginTitle: "Anki Heading Sync",
ankiConnectUrl: {
name: "AnkiConnect URL",
desc: "Default is http://127.0.0.1:8765",
placeholder: "http://127.0.0.1:8765",
},
qaHeadingLevel: {
name: "QA heading level",
desc: "Default is H4",
},
clozeHeadingLevel: {
name: "Cloze heading level",
desc: "Default is H5",
},
qaGroup: {
title: "QA Group 12",
marker: {
name: "QA Group marker",
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.",
placeholder: "#anki-list",
},
managedNoteType: "Managed note type: {{modelName}}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.",
managedModelContract: "Managed model contract: {{fieldCount}} fields and {{templateCount}} templates are checked automatically during sync.",
},
semanticQa: {
title: "Semantic QA",
marker: {
name: "Semantic QA marker",
desc: "When a QA heading ends with this hashtag marker, first-level list items with indented child content become separate cards.",
placeholder: "#anki-list-qa",
},
previewTitle: "Semantic QA preview",
triggerHeadingExample: "Trigger heading example: {{heading}}",
previewUnavailable: "Preview unavailable. Use a trailing hashtag marker such as #anki-list-qa.",
questionPreview: "Question preview: {{question}}",
answerPreview: "Answer preview: {{answer}}",
},
syncOptions: {
addObsidianBacklink: {
name: "Add Obsidian backlink",
desc: "Append a backlink to the source heading into synced cards.",
},
highlightsToCloze: {
name: "Highlights to Cloze",
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
},
},
mapping: {
title: "Note type field mappings",
refresh: {
name: "Refresh note types from Anki",
button: "Refresh note types",
},
status: {
idle: "Refresh note types from Anki to load the available note types.",
loadedCount: "Loaded {{count}} note types from Anki.",
empty: "Anki returned no note types.",
failedLoadNoteTypes: "Failed to load note types from Anki.",
loadedSavedMapping: "Loaded saved mapping for {{modelName}}.",
selectedModel: "Selected {{modelName}}. Read fields from Anki to create or refresh its mapping.",
loadedFieldsForModel: "Loaded fields for {{modelName}}. Review the suggested mapping and save it.",
failedLoadFields: "Failed to load fields for {{modelName}}.",
noLoadedFields: "No loaded fields for {{modelName}}. Read fields from Anki first.",
savedMapping: "Saved mapping for {{modelName}}.",
failedSaveMapping: "Failed to save mapping for {{modelName}}.",
savedMappingReady: "Saved mapping is ready for {{title}}.",
noSavedMapping: "No saved mapping for {{title}}. Read fields from Anki first.",
},
section: {
basic: {
title: "QA / Basic",
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
},
cloze: {
title: "Cloze",
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
},
semanticQa: {
title: "Semantic QA",
description: "Choose the semantic QA note type, read its fields from Anki, then confirm the title/body mapping for child cards.",
},
},
noteTypeLabel: "{{title}} note type",
noteTypeDesc: "Loaded from Anki note types. Refresh if the latest models are not shown.",
fieldsLabel: "{{title}} fields",
fieldsDesc: "Load the current note type fields from Anki, then review the suggested mapping.",
readFieldsButton: "Read fields from Anki",
loadedFields: "Loaded fields: {{fields}}",
loadedFieldsNone: "Loaded fields: none. Read fields from Anki first.",
mappingLabel: "{{title}} mapping",
mappingDesc: "Save the current field mapping for this specific note type.",
saveMappingButton: "Save mapping",
titleFieldLabel: "{{title}} title field",
titleFieldDesc: "Which Anki field should receive the heading/title fragment.",
bodyFieldLabel: "{{title}} body field",
bodyFieldDesc: "Which Anki field should receive the body fragment.",
mainField: {
name: "Cloze main field",
desc: "The selected field receives title + <br><br> + body during sync.",
},
selectFieldPlaceholder: "-- Select field --",
},
deck: {
defaultSectionTitle: "Default deck",
defaultDeck: {
name: "Default deck",
desc: "Lowest priority: used when neither a file-level deck nor a folder mapping matches.",
},
fileDeckSectionTitle: "File-level custom deck",
fileDeckEnabled: {
name: "Enable file-level custom deck",
desc: "When enabled, a shared marker can define one deck for the whole note in YAML or body.",
},
marker: {
name: "Deck marker name",
desc: "YAML key and body marker share the same identifier. The default value is TARGET DECK.",
},
template: {
name: "Default deck template",
desc: "Only the filename variable is supported. It expands to the current file name without .md when inserted.",
},
insertLocation: {
name: "Template insert location",
desc: "Choose whether the deck template is written into YAML frontmatter or the body header area.",
options: {
body: "Body header",
yaml: "YAML frontmatter",
},
},
insertTemplate: {
name: "Insert deck template into the current file",
desc: "Write the deck declaration into the current active Markdown file using the current marker, template, and insert location.",
button: "Insert deck template",
},
folderMappingSectionTitle: "Advanced: folder mapping",
folderDeckMode: {
name: "Folder mapping mode",
desc: "Off / parent folders only / parent folders plus current file name.",
options: {
off: "Off",
folder: "Folder-based mapping",
folderAndFile: "Folder-and-file mapping",
},
},
folderExample: "Folder-based mapping example: 数学/第一章/第一节.md -> 数学::第一章",
folderAndFileExample: "Folder-and-file mapping example: 数学/第一章/第一节.md -> 数学::第一章::第一节",
priorityTitle: "Final priority",
priorityDesc: "Final deck priority: file-level custom deck > folder-mapped deck > default deck. Existing-card deck behavior stays aligned with the current sync implementation.",
},
scope: {
name: "Run scope",
summary: {
all: "Process Markdown files across the whole vault",
include: "Only process Markdown files in the checked folders below",
exclude: "Process the whole vault, but skip Markdown files in the checked folders below",
},
option: {
all: "All files",
include: "Only in selected folders",
exclude: "Exclude selected folders",
},
treeDescription: {
include: "Only in selected folders: only process Markdown files in the checked folders below",
exclude: "Exclude selected folders: process the whole vault, but skip Markdown files in the checked folders below",
},
loading: "Loading folders from the current vault...",
empty: "There are no selectable folders in the current vault.",
failedLoad: "Failed to load folders from the current vault.",
expandFolder: "Expand {{name}}",
collapseFolder: "Collapse {{name}}",
},
},
modal: {
emptyDeck: {
title: "Clean up empty decks",
description: "Select the empty decks to delete. A deck is deleted only if it is still empty at deletion time.",
selectAll: "Select all",
clearAll: "Clear all",
invert: "Invert selection",
cancel: "Cancel",
deleteSelected: "Delete selected empty decks",
selectionCount: "Selected {{selected}} / {{total}} empty decks",
},
},
notice: {
invalidSettingsRestored: "Invalid plugin settings were detected. Default settings were restored in memory.",
failedSavePluginSettings: "Failed to save plugin settings.",
noActiveMarkdownForSync: "No active Markdown file is available for sync.",
syncUseCaseNotInitialized: "Sync use case is not initialized.",
currentFileSyncFailed: "Current file sync failed.",
vaultSyncFailed: "Vault sync failed.",
rebuildFailed: "Card index rebuild failed.",
noActiveMarkdownForDeckTemplateInsertion: "No active Markdown file is available for deck template insertion.",
vaultGatewayNotInitialized: "Vault gateway is not initialized.",
markdownFileNotFound: "Markdown file not found: {{filePath}}",
deckTemplateAlreadyCurrent: "The deck template in the current file is already up to date.",
deckTemplateInserted: "Inserted deck template into the current file: {{deck}}",
deckTemplateInsertionFailed: "Deck template insertion failed.",
noActiveMarkdownForReset: "No active Markdown file is available for reset.",
clearCurrentFileUseCaseNotInitialized: "Clear-current-file use case is not initialized.",
noTrackedCardsInCurrentFile: "There are no synced cards in the current file.",
clearCurrentFileFailed: "Clear current file synced cards failed.",
cleanupEmptyDecksUseCaseNotInitialized: "Empty-deck cleanup use case is not initialized.",
noEmptyDeckCandidates: "No empty decks were found for cleanup.",
cleanupEmptyDecksCancelled: "Empty-deck cleanup was cancelled.",
cleanupEmptyDecksFailed: "Cleanup empty decks failed.",
summary: {
currentFileSync: "Current file sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
vaultSync: "Vault sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.",
rebuild: "Card index rebuild completed: files {{scannedFiles}}, cards {{scannedCards}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, rewritten markers {{rewrittenMarkers}}, skipped {{skippedUnchangedCards}}.",
clearCurrentFile: "Current file synced cards cleared: tracked cards {{trackedCards}}, tracked groups {{trackedGroups}}, deleted notes {{deletedNotes}}, removed markers {{removedMarkers}}, removed ID markers {{removedCardMarkers}}, removed GI markers {{removedGroupMarkers}}, deleted local records {{deletedLocalRecords}}.",
cleanupEmptyDecks: "Empty-deck cleanup completed: candidates {{candidateCount}}, selected {{selectedCount}}, deleted {{deletedCount}}, skipped {{skippedCount}}.",
markerWriteConflicts: "Marker write conflicts: {{files}}.",
warningsCount: "Warnings: {{count}}.",
markerRemovalConflicts: "Marker removal conflicts: {{files}}.",
failures: "Failures: {{items}}.",
skippedDecks: "Skipped decks: {{decks}}.",
},
},
errors: {
currentFileOutOfScope: "The current file is outside the plugin scope: {{filePath}}",
deck: {
emptyName: "Deck name cannot be empty. Check the file-level deck declaration or default deck setting.",
},
settings: {
headingLevelsRange: "Heading levels must be integers between 1 and 6.",
headingLevelsDifferent: "QA and Cloze heading levels must be different.",
qaNoteTypeRequired: "QA note type is required.",
qaGroupMarkerRequired: "QA Group marker is required.",
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.",
clozeNoteTypeRequired: "Cloze note type is required.",
semanticQaMarkerRequired: "Semantic QA marker is required.",
semanticQaMarkerInvalid: "Semantic QA marker must be a hashtag-style token like #anki-list-qa.",
qaGroupMarkerConflict: "QA Group marker must be different from Semantic QA marker.",
semanticQaNoteTypeRequired: "Semantic QA note type is required.",
defaultDeckRequired: "Default deck is required.",
fileDeckEnabledBoolean: "File deck enabled must be a boolean.",
fileDeckMarkerString: "File deck marker must be a string.",
fileDeckTemplateString: "File deck template must be a string.",
fileDeckInsertLocationInvalid: "File deck insert location must be yaml or body.",
folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.",
scopeModeInvalid: "Scope mode must be one of all, include, or exclude.",
includeFoldersArray: "Include folders must be an array.",
includeFoldersStrings: "Include folders must only contain strings.",
excludeFoldersArray: "Exclude folders must be an array.",
excludeFoldersStrings: "Exclude folders must only contain strings.",
ankiConnectUrlRequired: "AnkiConnect URL is required.",
noteFieldMappingsObject: "Note field mappings must be an object.",
noteFieldMappingsCardType: "Note field mappings must use a supported card type.",
noteFieldMappingsModelName: "Note field mappings must include a model name.",
noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.",
noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.",
},
noteFieldMapping: {
missingSavedMapping: {
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
cloze: "No saved field mapping found for cloze note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
semanticQa: "No saved field mapping found for semantic QA note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
},
incompleteSavedMapping: {
basic: "Saved field mapping for basic note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
semanticQa: "Saved field mapping for semantic QA note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
cloze: "Saved field mapping for cloze note type \"{{modelName}}\" is incomplete. Open plugin settings and save the main field.",
},
titleBodyMustDiffer: {
basic: "Basic note type \"{{modelName}}\" must use different title and body fields.",
semanticQa: "Semantic QA note type \"{{modelName}}\" must use different title and body fields.",
},
stale: "Saved field mapping for note type \"{{modelName}}\" is stale because these fields no longer exist in Anki: {{fields}}. Open plugin settings and read fields from Anki again.",
clozeIncompatible: "Cloze note type \"{{modelName}}\" is not cloze-compatible in Anki.",
},
writeBack: {
summary: "Markdown marker write-back failed for {{fileCount}} file(s).",
markdownFileNotFound: "Markdown file was not found.",
missingSourceContent: "Scanned source content is missing for marker write-back.",
noteIdMissing: "Cannot write a marker because note ID is missing for the block at line {{blockStartLine}}.",
},
markerRemoval: {
markdownFileNotFound: "Markdown file was not found.",
},
},
warnings: {
deck: {
conflictYamlBody: "Conflicting {{marker}} declarations were found in YAML and body. This sync used the YAML value.",
multipleBodyDeclarations: "Multiple {{marker}} declarations were found in the file. This sync used the first body declaration.",
invalidFolderSegment: "A folder or file name contains ::, so folder-based deck mapping was skipped and the default deck was used.",
fallbackDefault: "No explicit deck was found and a folder-based deck could not be generated, so the default deck was used.",
},
},
} satisfies MessageTree;
export default en;

View file

@ -0,0 +1,303 @@
import type { en } from "./en";
export const zh = {
commands: {
syncCurrentFileToAnki: "同步当前文件到 Anki",
syncVaultToAnki: "同步全库到 Anki",
rebuildCardIndex: "重建卡片索引",
clearCurrentFileSyncedCards: "清空当前文件已同步卡片",
cleanupEmptyDecks: "清理空牌组",
},
settings: {
pluginTitle: "Anki Heading Sync",
ankiConnectUrl: {
name: "AnkiConnect URL",
desc: "默认是 http://127.0.0.1:8765",
placeholder: "http://127.0.0.1:8765",
},
qaHeadingLevel: {
name: "QA 标题层级",
desc: "默认是 H4",
},
clozeHeadingLevel: {
name: "Cloze 标题层级",
desc: "默认是 H5",
},
qaGroup: {
title: "QA Group 12",
marker: {
name: "QA Group 标记",
desc: "当 QA 标题以这个 hashtag 标记结尾时,整个标题块会作为一张 ObsiAnki QA Group 12 笔记同步。",
placeholder: "#anki-list",
},
managedNoteType: "托管笔记类型:{{modelName}}。QA Group 同步会直接写入 Stem / GroupId / Src / S01..S12。如果你想在其他同步路径里复用它它也会出现在下面的字段映射面板中。",
managedModelContract: "托管模型约束:同步时会自动检查 {{fieldCount}} 个字段和 {{templateCount}} 个模板。",
},
semanticQa: {
title: "语义 QA",
marker: {
name: "语义 QA 标记",
desc: "当 QA 标题以这个 hashtag 标记结尾时,带缩进子内容的一级列表项会拆分成独立卡片。",
placeholder: "#anki-list-qa",
},
previewTitle: "语义 QA 预览",
triggerHeadingExample: "触发标题示例:{{heading}}",
previewUnavailable: "当前无法生成预览。请使用类似 #anki-list-qa 的尾随 hashtag 标记。",
questionPreview: "问题预览:{{question}}",
answerPreview: "答案预览:{{answer}}",
},
syncOptions: {
addObsidianBacklink: {
name: "添加 Obsidian 回链",
desc: "把来源标题的回链追加到同步后的卡片中。",
},
highlightsToCloze: {
name: "高亮转 Cloze",
desc: "把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。",
},
},
mapping: {
title: "笔记类型字段映射",
refresh: {
name: "从 Anki 刷新笔记类型",
button: "刷新笔记类型",
},
status: {
idle: "请先从 Anki 刷新笔记类型,以加载可用的笔记类型列表。",
loadedCount: "已从 Anki 加载 {{count}} 个笔记类型。",
empty: "Anki 没有返回任何笔记类型。",
failedLoadNoteTypes: "从 Anki 加载笔记类型失败。",
loadedSavedMapping: "已加载 {{modelName}} 的已保存映射。",
selectedModel: "已选择 {{modelName}}。请从 Anki 读取字段以创建或刷新它的映射。",
loadedFieldsForModel: "已加载 {{modelName}} 的字段。请检查建议映射后保存。",
failedLoadFields: "加载 {{modelName}} 的字段失败。",
noLoadedFields: "{{modelName}} 还没有已加载字段。请先从 Anki 读取字段。",
savedMapping: "已保存 {{modelName}} 的映射。",
failedSaveMapping: "保存 {{modelName}} 的映射失败。",
savedMappingReady: "{{title}} 的已保存映射已就绪。",
noSavedMapping: "{{title}} 还没有已保存映射。请先从 Anki 读取字段。",
},
section: {
basic: {
title: "QA / 基础卡",
description: "选择 QA 笔记类型,从 Anki 读取字段,然后确认标题/正文映射。",
},
cloze: {
title: "Cloze",
description: "选择 Cloze 笔记类型,从 Anki 读取字段,然后确认主字段映射。",
},
semanticQa: {
title: "语义 QA",
description: "选择语义 QA 笔记类型,从 Anki 读取字段,然后确认子卡片的标题/正文映射。",
},
},
noteTypeLabel: "{{title}} 笔记类型",
noteTypeDesc: "从 Anki 的笔记类型列表加载。如果最新模型没有显示,请先刷新。",
fieldsLabel: "{{title}} 字段",
fieldsDesc: "从 Anki 读取当前笔记类型的字段,然后检查建议映射。",
readFieldsButton: "从 Anki 读取字段",
loadedFields: "已加载字段:{{fields}}",
loadedFieldsNone: "已加载字段:无。请先从 Anki 读取字段。",
mappingLabel: "{{title}} 映射",
mappingDesc: "保存当前笔记类型的字段映射。",
saveMappingButton: "保存映射",
titleFieldLabel: "{{title}} 标题字段",
titleFieldDesc: "选择接收标题片段的 Anki 字段。",
bodyFieldLabel: "{{title}} 正文字段",
bodyFieldDesc: "选择接收正文片段的 Anki 字段。",
mainField: {
name: "Cloze 主字段",
desc: "同步时,所选字段会接收 标题 + <br><br> + 正文。",
},
selectFieldPlaceholder: "-- 选择字段 --",
},
deck: {
defaultSectionTitle: "默认牌组",
defaultDeck: {
name: "默认牌组",
desc: "优先级最低:当文件级 deck 与文件夹映射都未命中时使用。",
},
fileDeckSectionTitle: "文件级自定义牌组",
fileDeckEnabled: {
name: "开启文件级自定义牌组",
desc: "开启后,可在 YAML 或正文中用统一 marker 为整篇笔记指定一个 deck。",
},
marker: {
name: "牌组识别名",
desc: "YAML key 与正文 marker 共用同一个识别名。默认值是 TARGET DECK。",
},
template: {
name: "默认牌组模板",
desc: "只支持 filename 变量。插入时会展开为当前文件名(不含 .md。",
},
insertLocation: {
name: "模板插入位置",
desc: "选择将 deck 模板写入 YAML frontmatter 还是正文前部。",
options: {
body: "正文前部",
yaml: "YAML frontmatter",
},
},
insertTemplate: {
name: "向当前文件插入 deck 模板",
desc: "按当前 marker、模板和插入位置把 deck 声明写入当前活动 Markdown 文件。",
button: "插入 deck 模板",
},
folderMappingSectionTitle: "高级:文件夹映射",
folderDeckMode: {
name: "文件夹映射模式",
desc: "关闭 / 仅父文件夹 / 父文件夹加当前文件名。",
options: {
off: "关闭",
folder: "文件夹级映射",
folderAndFile: "文件夹及文件名级映射",
},
},
folderExample: "文件夹级映射示例:数学/第一章/第一节.md -> 数学::第一章",
folderAndFileExample: "文件夹及文件名级映射示例:数学/第一章/第一节.md -> 数学::第一章::第一节",
priorityTitle: "最终优先级说明",
priorityDesc: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡的 deck 行为保持与当前同步实现一致。",
},
scope: {
name: "运行范围",
summary: {
all: "处理整个 vault 中的 Markdown 文件",
include: "仅处理下方勾选文件夹中的 Markdown 文件",
exclude: "处理整个 vault但跳过下方勾选文件夹中的 Markdown 文件",
},
option: {
all: "全部文件",
include: "仅在指定文件夹",
exclude: "排除指定文件夹",
},
treeDescription: {
include: "仅在指定文件夹:只处理下方勾选文件夹中的 Markdown 文件",
exclude: "排除指定文件夹:处理整个 vault但跳过下方勾选文件夹中的 Markdown 文件",
},
loading: "正在读取当前 vault 文件夹...",
empty: "当前 vault 中没有可选文件夹。",
failedLoad: "读取当前 vault 文件夹失败。",
expandFolder: "展开 {{name}}",
collapseFolder: "收起 {{name}}",
},
},
modal: {
emptyDeck: {
title: "清理空牌组",
description: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。",
selectAll: "全选全部",
clearAll: "全部不选",
invert: "反选",
cancel: "取消",
deleteSelected: "删除所选空牌组",
selectionCount: "已选 {{selected}} / 共 {{total}} 个空牌组",
},
},
notice: {
invalidSettingsRestored: "检测到无效的插件设置,已在内存中恢复默认设置。",
failedSavePluginSettings: "保存插件设置失败。",
noActiveMarkdownForSync: "当前没有可用于同步的活动 Markdown 文件。",
syncUseCaseNotInitialized: "同步用例尚未初始化。",
currentFileSyncFailed: "当前文件同步失败。",
vaultSyncFailed: "全库同步失败。",
rebuildFailed: "卡片索引重建失败。",
noActiveMarkdownForDeckTemplateInsertion: "当前没有可用于插入 deck 模板的活动 Markdown 文件。",
vaultGatewayNotInitialized: "Vault 网关尚未初始化。",
markdownFileNotFound: "未找到 Markdown 文件:{{filePath}}",
deckTemplateAlreadyCurrent: "当前文件中的 deck 模板已是最新,无需重复写入。",
deckTemplateInserted: "已向当前文件写入 deck 模板:{{deck}}",
deckTemplateInsertionFailed: "插入 deck 模板失败。",
noActiveMarkdownForReset: "当前没有可用于重置的活动 Markdown 文件。",
clearCurrentFileUseCaseNotInitialized: "清空当前文件用例尚未初始化。",
noTrackedCardsInCurrentFile: "当前文件没有已同步卡片。",
clearCurrentFileFailed: "清空当前文件已同步卡片失败。",
cleanupEmptyDecksUseCaseNotInitialized: "空牌组清理用例尚未初始化。",
noEmptyDeckCandidates: "未发现可清理的空牌组。",
cleanupEmptyDecksCancelled: "已取消空牌组清理。",
cleanupEmptyDecksFailed: "清理空牌组失败。",
summary: {
currentFileSync: "当前文件同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
vaultSync: "全库同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。",
rebuild: "卡片索引重建完成:文件 {{scannedFiles}},卡片 {{scannedCards}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},重写标记 {{rewrittenMarkers}},跳过 {{skippedUnchangedCards}}。",
clearCurrentFile: "当前文件已同步卡片清空完成:已跟踪卡片 {{trackedCards}},已跟踪分组 {{trackedGroups}},删除笔记 {{deletedNotes}},移除标记 {{removedMarkers}},移除 ID 标记 {{removedCardMarkers}},移除 GI 标记 {{removedGroupMarkers}},删除本地记录 {{deletedLocalRecords}}。",
cleanupEmptyDecks: "空牌组清理完成:候选 {{candidateCount}},已选 {{selectedCount}},已删 {{deletedCount}},跳过 {{skippedCount}}。",
markerWriteConflicts: "标记写回冲突:{{files}}。",
warningsCount: "警告:{{count}}。",
markerRemovalConflicts: "标记移除冲突:{{files}}。",
failures: "失败项:{{items}}。",
skippedDecks: "跳过的牌组:{{decks}}。",
},
},
errors: {
currentFileOutOfScope: "当前文件不在插件作用范围内:{{filePath}}",
deck: {
emptyName: "Deck 不能为空,请检查文件级 deck 声明或默认 deck 设置。",
},
settings: {
headingLevelsRange: "标题层级必须是 1 到 6 之间的整数。",
headingLevelsDifferent: "QA 和 Cloze 的标题层级必须不同。",
qaNoteTypeRequired: "QA 笔记类型不能为空。",
qaGroupMarkerRequired: "QA Group 标记不能为空。",
qaGroupMarkerInvalid: "QA Group 标记必须是类似 #anki-list 的 hashtag 样式 token。",
clozeNoteTypeRequired: "Cloze 笔记类型不能为空。",
semanticQaMarkerRequired: "语义 QA 标记不能为空。",
semanticQaMarkerInvalid: "语义 QA 标记必须是类似 #anki-list-qa 的 hashtag 样式 token。",
qaGroupMarkerConflict: "QA Group 标记必须和语义 QA 标记不同。",
semanticQaNoteTypeRequired: "语义 QA 笔记类型不能为空。",
defaultDeckRequired: "默认牌组不能为空。",
fileDeckEnabledBoolean: "文件级 deck 开关必须是布尔值。",
fileDeckMarkerString: "文件级 deck marker 必须是字符串。",
fileDeckTemplateString: "文件级 deck 模板必须是字符串。",
fileDeckInsertLocationInvalid: "文件级 deck 模板插入位置只能是 yaml 或 body。",
folderDeckModeInvalid: "文件夹 deck 模式只能是 off、folder 或 folder-and-file。",
scopeModeInvalid: "运行范围模式只能是 all、include 或 exclude。",
includeFoldersArray: "包含文件夹必须是数组。",
includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
excludeFoldersArray: "排除文件夹必须是数组。",
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
noteFieldMappingsObject: "字段映射必须是对象。",
noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。",
noteFieldMappingsModelName: "字段映射必须包含模型名称。",
noteFieldMappingsLoadedFieldNames: "字段映射必须包含已加载字段名。",
noteFieldMappingsLoadedAt: "字段映射必须包含已加载时间戳。",
},
noteFieldMapping: {
missingSavedMapping: {
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
cloze: "找不到 Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
semanticQa: "找不到语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
},
incompleteSavedMapping: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
cloze: "Cloze 笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存主字段。",
},
titleBodyMustDiffer: {
basic: "基础卡笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
semanticQa: "语义 QA 笔记类型 \"{{modelName}}\" 的标题字段和正文字段必须不同。",
},
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
clozeIncompatible: "Cloze 笔记类型 \"{{modelName}}\" 在 Anki 中不是 cloze 兼容模型。",
},
writeBack: {
summary: "Markdown 标记写回失败,共 {{fileCount}} 个文件。",
markdownFileNotFound: "Markdown 文件不存在。",
missingSourceContent: "缺少用于写回标记的扫描源码内容。",
noteIdMissing: "无法为第 {{blockStartLine}} 行所在块写入标记,因为缺少 note ID。",
},
markerRemoval: {
markdownFileNotFound: "Markdown 文件不存在。",
},
},
warnings: {
deck: {
conflictYamlBody: "检测到 YAML 和正文中的 {{marker}} 声明互相冲突,本次同步已使用 YAML 的值。",
multipleBodyDeclarations: "检测到文件中存在多个 {{marker}} 声明,本次同步只使用第一个正文声明。",
invalidFolderSegment: "检测到文件夹名或文件名包含 ::,已跳过文件夹映射并回退到默认 deck。",
fallbackDefault: "该文件没有显式 deck且无法生成文件夹映射 deck已回退到默认 deck。",
},
},
} satisfies typeof en;
export default zh;

View file

@ -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 个空牌组");
});
});

View file

@ -1,5 +1,7 @@
import { Modal, Setting, type App, type ButtonComponent, type ToggleComponent } from "obsidian";
import { t } from "@/presentation/i18n";
export class EmptyDeckSelectionModal extends Modal {
private readonly deckToggles = new Map<string, ToggleComponent>();
private readonly selectedDeckNames = new Set<string>();
@ -22,8 +24,8 @@ export class EmptyDeckSelectionModal extends Modal {
override onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "清理空牌组" });
contentEl.createEl("p", { text: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。" });
contentEl.createEl("h2", { text: t("modal.emptyDeck.title") });
contentEl.createEl("p", { text: t("modal.emptyDeck.description") });
for (const deckName of this.candidateDeckNames) {
new Setting(contentEl)
@ -43,28 +45,28 @@ export class EmptyDeckSelectionModal extends Modal {
const footerSetting = new Setting(contentEl)
.addButton((button) => {
button.setButtonText("全选全部").onClick(() => {
button.setButtonText(t("modal.emptyDeck.selectAll")).onClick(() => {
this.selectAllDecks();
});
})
.addButton((button) => {
button.setButtonText("全部不选").onClick(() => {
button.setButtonText(t("modal.emptyDeck.clearAll")).onClick(() => {
this.clearSelectedDecks();
});
})
.addButton((button) => {
button.setButtonText("反选").onClick(() => {
button.setButtonText(t("modal.emptyDeck.invert")).onClick(() => {
this.invertSelectedDecks();
});
})
.addButton((button) => {
button.setButtonText("取消").onClick(() => {
button.setButtonText(t("modal.emptyDeck.cancel")).onClick(() => {
this.finish(null);
});
})
.addButton((button) => {
this.deleteButton = button;
button.setButtonText("删除所选空牌组").setCta().onClick(() => {
button.setButtonText(t("modal.emptyDeck.deleteSelected")).setCta().onClick(() => {
this.finish(Array.from(this.selectedDeckNames));
});
});
@ -142,6 +144,9 @@ export class EmptyDeckSelectionModal extends Modal {
}
private getSelectionCountText(): string {
return `已选 ${this.selectedDeckNames.size} / 共 ${this.candidateDeckNames.length} 个空牌组`;
return t("modal.emptyDeck.selectionCount", {
selected: this.selectedDeckNames.size,
total: this.candidateDeckNames.length,
});
}
}

View file

@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getLanguageMock, noticeRecords } = vi.hoisted(() => {
const hoistedGetLanguage = vi.fn(() => "en");
const hoistedNoticeRecords: Array<{ message: string; timeout?: number }> = [];
class HoistedNotice {
constructor(message: string, timeout?: number) {
hoistedNoticeRecords.push({ message, timeout });
}
}
return {
getLanguageMock: hoistedGetLanguage,
noticeRecords: hoistedNoticeRecords,
HoistedNotice,
};
});
vi.mock("obsidian", () => ({
getLanguage: getLanguageMock,
Notice: class {
constructor(message: string, timeout?: number) {
noticeRecords.push({ message, timeout });
}
},
}));
import type { ClearCurrentFileSyncedCardsResult, CleanupEmptyDecksResult } from "@/application/use-cases/cleanupResetTypes";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
import { NoticeService } from "./NoticeService";
function createManualSyncResult(overrides: Partial<ManualSyncResult> = {}): ManualSyncResult {
return {
scannedFiles: 3,
scannedCards: 8,
created: 2,
updated: 1,
migratedDecks: 1,
orphaned: 0,
uploadedMedia: 4,
skippedUnchangedCards: 5,
rewrittenMarkers: 2,
markerWriteConflictFiles: [],
warnings: [],
...overrides,
};
}
function createClearResult(overrides: Partial<ClearCurrentFileSyncedCardsResult> = {}): ClearCurrentFileSyncedCardsResult {
return {
trackedCards: 2,
trackedGroups: 1,
deletedNotes: 3,
removedMarkers: 4,
removedCardMarkers: 2,
removedGroupMarkers: 2,
deletedLocalRecords: 3,
conflictFiles: [],
failureFiles: [],
...overrides,
};
}
function createCleanupResult(overrides: Partial<CleanupEmptyDecksResult> = {}): CleanupEmptyDecksResult {
return {
candidateCount: 4,
selectedCount: 3,
deletedCount: 2,
deletedDeckNames: ["Deck A", "Deck B"],
skippedDeckNames: ["Deck C"],
...overrides,
};
}
describe("NoticeService", () => {
beforeEach(() => {
noticeRecords.length = 0;
getLanguageMock.mockReset();
getLanguageMock.mockReturnValue("en");
});
it("renders sync summary and deck warnings in English", () => {
const service = new NoticeService();
service.showSyncSummary("currentFile", createManualSyncResult({
markerWriteConflictFiles: ["notes/a.md", "notes/b.md"],
warnings: [
{ filePath: "notes/a.md", code: "deck_conflict_yaml_body", params: { marker: "TARGET DECK" } },
{ filePath: "notes/b.md", code: "deck_fallback_default" },
],
}));
expect(noticeRecords.map((entry) => entry.message)).toEqual([
"Current file sync completed: files 3, cards 8, created 2, updated 1, migrated decks 1, orphaned 0, media 4, skipped 5. Marker write conflicts: notes/a.md, notes/b.md. Warnings: 2.",
"Conflicting TARGET DECK declarations were found in YAML and body. This sync used the YAML value.",
"No explicit deck was found and a folder-based deck could not be generated, so the default deck was used.",
]);
});
it("renders rebuild summary and warnings in Simplified Chinese", () => {
getLanguageMock.mockReturnValue("zh");
const service = new NoticeService();
service.showRebuildSummary(createManualSyncResult({
warnings: [{ filePath: "notes/a.md", code: "deck_multiple_body_declarations", params: { marker: "MY DECK" } }],
}));
expect(noticeRecords.map((entry) => entry.message)).toEqual([
"卡片索引重建完成:文件 3卡片 8迁移牌组 1孤儿 0重写标记 2跳过 5。 警告1。",
"检测到文件中存在多个 MY DECK 声明,本次同步只使用第一个正文声明。",
]);
});
it("renders clear-current-file summaries with localized failures", () => {
getLanguageMock.mockReturnValue("zh");
const service = new NoticeService();
service.showClearCurrentFileSummary(createClearResult({
conflictFiles: ["notes/reset.md"],
failureFiles: [
{ filePath: "notes/a.md", key: "errors.markerRemoval.markdownFileNotFound" },
{ filePath: "notes/b.md", rawMessage: "permission denied" },
],
}));
expect(noticeRecords.map((entry) => entry.message)).toEqual([
"当前文件已同步卡片清空完成:已跟踪卡片 2已跟踪分组 1删除笔记 3移除标记 4移除 ID 标记 2移除 GI 标记 2删除本地记录 3。 标记移除冲突notes/reset.md。 失败项notes/a.md (Markdown 文件不存在。)、notes/b.md (permission denied)。",
]);
});
it("renders cleanup summary and skipped decks in English", () => {
const service = new NoticeService();
service.showCleanupEmptyDecksSummary(createCleanupResult({
skippedDeckNames: ["Deck C", "Deck D"],
}));
expect(noticeRecords.map((entry) => entry.message)).toEqual([
"Empty-deck cleanup completed: candidates 4, selected 3, deleted 2, skipped 2. Skipped decks: Deck C, Deck D.",
]);
});
});

View file

@ -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";
}
}

View file

@ -8,9 +8,12 @@ const {
FakeButtonComponent,
FakeDropdownComponent,
FakeElement,
getLanguageMock,
FakePluginSettingTab,
FakeSetting,
} = vi.hoisted(() => {
const hoistedGetLanguage = vi.fn(() => "en");
class HoistedFakeElement {
public readonly children: HoistedFakeElement[] = [];
public readonly dataset: Record<string, string> = {};
@ -242,12 +245,14 @@ const {
FakeButtonComponent: HoistedFakeButtonComponent,
FakeDropdownComponent: HoistedFakeDropdownComponent,
FakeElement: HoistedFakeElement,
getLanguageMock: hoistedGetLanguage,
FakePluginSettingTab: HoistedFakePluginSettingTab,
FakeSetting: HoistedFakeSetting,
};
});
vi.mock("obsidian", () => ({
getLanguage: getLanguageMock,
PluginSettingTab: FakePluginSettingTab,
Setting: FakeSetting,
}));
@ -462,6 +467,57 @@ async function flushAsync(): Promise<void> {
describe("AnkiHeadingSyncSettingTab", () => {
beforeEach(() => {
vi.clearAllMocks();
getLanguageMock.mockReset();
getLanguageMock.mockReturnValue("en");
});
it("renders key settings labels, descriptions, and aria text in English", async () => {
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
scopeMode: "include",
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
expect(container.textNodes).toContain("Anki Heading Sync");
expect(findSetting(container, "AnkiConnect URL").desc).toBe("Default is http://127.0.0.1:8765");
expect(findSetting(container, "Run scope").desc).toBe("Only process Markdown files in the checked folders below");
await flushAsync();
tab.display();
const toggle = getFolderToggle(container, "notes");
expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("Expand notes");
await toggle.trigger("click");
expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("Collapse notes");
});
it("renders key settings labels, descriptions, and aria text in Simplified Chinese", async () => {
getLanguageMock.mockReturnValue("zh");
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
scopeMode: "include",
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4");
expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件");
expect(findSetting(container, "默认牌组").desc).toBe("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。");
await flushAsync();
tab.display();
const toggle = getFolderToggle(container, "notes");
expect((toggle as unknown as { [key: string]: string })["aria-label"]).toBe("展开 notes");
await toggle.trigger("click");
expect((getFolderToggle(container, "notes") as unknown as { [key: string]: string })["aria-label"]).toBe("收起 notes");
});
it("refreshes note type list from Anki into the dropdowns", async () => {
@ -631,7 +687,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(querySetting(container, "Include folders")).toBeUndefined();
expect(querySetting(container, "Exclude folders")).toBeUndefined();
expect(findSetting(container, "运行范围")).toBeDefined();
expect(findSetting(container, "Run scope")).toBeDefined();
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
});
@ -813,7 +869,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
await flushAsync();
await getDropdown(findSetting(container, "运行范围")).triggerChange("include");
await getDropdown(findSetting(container, "Run scope")).triggerChange("include");
await flushAsync();
const parentCheckbox = getCheckboxByPath(container, "notes");
@ -823,7 +879,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(plugin.settings.includeFolders).toEqual(["notes"]);
await getDropdown(findSetting(container, "运行范围")).triggerChange("all");
await getDropdown(findSetting(container, "Run scope")).triggerChange("all");
await flushAsync();
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
@ -836,19 +892,19 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
expect(container.textNodes).toContain("默认牌组");
expect(container.textNodes).toContain("文件级自定义牌组");
expect(container.textNodes).toContain("高级:文件夹映射");
expect(container.textNodes).toContain("最终优先级说明");
expect(container.textNodes).toContain("Default deck");
expect(container.textNodes).toContain("File-level custom deck");
expect(container.textNodes).toContain("Advanced: folder mapping");
expect(container.textNodes).toContain("Final priority");
await getToggle(findSetting(container, "开启文件级自定义牌组")).triggerChange(true);
await getToggle(findSetting(container, "Enable file-level custom deck")).triggerChange(true);
await flushAsync();
await getText(findSetting(container, "牌组识别名")).triggerChange("MY DECK");
await getText(findSetting(container, "默认牌组模板")).triggerChange("vault::filename");
await getDropdown(findSetting(container, "模板插入位置")).triggerChange("yaml");
await getDropdown(findSetting(container, "文件夹映射模式")).triggerChange("folder-and-file");
await getText(findSetting(container, "默认牌组")).triggerChange("Deck::Default");
await getText(findSetting(container, "Deck marker name")).triggerChange("MY DECK");
await getText(findSetting(container, "Default deck template")).triggerChange("vault::filename");
await getDropdown(findSetting(container, "Template insert location")).triggerChange("yaml");
await getDropdown(findSetting(container, "Folder mapping mode")).triggerChange("folder-and-file");
await getText(findSetting(container, "Default deck")).triggerChange("Deck::Default");
await flushAsync();
expect(plugin.settings.fileDeckEnabled).toBe(true);
@ -860,12 +916,12 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
expect(getToggle(findSetting(container, "开启文件级自定义牌组")).value).toBe(true);
expect(getText(findSetting(container, "牌组识别名")).value).toBe("MY DECK");
expect(getText(findSetting(container, "默认牌组模板")).value).toBe("vault::filename");
expect(getDropdown(findSetting(container, "模板插入位置")).value).toBe("yaml");
expect(getDropdown(findSetting(container, "文件夹映射模式")).value).toBe("folder-and-file");
expect(getText(findSetting(container, "默认牌组")).value).toBe("Deck::Default");
expect(getToggle(findSetting(container, "Enable file-level custom deck")).value).toBe(true);
expect(getText(findSetting(container, "Deck marker name")).value).toBe("MY DECK");
expect(getText(findSetting(container, "Default deck template")).value).toBe("vault::filename");
expect(getDropdown(findSetting(container, "Template insert location")).value).toBe("yaml");
expect(getDropdown(findSetting(container, "Folder mapping mode")).value).toBe("folder-and-file");
expect(getText(findSetting(container, "Default deck")).value).toBe("Deck::Default");
});
it("exposes the deck template insertion action when file deck mode is enabled", async () => {
@ -878,7 +934,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getButton(findSetting(container, "向当前文件插入 deck 模板")).click();
await getButton(findSetting(container, "Insert deck template into the current file")).click();
expect(plugin.insertDeckTemplateCalls).toBe(1);
});

View file

@ -4,21 +4,21 @@ import { isValidHashtagMarker, isValidSemanticQaMarker, type FileDeckInsertLocat
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { type UserFacingMessage, renderUserFacingMessage, toUserFacingMessage } from "@/application/errors/PluginUserError";
import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
import { buildQaGroupModelDefinition } from "@/application/services/QaGroupModelDefinition";
import type { CardType } from "@/domain/card/entities/RenderedFields";
import { SemanticQaListParser } from "@/domain/manual-sync/services/SemanticQaListParser";
import { t } from "@/presentation/i18n";
import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSelectionNode } from "./FolderScopeTree";
const NOTE_TYPE_STATUS_IDLE = "Refresh note types from Anki to load the available note types.";
const FOLDER_TREE_STATUS_LOADING = "正在读取当前 vault 文件夹...";
const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status.idle" };
const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" };
interface MappingSectionConfig {
cardType: CardType;
title: string;
description: string;
}
type SimpleDropdown = {
@ -31,12 +31,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private readonly noteFieldMappingService = new NoteFieldMappingService();
private readonly semanticQaListParser = new SemanticQaListParser();
private availableNoteModels: string[] = [];
private noteTypeStatus = NOTE_TYPE_STATUS_IDLE;
private noteTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE;
private readonly draftMappings: Record<string, NoteModelFieldMapping> = {};
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
private readonly sectionStatuses: Partial<Record<CardType, string>> = {};
private readonly sectionStatuses: Partial<Record<CardType, UserFacingMessage>> = {};
private folderTree: FolderTreeNode[] = [];
private folderTreeStatus = FOLDER_TREE_STATUS_LOADING;
private folderTreeStatus: UserFacingMessage = FOLDER_TREE_STATUS_LOADING;
private folderTreeLoadPromise: Promise<void> | null = null;
private hasLoadedFolderTree = false;
private readonly expandedFolderPaths = new Set<string>();
@ -59,13 +59,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const settings = this.plugin.settings;
containerEl.empty();
containerEl.createEl("h2", { text: "Anki Heading Sync" });
containerEl.createEl("h2", { text: t("settings.pluginTitle") });
new Setting(containerEl)
.setName("AnkiConnect URL")
.setDesc("Default is http://127.0.0.1:8765")
.setName(t("settings.ankiConnectUrl.name"))
.setDesc(t("settings.ankiConnectUrl.desc"))
.addText((text) => {
text.setPlaceholder("http://127.0.0.1:8765").setValue(settings.ankiConnectUrl).onChange((value) => {
text.setPlaceholder(t("settings.ankiConnectUrl.placeholder")).setValue(settings.ankiConnectUrl).onChange((value) => {
void this.plugin.updateSettings({ ankiConnectUrl: value.trim() || settings.ankiConnectUrl });
});
});
@ -73,8 +73,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.renderDeckSection(containerEl, settings);
new Setting(containerEl)
.setName("QA heading level")
.setDesc("Default is H4")
.setName(t("settings.qaHeadingLevel.name"))
.setDesc(t("settings.qaHeadingLevel.desc"))
.addDropdown((dropdown) => {
for (let level = 1; level <= 6; level += 1) {
dropdown.addOption(String(level), `H${level}`);
@ -86,8 +86,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("Cloze heading level")
.setDesc("Default is H5")
.setName(t("settings.clozeHeadingLevel.name"))
.setDesc(t("settings.clozeHeadingLevel.desc"))
.addDropdown((dropdown) => {
for (let level = 1; level <= 6; level += 1) {
dropdown.addOption(String(level), `H${level}`);
@ -98,13 +98,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
containerEl.createEl("h3", { text: "QA Group 12" });
containerEl.createEl("h3", { text: t("settings.qaGroup.title") });
new Setting(containerEl)
.setName("QA Group marker")
.setDesc("When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.")
.setName(t("settings.qaGroup.marker.name"))
.setDesc(t("settings.qaGroup.marker.desc"))
.addText((text) => {
text.setPlaceholder("#anki-list").setValue(settings.qaGroupMarker).onChange(async (value) => {
text.setPlaceholder(t("settings.qaGroup.marker.placeholder")).setValue(settings.qaGroupMarker).onChange(async (value) => {
const nextValue = value.trim();
if (!nextValue || !isValidHashtagMarker(nextValue) || nextValue === settings.semanticQaMarker) {
return;
@ -116,13 +116,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.renderQaGroupModelStatus(containerEl);
containerEl.createEl("h3", { text: "Semantic QA" });
containerEl.createEl("h3", { text: t("settings.semanticQa.title") });
new Setting(containerEl)
.setName("Semantic QA marker")
.setDesc("When a QA heading ends with this hashtag marker, first-level list items with indented child content become separate cards.")
.setName(t("settings.semanticQa.marker.name"))
.setDesc(t("settings.semanticQa.marker.desc"))
.addText((text) => {
text.setPlaceholder("#anki-list-qa").setValue(settings.semanticQaMarker).onChange(async (value) => {
text.setPlaceholder(t("settings.semanticQa.marker.placeholder")).setValue(settings.semanticQaMarker).onChange(async (value) => {
const nextValue = value.trim();
if (!nextValue || !isValidSemanticQaMarker(nextValue)) {
return;
@ -138,8 +138,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.renderScopeSection(containerEl, settings);
new Setting(containerEl)
.setName("Add Obsidian backlink")
.setDesc("Append a backlink to the source heading into synced cards.")
.setName(t("settings.syncOptions.addObsidianBacklink.name"))
.setDesc(t("settings.syncOptions.addObsidianBacklink.desc"))
.addToggle((toggle) => {
toggle.setValue(settings.addObsidianBacklink).onChange((value) => {
void this.plugin.updateSettings({ addObsidianBacklink: value });
@ -147,53 +147,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("Highlights to Cloze")
.setDesc("Convert ==highlight== segments into cloze deletions for cloze cards.")
.setName(t("settings.syncOptions.highlightsToCloze.name"))
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))
.addToggle((toggle) => {
toggle.setValue(settings.convertHighlightsToCloze).onChange((value) => {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
containerEl.createEl("h3", { text: "Note type field mappings" });
containerEl.createEl("h3", { text: t("settings.mapping.title") });
new Setting(containerEl)
.setName("Refresh note types from Anki")
.setDesc(this.noteTypeStatus)
.setName(t("settings.mapping.refresh.name"))
.setDesc(renderUserFacingMessage(this.noteTypeStatus))
.addButton((button) => {
button.setButtonText("Refresh note types").onClick(() => {
button.setButtonText(t("settings.mapping.refresh.button")).onClick(() => {
void this.refreshNoteTypes();
});
});
this.renderMappingSection(containerEl, {
cardType: "basic",
title: "QA / Basic",
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
});
this.renderMappingSection(containerEl, {
cardType: "cloze",
title: "Cloze",
description: "Choose the cloze note type, read its fields from Anki, then confirm the main field mapping.",
});
this.renderMappingSection(containerEl, {
cardType: "semantic-qa",
title: "Semantic QA",
description: "Choose the semantic QA note type, read its fields from Anki, then confirm the title/body mapping for child cards.",
});
this.renderMappingSection(containerEl, { cardType: "basic" });
this.renderMappingSection(containerEl, { cardType: "cloze" });
this.renderMappingSection(containerEl, { cardType: "semantic-qa" });
}
private renderMappingSection(containerEl: HTMLElement, config: MappingSectionConfig): void {
const sectionText = getMappingSectionText(config.cardType);
const sectionTitle = sectionText.title;
const selectedModelName = this.getSelectedMappingNoteType(config.cardType);
const mappingKey = createNoteFieldMappingKey(config.cardType, selectedModelName);
const currentMapping = this.getCurrentMapping(mappingKey);
containerEl.createEl("h4", { text: config.title });
containerEl.createEl("p", { text: config.description });
containerEl.createEl("h4", { text: sectionTitle });
containerEl.createEl("p", { text: sectionText.description });
new Setting(containerEl)
.setName(`${config.title} note type`)
.setDesc("Loaded from Anki note types. Refresh if the latest models are not shown.")
.setName(t("settings.mapping.noteTypeLabel", { title: sectionTitle }))
.setDesc(t("settings.mapping.noteTypeDesc"))
.addDropdown((dropdown) => {
for (const noteModel of this.getSelectableNoteModels(config.cardType, selectedModelName)) {
dropdown.addOption(noteModel, noteModel);
@ -205,10 +195,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName(`${config.title} fields`)
.setDesc("Load the current note type fields from Anki, then review the suggested mapping.")
.setName(t("settings.mapping.fieldsLabel", { title: sectionTitle }))
.setDesc(t("settings.mapping.fieldsDesc"))
.addButton((button) => {
button.setButtonText("Read fields from Anki").onClick(() => {
button.setButtonText(t("settings.mapping.readFieldsButton")).onClick(() => {
void this.loadFieldsFromAnki(config.cardType);
});
});
@ -216,31 +206,31 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
containerEl.createEl("p", {
text:
currentMapping?.loadedFieldNames.length
? `Loaded fields: ${currentMapping.loadedFieldNames.join(", ")}`
: "Loaded fields: none. Read fields from Anki first.",
? t("settings.mapping.loadedFields", { fields: currentMapping.loadedFieldNames })
: t("settings.mapping.loadedFieldsNone"),
});
if (config.cardType === "cloze") {
this.renderClozeFieldSelector(containerEl, selectedModelName, currentMapping);
} else {
this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping, config.title);
this.renderBasicFieldSelectors(containerEl, selectedModelName, currentMapping, config.cardType, sectionTitle);
}
new Setting(containerEl)
.setName(`${config.title} mapping`)
.setDesc("Save the current field mapping for this specific note type.")
.setName(t("settings.mapping.mappingLabel", { title: sectionTitle }))
.setDesc(t("settings.mapping.mappingDesc"))
.addButton((button) => {
button.setButtonText("Save mapping").onClick(() => {
button.setButtonText(t("settings.mapping.saveMappingButton")).onClick(() => {
void this.saveMapping(config.cardType);
});
});
containerEl.createEl("p", {
text:
this.sectionStatuses[config.cardType] ??
(this.sectionStatuses[config.cardType] ? renderUserFacingMessage(this.sectionStatuses[config.cardType] as UserFacingMessage) : undefined) ??
(this.plugin.settings.noteFieldMappings[mappingKey]
? `Saved mapping is ready for ${config.title}.`
: `No saved mapping for ${config.title}. Read fields from Anki first.`),
? t("settings.mapping.status.savedMappingReady", { title: sectionTitle })
: t("settings.mapping.status.noSavedMapping", { title: sectionTitle })),
});
}
@ -248,27 +238,28 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
containerEl: HTMLElement,
selectedModelName: string,
mapping: NoteModelFieldMapping | undefined,
cardType: Extract<CardType, "basic" | "semantic-qa">,
sectionTitle: string,
): void {
const fieldNames = mapping?.loadedFieldNames ?? [];
new Setting(containerEl)
.setName(`${sectionTitle} title field`)
.setDesc("Which Anki field should receive the heading/title fragment.")
.setName(t("settings.mapping.titleFieldLabel", { title: sectionTitle }))
.setDesc(t("settings.mapping.titleFieldDesc"))
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.titleField);
dropdown.onChange((value) => {
this.updateDraftMapping(mapping?.cardType ?? inferBasicLikeCardType(sectionTitle), selectedModelName, { titleField: value || undefined });
this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { titleField: value || undefined });
});
});
new Setting(containerEl)
.setName(`${sectionTitle} body field`)
.setDesc("Which Anki field should receive the body fragment.")
.setName(t("settings.mapping.bodyFieldLabel", { title: sectionTitle }))
.setDesc(t("settings.mapping.bodyFieldDesc"))
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.bodyField);
dropdown.onChange((value) => {
this.updateDraftMapping(mapping?.cardType ?? inferBasicLikeCardType(sectionTitle), selectedModelName, { bodyField: value || undefined });
this.updateDraftMapping(mapping?.cardType ?? cardType, selectedModelName, { bodyField: value || undefined });
});
});
}
@ -281,8 +272,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const fieldNames = mapping?.loadedFieldNames ?? [];
new Setting(containerEl)
.setName("Cloze main field")
.setDesc("The selected field receives title + <br><br> + body during sync.")
.setName(t("settings.mapping.mainField.name"))
.setDesc(t("settings.mapping.mainField.desc"))
.addDropdown((dropdown) => {
this.populateFieldDropdown(dropdown, fieldNames, mapping?.mainField);
dropdown.onChange((value) => {
@ -292,7 +283,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private populateFieldDropdown(dropdown: SimpleDropdown, fieldNames: string[], selectedValue: string | undefined): void {
dropdown.addOption("", "-- Select field --");
dropdown.addOption("", t("settings.mapping.selectFieldPlaceholder"));
for (const fieldName of fieldNames) {
dropdown.addOption(fieldName, fieldName);
@ -307,10 +298,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.availableNoteModels = [...noteModels].sort((left, right) => left.localeCompare(right));
this.noteTypeStatus =
this.availableNoteModels.length > 0
? `Loaded ${this.availableNoteModels.length} note types from Anki.`
: "Anki returned no note types.";
? { key: "settings.mapping.status.loadedCount", params: { count: this.availableNoteModels.length } }
: { key: "settings.mapping.status.empty" };
} catch (error) {
this.noteTypeStatus = error instanceof Error ? error.message : "Failed to load note types from Anki.";
this.noteTypeStatus = toUserFacingMessage(error, "settings.mapping.status.failedLoadNoteTypes");
}
this.display();
@ -327,8 +318,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
this.sectionStatuses[cardType] = this.plugin.settings.noteFieldMappings[mappingKey]
? `Loaded saved mapping for ${modelName}.`
: `Selected ${modelName}. Read fields from Anki to create or refresh its mapping.`;
? { key: "settings.mapping.status.loadedSavedMapping", params: { modelName } }
: { key: "settings.mapping.status.selectedModel", params: { modelName } };
this.display();
}
@ -342,9 +333,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.draftMappings[mappingKey] = mapping;
this.loadedModelDetails[mappingKey] = modelDetails;
this.sectionStatuses[cardType] = `Loaded fields for ${modelName}. Review the suggested mapping and save it.`;
this.sectionStatuses[cardType] = { key: "settings.mapping.status.loadedFieldsForModel", params: { modelName } };
} catch (error) {
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`;
this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedLoadFields", { modelName });
}
this.display();
@ -356,7 +347,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const mapping = this.getCurrentMapping(mappingKey);
if (!mapping) {
this.sectionStatuses[cardType] = `No loaded fields for ${modelName}. Read fields from Anki first.`;
this.sectionStatuses[cardType] = { key: "settings.mapping.status.noLoadedFields", params: { modelName } };
this.display();
return;
}
@ -377,9 +368,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
},
});
this.sectionStatuses[cardType] = `Saved mapping for ${modelName}.`;
this.sectionStatuses[cardType] = { key: "settings.mapping.status.savedMapping", params: { modelName } };
} catch (error) {
this.sectionStatuses[cardType] = error instanceof Error ? error.message : `Failed to save mapping for ${modelName}.`;
this.sectionStatuses[cardType] = toUserFacingMessage(error, "settings.mapping.status.failedSaveMapping", { modelName });
}
this.display();
@ -450,19 +441,26 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private renderQaGroupModelStatus(containerEl: HTMLElement): void {
const definition = buildQaGroupModelDefinition();
containerEl.createEl("p", {
text: `Managed note type: ${definition.modelName}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.`,
text: t("settings.qaGroup.managedNoteType", {
modelName: definition.modelName,
}),
});
containerEl.createEl("p", {
text: `Managed model contract: ${definition.fieldNames.length} fields and ${definition.templates.length} templates are checked automatically during sync.`,
text: t("settings.qaGroup.managedModelContract", {
fieldCount: definition.fieldNames.length,
templateCount: definition.templates.length,
}),
});
}
private renderSemanticQaPreview(containerEl: HTMLElement, marker: string): void {
containerEl.createEl("h4", { text: "Semantic QA preview" });
containerEl.createEl("p", { text: `Trigger heading example: 城市更新 ${marker}` });
const sampleHeading = `城市更新 ${marker}`;
containerEl.createEl("h4", { text: t("settings.semanticQa.previewTitle") });
containerEl.createEl("p", { text: t("settings.semanticQa.triggerHeadingExample", { heading: sampleHeading }) });
const previewCards = this.semanticQaListParser.parse({
parentHeadingText: `城市更新 ${marker}`,
parentHeadingText: sampleHeading,
marker,
bodyLines: [
"- 核心产品",
@ -474,32 +472,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
if (previewCards.length === 0) {
containerEl.createEl("p", { text: "Preview unavailable. Use a trailing hashtag marker such as #anki-list-qa." });
containerEl.createEl("p", { text: t("settings.semanticQa.previewUnavailable") });
return;
}
const firstPreview = previewCards[0];
containerEl.createEl("p", { text: `Question preview: ${firstPreview.heading}` });
containerEl.createEl("p", { text: `Answer preview: ${firstPreview.bodyMarkdown}` });
containerEl.createEl("p", { text: t("settings.semanticQa.questionPreview", { question: firstPreview.heading }) });
containerEl.createEl("p", { text: t("settings.semanticQa.answerPreview", { answer: firstPreview.bodyMarkdown }) });
}
private renderDeckSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
containerEl.createEl("h3", { text: "默认牌组" });
containerEl.createEl("h3", { text: t("settings.deck.defaultSectionTitle") });
new Setting(containerEl)
.setName("默认牌组")
.setDesc("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。")
.setName(t("settings.deck.defaultDeck.name"))
.setDesc(t("settings.deck.defaultDeck.desc"))
.addText((text) => {
text.setValue(settings.defaultDeck).onChange((value) => {
void this.plugin.updateSettings({ defaultDeck: value });
});
});
containerEl.createEl("h3", { text: "文件级自定义牌组" });
containerEl.createEl("h3", { text: t("settings.deck.fileDeckSectionTitle") });
new Setting(containerEl)
.setName("开启文件级自定义牌组")
.setDesc("开启后,允许用统一 marker 在 YAML 或正文中为整篇笔记指定 deck。")
.setName(t("settings.deck.fileDeckEnabled.name"))
.setDesc(t("settings.deck.fileDeckEnabled.desc"))
.addToggle((toggle) => {
toggle.setValue(settings.fileDeckEnabled).onChange(async (value) => {
await this.plugin.updateSettings({ fileDeckEnabled: value });
@ -509,8 +507,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
if (settings.fileDeckEnabled) {
new Setting(containerEl)
.setName("牌组识别名")
.setDesc("YAML key 与正文 marker 共用同一个识别名。默认是 TARGET DECK。")
.setName(t("settings.deck.marker.name"))
.setDesc(t("settings.deck.marker.desc"))
.addText((text) => {
text.setValue(settings.fileDeckMarker).onChange((value) => {
const nextValue = value.trim();
@ -523,8 +521,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("默认牌组模板")
.setDesc("只支持 filename 变量,插入时会展开为当前文件名(不含 .md。")
.setName(t("settings.deck.template.name"))
.setDesc(t("settings.deck.template.desc"))
.addText((text) => {
text.setValue(settings.fileDeckTemplate).onChange((value) => {
const nextValue = value.trim();
@ -537,8 +535,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("模板插入位置")
.setDesc("选择将 deck 模板写入 YAML frontmatter 还是正文前部。")
.setName(t("settings.deck.insertLocation.name"))
.setDesc(t("settings.deck.insertLocation.desc"))
.addDropdown((dropdown) => {
this.populateFileDeckInsertLocationDropdown(dropdown, settings.fileDeckInsertLocation);
dropdown.onChange((value) => {
@ -551,20 +549,20 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("向当前文件插入 deck 模板")
.setDesc("按当前 marker、模板和插入位置把 deck 声明写入当前活动 Markdown 文件。")
.setName(t("settings.deck.insertTemplate.name"))
.setDesc(t("settings.deck.insertTemplate.desc"))
.addButton((button) => {
button.setButtonText("插入 deck 模板").onClick(() => {
button.setButtonText(t("settings.deck.insertTemplate.button")).onClick(() => {
void this.plugin.insertDeckTemplateToCurrentFile();
});
});
}
containerEl.createEl("h3", { text: "高级:文件夹映射" });
containerEl.createEl("h3", { text: t("settings.deck.folderMappingSectionTitle") });
new Setting(containerEl)
.setName("文件夹映射模式")
.setDesc("关闭 / 仅父文件夹 / 父文件夹加当前文件名。")
.setName(t("settings.deck.folderDeckMode.name"))
.setDesc(t("settings.deck.folderDeckMode.desc"))
.addDropdown((dropdown) => {
this.populateFolderDeckModeDropdown(dropdown, settings.folderDeckMode);
dropdown.onChange((value) => {
@ -576,37 +574,37 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
containerEl.createEl("p", { text: "文件夹级映射示例:数学/第一章/第一节.md -> 数学::第一章" });
containerEl.createEl("p", { text: "文件夹及文件名级映射示例:数学/第一章/第一节.md -> 数学::第一章::第一节" });
containerEl.createEl("p", { text: t("settings.deck.folderExample") });
containerEl.createEl("p", { text: t("settings.deck.folderAndFileExample") });
containerEl.createEl("h3", { text: "最终优先级说明" });
containerEl.createEl("h3", { text: t("settings.deck.priorityTitle") });
containerEl.createEl("p", {
text: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡 deck 行为保持当前实现。",
text: t("settings.deck.priorityDesc"),
});
}
private populateFileDeckInsertLocationDropdown(dropdown: SimpleDropdown, selectedValue: FileDeckInsertLocation): void {
dropdown.addOption("body", "正文前部");
dropdown.addOption("yaml", "YAML frontmatter");
dropdown.addOption("body", t("settings.deck.insertLocation.options.body"));
dropdown.addOption("yaml", t("settings.deck.insertLocation.options.yaml"));
dropdown.setValue(selectedValue);
}
private populateFolderDeckModeDropdown(dropdown: SimpleDropdown, selectedValue: FolderDeckMode): void {
dropdown.addOption("off", "关闭");
dropdown.addOption("folder", "文件夹级映射");
dropdown.addOption("folder-and-file", "文件夹及文件名级映射");
dropdown.addOption("off", t("settings.deck.folderDeckMode.options.off"));
dropdown.addOption("folder", t("settings.deck.folderDeckMode.options.folder"));
dropdown.addOption("folder-and-file", t("settings.deck.folderDeckMode.options.folderAndFile"));
dropdown.setValue(selectedValue);
}
private renderScopeSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
new Setting(containerEl)
.setName("运行范围")
.setName(t("settings.scope.name"))
.setDesc(getScopeModeSummary(settings.scopeMode))
.addDropdown((dropdown) => {
dropdown
.addOption("all", "全部文件")
.addOption("include", "仅在指定文件夹")
.addOption("exclude", "排除指定文件夹")
.addOption("all", t("settings.scope.option.all"))
.addOption("include", t("settings.scope.option.include"))
.addOption("exclude", t("settings.scope.option.exclude"))
.setValue(settings.scopeMode)
.onChange((value) => {
if (value !== "all" && value !== "include" && value !== "exclude") {
@ -627,12 +625,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
scopeContainer.createEl("p", { text: getScopeModeTreeDescription(settings.scopeMode) });
if (this.folderTreeLoadPromise) {
scopeContainer.createEl("p", { text: this.folderTreeStatus });
scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) });
return;
}
if (this.folderTree.length === 0) {
scopeContainer.createEl("p", { text: this.folderTreeStatus });
scopeContainer.createEl("p", { text: renderUserFacingMessage(this.folderTreeStatus) });
return;
}
@ -676,7 +674,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
toggleControl.style.cursor = hasChildren ? "pointer" : "default";
if (hasChildren) {
toggleControl.setAttr("aria-label", expanded ? `收起 ${node.name}` : `展开 ${node.name}`);
toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
toggleControl.setAttr("aria-expanded", String(expanded));
toggleControl.addEventListener("click", () => {
this.toggleFolderExpanded(node.path);
@ -721,11 +719,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.listFolderTree()
.then((folderTree) => {
this.folderTree = folderTree;
this.folderTreeStatus = folderTree.length > 0 ? "" : "当前 vault 中没有可选文件夹。";
this.folderTreeStatus = folderTree.length > 0 ? { rawMessage: "" } : { key: "settings.scope.empty" };
})
.catch((error) => {
this.folderTree = [];
this.folderTreeStatus = error instanceof Error ? error.message : "读取 vault 文件夹失败。";
this.folderTreeStatus = toUserFacingMessage(error, "settings.scope.failedLoad");
})
.finally(() => {
this.hasLoadedFolderTree = true;
@ -758,26 +756,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
}
function inferBasicLikeCardType(sectionTitle: string): Extract<CardType, "basic" | "semantic-qa"> {
return sectionTitle === "Semantic QA" ? "semantic-qa" : "basic";
function getMappingSectionText(cardType: CardType): { title: string; description: string } {
if (cardType === "cloze") {
return {
title: t("settings.mapping.section.cloze.title"),
description: t("settings.mapping.section.cloze.description"),
};
}
if (cardType === "semantic-qa") {
return {
title: t("settings.mapping.section.semanticQa.title"),
description: t("settings.mapping.section.semanticQa.description"),
};
}
return {
title: t("settings.mapping.section.basic.title"),
description: t("settings.mapping.section.basic.description"),
};
}
function getScopeModeSummary(scopeMode: ScopeMode): string {
if (scopeMode === "include") {
return "仅处理下方勾选文件夹中的 Markdown 文件";
return t("settings.scope.summary.include");
}
if (scopeMode === "exclude") {
return "处理整个 vault但跳过下方勾选文件夹中的 Markdown 文件";
return t("settings.scope.summary.exclude");
}
return "处理整个 vault 中的 Markdown 文件";
return t("settings.scope.summary.all");
}
function getScopeModeTreeDescription(scopeMode: ScopeMode): string {
if (scopeMode === "include") {
return "仅在指定文件夹:只处理下方勾选文件夹中的 Markdown 文件";
return t("settings.scope.treeDescription.include");
}
return "排除指定文件夹:处理整个 vault但跳过下方勾选文件夹中的 Markdown 文件";
return t("settings.scope.treeDescription.exclude");
}

View file

@ -2,6 +2,10 @@ export async function requestUrl(): Promise<never> {
throw new Error("obsidian.requestUrl stub was called without a test mock.");
}
export function getLanguage(): string {
return "en";
}
export class Plugin {
public app: unknown;