From b82e1dd53d755599f44bbb5effaf25c881c012c1 Mon Sep 17 00:00:00 2001
From: Dusk
Date: Thu, 23 Apr 2026 23:08:00 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=AF=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=20Obsidian=20=E5=9B=9E=E9=93=BE=E6=98=BE=E7=A4=BA?=
=?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=B8=8E=E4=BD=8D=E7=BD=AE=20/=20configurabl?=
=?UTF-8?q?e=20Obsidian=20backlink=20label=20and=20placement?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
覆盖普通卡、Cloze、语义 QA 与 QA Group 12,新增回链名称与放置位置设置、渲染位置控制、QA Group 模板漂移更新与 renderConfigHash 失效。
Cover normal cards, cloze, semantic QA, and QA Group 12 with configurable backlink label and placement, placement-aware rendering, QA Group template drift updates, and renderConfigHash invalidation.
---
docs/obsidian-backlink-config-decisions.md | 85 +++++++++++++
docs/obsidian-backlink-config-gap-report.md | 119 ++++++++++++++++++
src/application/config/PluginSettings.test.ts | 43 ++++++-
src/application/config/PluginSettings.ts | 43 +++++++
src/application/services/ManualSyncService.ts | 2 +
.../services/QaGroupModelDefinition.ts | 47 ++++++-
.../services/QaGroupModelService.test.ts | 50 +++++++-
.../services/QaGroupModelService.ts | 5 +-
.../services/QaGroupSyncService.test.ts | 25 ++++
.../services/QaGroupSyncService.ts | 5 +-
.../services/RenderConfigService.ts | 2 +
.../services/DiffPlannerService.test.ts | 48 +++++++
.../services/ManualCardRenderer.test.ts | 68 ++++++++++
.../services/ManualCardRenderer.ts | 30 +++--
src/domain/shared/renderObsidianBacklink.ts | 53 ++++++++
.../DataJsonPluginConfigRepository.test.ts | 24 ++++
.../DataJsonPluginConfigRepository.ts | 15 +--
src/presentation/AnkiHeadingSyncPlugin.ts | 6 +-
src/presentation/i18n/messages/en.ts | 15 +++
src/presentation/i18n/messages/zh.ts | 15 +++
.../settings/PluginSettingTab.test.ts | 43 +++++++
src/presentation/settings/PluginSettingTab.ts | 38 +++++-
22 files changed, 750 insertions(+), 31 deletions(-)
create mode 100644 docs/obsidian-backlink-config-decisions.md
create mode 100644 docs/obsidian-backlink-config-gap-report.md
create mode 100644 src/domain/shared/renderObsidianBacklink.ts
diff --git a/docs/obsidian-backlink-config-decisions.md b/docs/obsidian-backlink-config-decisions.md
new file mode 100644
index 0000000..6cf2d32
--- /dev/null
+++ b/docs/obsidian-backlink-config-decisions.md
@@ -0,0 +1,85 @@
+# Obsidian Backlink Config Decisions
+
+## Decision Summary
+
+本轮按真实仓库结构锁定以下实现决策:
+
+1. 新增 `obsidianBacklinkLabel` 与 `obsidianBacklinkPlacement` 两个 settings,并通过集中 normalize 层处理空 label。
+2. 普通 QA / Cloze / 语义 QA 继续只改 `renderedFields.title` 与 `renderedFields.body`,不改现有字段映射结构。
+3. Cloze 继续依赖既有 `title
body` 合并路径,不单独引入第三段字段。
+4. 回链 HTML 统一由共享 helper 生成 anchor,普通卡与 QA Group 模板共用同一 escape 规则。
+5. QA Group 12 通过“模板生成接收 settings + ensureModel 按当前 settings 校验/漂移更新”实现配置漂移,不改 `Src` 字段语义。
+6. `RenderConfigService` 必须把新 label/placement 纳入 hash,确保已同步卡片会随设置变化重渲染。
+
+## Concrete Decisions
+
+### 1. Settings normalization policy
+
+设置层新增两个 helper:
+
+- `normalizeObsidianBacklinkLabel()`
+- `normalizePluginSettings()` / `mergePluginSettings()`
+
+用途分工:
+
+- load:旧 snapshot 缺字段时吃默认值,空 label 归一为 `Open in Obsidian`
+- save:写回前统一 trim label,空白值写默认文案
+- UI:`plugin.updateSettings()` 也走 normalize,避免内存态和持久化态不一致
+
+### 2. Placement rendering policy for normal cards
+
+普通 QA / Cloze / 语义 QA 使用同一 placement 语义:
+
+- `question-last-line`: 在 `renderedFields.title` 末尾追加 `
` + backlink anchor
+- `answer-first-line`: 在 `renderedFields.body` 开头插入一行仅含 backlink 的块级 HTML
+- `answer-last-line`: 在 `renderedFields.body` 末尾追加一行仅含 backlink 的块级 HTML,保持当前默认行为
+
+这里不改变 title/body 的职责边界,只改变 backlink 被拼进哪一段 HTML。
+
+### 3. Shared backlink helper policy
+
+共享 helper 负责两件事:
+
+- 生成 `label`
+- 在普通卡与 QA Group 模板中按需 escape href / label
+
+约束:
+
+- 普通卡:escape URL 与 label
+- QA Group 模板:href 保持 `{{Src}}` 原样,只 escape label
+
+### 4. QA Group integration policy
+
+`buildQaGroupModelDefinition()` 改为接收:
+
+- `obsidianBacklinkLabel`
+- `obsidianBacklinkPlacement`
+
+`QaGroupModelService.ensureModel()` 改为接收当前 settings,并在每次 sync 前按 settings 生成目标模板。
+
+placement 规则锁定为:
+
+- `question-last-line`: `{{FrontSide}}` 后、`
` 前
+- `answer-first-line`: `...
` 前
+- `answer-last-line`: 答案容器后,保持当前默认位置
+
+### 5. Render hash policy
+
+`renderConfigHash` 继续只表达“字段渲染语义”,因此新增:
+
+- `obsidianBacklinkLabel`
+- `obsidianBacklinkPlacement`
+
+这样配置变化会进入现有 diff planner 的 update 判定,无需单独补一套回链迁移机制。
+
+## Intentional Deviation From Draft Plan
+
+本轮预计只有一个实现层面的轻微偏离:
+
+- 普通卡的 `answer-first-line` 采用块级一行 backlink 再接原 body,而不是内联 anchor 紧贴正文开头
+
+原因:
+
+- 当前默认 `answer-last-line` 已是块级 `...
` 形态
+- 保持 body 两种 placement 都使用独立一行块级 HTML,更稳定,也更接近“第一行 / 最后一行”的视觉语义
+- 不会影响 Cloze 合并结构或 note field mapping
\ No newline at end of file
diff --git a/docs/obsidian-backlink-config-gap-report.md b/docs/obsidian-backlink-config-gap-report.md
new file mode 100644
index 0000000..063fc2e
--- /dev/null
+++ b/docs/obsidian-backlink-config-gap-report.md
@@ -0,0 +1,119 @@
+# Obsidian Backlink Config Gap Report
+
+## Scope
+
+本报告基于当前仓库真实代码审查,聚焦这五条链路与计划差距:
+
+- settings 默认值、校验、持久化与旧 data.json 兼容
+- 普通 QA / Cloze / 语义 QA 的回链渲染入口
+- Cloze title/body 合并影响面
+- QA Group 12 模板生成与 model drift 更新链路
+- renderConfigHash 对已同步卡片的失效机制
+
+## Confirmed Gaps
+
+### 1. 普通卡回链仍硬编码在 ManualCardRenderer
+
+`src/domain/manual-sync/services/ManualCardRenderer.ts` 当前只在一个分支里决定回链行为:
+
+- `addObsidianBacklink = true` 时始终向 `renderedFields.body` 末尾追加 `Open in Obsidian
`
+- 没有 label 配置入口
+- 没有 placement 配置入口
+
+这意味着普通 QA、Cloze、语义 QA 目前共享同一个“答案末尾 + 固定文案”硬编码。
+
+### 2. Cloze 兼容点不在 renderer,而在现有字段映射合并逻辑
+
+当前仓库结构里,Cloze 仍然由 renderer 先产出 `title` / `body`,再由 note field mapping 把它们合并进主字段。
+
+因此实现时不需要改 Cloze 的字段结构;只要保证 `question-last-line` 改的是 `title`,`answer-first-line` / `answer-last-line` 改的是 `body`,现有 `title
body` 合并形状就会自然继承新位置。
+
+### 3. QA Group 12 也仍然硬编码默认回链
+
+`src/application/services/QaGroupModelDefinition.ts` 当前在模板生成里写死:
+
+- back 模板固定把 `Open in Obsidian` 放在答案容器后
+- `buildQaGroupModelDefinition()` 没有接收 settings 或其他配置参数
+
+`src/application/services/QaGroupModelService.ts` 的 `ensureModel()` 也始终调用无参 `buildQaGroupModelDefinition()`,所以模板漂移更新机制存在,但暂时无法感知用户配置。
+
+### 4. QA Group note 字段链路已经独立,不需要改字段结构
+
+`src/application/services/QaGroupSyncService.ts` 当前行为已符合计划的结构约束:
+
+- `Src` 仍由 `buildGroupBacklink()` 生成并写入 note fields
+- `buildQaGroupNoteFields()` 仍固定写 `Stem / GroupId / Src / S01..S12`
+- 没有通过普通 note field mapping 去映射 QA Group 字段
+
+因此本轮不需要新增字段,也不需要改变 `Src` 含义,只需让 model template 生成使用当前 settings。
+
+### 5. settings 持久化只做默认值合并,没有空 label 归一
+
+`src/infrastructure/persistence/DataJsonPluginConfigRepository.ts` 当前对 settings 的处理是:
+
+- load 时直接用 `DEFAULT_SETTINGS` 合并旧 snapshot
+- save 时直接校验后原样写回
+
+这能处理“缺字段”,但不能处理:
+
+- 旧 / 异常 data.json 中 `obsidianBacklinkLabel` 为空字符串或全空格
+- UI 保存时把全空格文案写进 settings
+
+计划要求的空 label 归一,需要引入一个显式 normalize 层。
+
+### 6. renderConfigHash 目前还不感知新配置
+
+`src/application/services/RenderConfigService.ts` 当前 hash 只包含:
+
+- note model / mapping
+- `addObsidianBacklink`
+- `convertHighlightsToCloze`
+- `keepPureTagLinesInCardBody`
+
+如果只修改 label 或 placement,当前 diff planner 不会把已同步卡片判定为需要更新。
+
+### 7. settings UI 与 i18n 尚未暴露新配置
+
+`src/presentation/settings/PluginSettingTab.ts` 当前只在 sync options 区域渲染了 `addObsidianBacklink` toggle,没有:
+
+- label 文本输入
+- placement 下拉
+
+中英文消息文件里也尚未存在这两组设置文案与 placement 校验错误文案。
+
+### 8. 现有测试覆盖还缺四个关键面
+
+当前仓库已有:
+
+- `PluginSettings.test.ts`
+- `PluginSettingTab.test.ts`
+- `ManualCardRenderer.test.ts`
+- `QaGroupModelService.test.ts`
+- `QaGroupSyncService.test.ts`
+
+但还缺或不足:
+
+- settings 归一化与 placement 校验
+- label / placement 改变引起的 renderConfigHash 变化
+- 普通卡三种 placement 的断言
+- QA Group 模板三种 placement 与 drift 更新断言
+
+## Repository-Compatible Fix Direction
+
+最安全的仓库兼容方案是:
+
+1. 在 settings 层新增集中 normalize 函数,供 load/save/UI 共用。
+2. 把普通卡回链渲染收口成共享 helper,并在 `ManualCardRenderer` 内按 placement 分流到 `title` 或 `body`。
+3. 让 `buildQaGroupModelDefinition()` 接收 label/placement,并让 `QaGroupModelService.ensureModel()` 按 settings 生成模板。
+4. 把 label/placement 纳入 `RenderConfigService` hash。
+5. 只扩展现有测试锚点,不改 note 字段结构、不改 `Src` 语义、不做无关重构。
+
+## Out Of Scope
+
+本轮不扩张到:
+
+- 新增 note 字段
+- 改写 QA Group field contract
+- 修改 Cloze 合并结构
+- 新增独立的 QA Group settings 面板
+- 调整现有 Obsidian backlink 开关语义
\ No newline at end of file
diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts
index 7bb598c..cb878ca 100644
--- a/src/application/config/PluginSettings.test.ts
+++ b/src/application/config/PluginSettings.test.ts
@@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest";
import { PluginUserError } from "@/application/errors/PluginUserError";
-import { DEFAULT_SETTINGS, validatePluginSettings } from "./PluginSettings";
+import {
+ DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ DEFAULT_SETTINGS,
+ mergePluginSettings,
+ normalizeObsidianBacklinkLabel,
+ normalizePluginSettings,
+ validatePluginSettings,
+} from "./PluginSettings";
function expectPluginUserError(action: () => void, key: string): void {
try {
@@ -48,10 +55,44 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off");
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
+ expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
+ expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true);
});
+ it("fills new backlink defaults when loading legacy snapshots", () => {
+ const settings = mergePluginSettings({
+ qaNoteType: "Legacy Basic",
+ clozeNoteType: "Legacy Cloze",
+ });
+
+ expect(settings.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
+ expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line");
+ });
+
+ it("rejects invalid backlink placement values", () => {
+ expectPluginUserError(() => {
+ validatePluginSettings({
+ ...DEFAULT_SETTINGS,
+ obsidianBacklinkPlacement: "body-middle" as never,
+ });
+ }, "errors.settings.obsidianBacklinkPlacementInvalid");
+ });
+
+ it("normalizes blank backlink labels on load and save paths", () => {
+ expect(normalizeObsidianBacklinkLabel(" ")).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
+ expect(normalizePluginSettings({
+ ...DEFAULT_SETTINGS,
+ obsidianBacklinkLabel: " Custom Link ",
+ }).obsidianBacklinkLabel).toBe("Custom Link");
+ expect(mergePluginSettings({
+ qaNoteType: "Basic",
+ clozeNoteType: "Cloze",
+ obsidianBacklinkLabel: "\n\t ",
+ }).obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
+ });
+
it("rejects non-boolean tag sync settings", () => {
expectPluginUserError(() => {
validatePluginSettings({
diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts
index e0e9a1a..a97ddc6 100644
--- a/src/application/config/PluginSettings.ts
+++ b/src/application/config/PluginSettings.ts
@@ -5,6 +5,9 @@ export type ScopeMode = "all" | "include" | "exclude";
export type FileDeckInsertLocation = "yaml" | "body";
export type FolderDeckMode = "off" | "folder" | "folder-and-file";
export type CardAnswerCutoffMode = "heading-block" | "double-blank-lines";
+export type ObsidianBacklinkPlacement = "question-last-line" | "answer-first-line" | "answer-last-line";
+
+export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian";
export interface PluginSettings {
qaHeadingLevel: number;
@@ -26,6 +29,8 @@ export interface PluginSettings {
includeFolders: string[];
excludeFolders: string[];
addObsidianBacklink: boolean;
+ obsidianBacklinkLabel: string;
+ obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
convertHighlightsToCloze: boolean;
syncObsidianTagsToAnki: boolean;
keepPureTagLinesInCardBody: boolean;
@@ -52,6 +57,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
includeFolders: [],
excludeFolders: [],
addObsidianBacklink: true,
+ obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ obsidianBacklinkPlacement: "answer-last-line",
convertHighlightsToCloze: true,
syncObsidianTagsToAnki: true,
keepPureTagLinesInCardBody: true,
@@ -68,6 +75,34 @@ export function isValidSemanticQaMarker(marker: string): boolean {
return isValidHashtagMarker(marker);
}
+export function normalizeObsidianBacklinkLabel(value: string | null | undefined): string {
+ if (typeof value !== "string") {
+ return DEFAULT_OBSIDIAN_BACKLINK_LABEL;
+ }
+
+ const trimmedValue = value.trim();
+ return trimmedValue.length > 0 ? trimmedValue : DEFAULT_OBSIDIAN_BACKLINK_LABEL;
+}
+
+export function normalizePluginSettings(settings: PluginSettings): PluginSettings {
+ return {
+ ...settings,
+ obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
+ };
+}
+
+export function mergePluginSettings(settings?: Partial | null): PluginSettings {
+ const partialSettings = settings ?? {};
+
+ return normalizePluginSettings({
+ ...DEFAULT_SETTINGS,
+ ...partialSettings,
+ noteFieldMappings: partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings,
+ includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
+ excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
+ });
+}
+
export function validatePluginSettings(settings: PluginSettings): void {
const headingLevels = [settings.qaHeadingLevel, settings.clozeHeadingLevel];
@@ -155,6 +190,14 @@ export function validatePluginSettings(settings: PluginSettings): void {
throw new PluginUserError("errors.settings.scopeModeInvalid");
}
+ if (
+ settings.obsidianBacklinkPlacement !== "question-last-line"
+ && settings.obsidianBacklinkPlacement !== "answer-first-line"
+ && settings.obsidianBacklinkPlacement !== "answer-last-line"
+ ) {
+ throw new PluginUserError("errors.settings.obsidianBacklinkPlacementInvalid");
+ }
+
validateFolderList(settings.includeFolders, "include");
validateFolderList(settings.excludeFolders, "exclude");
diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts
index 8402660..821df8e 100644
--- a/src/application/services/ManualSyncService.ts
+++ b/src/application/services/ManualSyncService.ts
@@ -145,6 +145,8 @@ export class ManualSyncService {
const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings);
const renderContext: ManualCardRenderContext = {
addObsidianBacklink: settings.addObsidianBacklink,
+ obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
+ obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
resourceResolver: this.vaultGateway,
diff --git a/src/application/services/QaGroupModelDefinition.ts b/src/application/services/QaGroupModelDefinition.ts
index 7608199..04e0423 100644
--- a/src/application/services/QaGroupModelDefinition.ts
+++ b/src/application/services/QaGroupModelDefinition.ts
@@ -1,14 +1,28 @@
import type { AnkiModelTemplate, CreateAnkiModelInput } from "@/application/ports/AnkiGateway";
+import {
+ DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ type ObsidianBacklinkPlacement,
+ normalizeObsidianBacklinkLabel,
+} from "@/application/config/PluginSettings";
import type { GroupItem } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
+import { renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink";
export const QA_GROUP_MODEL_NAME = "ObsiAnki QA Group 12";
export const QA_GROUP_SLOT_COUNT = 12;
-export function buildQaGroupModelDefinition(): CreateAnkiModelInput {
+interface QaGroupModelDefinitionOptions {
+ obsidianBacklinkLabel?: string;
+ obsidianBacklinkPlacement?: ObsidianBacklinkPlacement;
+}
+
+export function buildQaGroupModelDefinition(options: QaGroupModelDefinitionOptions = {}): CreateAnkiModelInput {
+ const backlinkLabel = normalizeObsidianBacklinkLabel(options.obsidianBacklinkLabel ?? DEFAULT_OBSIDIAN_BACKLINK_LABEL);
+ const backlinkPlacement = options.obsidianBacklinkPlacement ?? "answer-last-line";
+
return {
modelName: QA_GROUP_MODEL_NAME,
fieldNames: buildQaGroupFieldNames(),
- templates: buildQaGroupTemplates(),
+ templates: buildQaGroupTemplates(backlinkLabel, backlinkPlacement),
css: QA_GROUP_MODEL_CSS,
};
}
@@ -24,15 +38,24 @@ export function buildQaGroupFieldNames(): string[] {
return fieldNames;
}
-export function buildQaGroupTemplates(): AnkiModelTemplate[] {
+export function buildQaGroupTemplates(
+ backlinkLabel = DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ backlinkPlacement: ObsidianBacklinkPlacement = "answer-last-line",
+): AnkiModelTemplate[] {
const templates: AnkiModelTemplate[] = [];
+ const backlinkAnchor = renderObsidianBacklinkAnchor({
+ href: "{{Src}}",
+ label: backlinkLabel,
+ escapeHref: false,
+ });
+ const backlinkLine = `{{#Src}}${backlinkAnchor}
{{/Src}}`;
for (let slot = 1; slot <= QA_GROUP_SLOT_COUNT; slot += 1) {
const slotId = formatQaGroupSlot(slot);
templates.push({
name: `Q${slotId.slice(1)}`,
front: `{{#${slotId}_Q}}{{#${slotId}_A}}{{Stem}}
\n{{${slotId}_Q}}
{{/${slotId}_A}}{{/${slotId}_Q}}`,
- back: `{{FrontSide}}\n\n
\n\n{{${slotId}_A}}
\n{{#Src}}Open in Obsidian
{{/Src}}`,
+ back: buildQaGroupBackTemplate(slotId, backlinkLine, backlinkPlacement),
});
}
@@ -63,6 +86,22 @@ export function formatQaGroupSlot(slot: number): string {
return `S${String(slot).padStart(2, "0")}`;
}
+function buildQaGroupBackTemplate(
+ slotId: string,
+ backlinkLine: string,
+ backlinkPlacement: ObsidianBacklinkPlacement,
+): string {
+ if (backlinkPlacement === "question-last-line") {
+ return `{{FrontSide}}\n${backlinkLine}\n\n
\n\n{{${slotId}_A}}
`;
+ }
+
+ if (backlinkPlacement === "answer-first-line") {
+ return `{{FrontSide}}\n\n
\n\n${backlinkLine}\n{{${slotId}_A}}
`;
+ }
+
+ return `{{FrontSide}}\n\n
\n\n{{${slotId}_A}}
\n${backlinkLine}`;
+}
+
export const QA_GROUP_MODEL_CSS = [
".card {",
" font-family: 'Helvetica Neue', Arial, sans-serif;",
diff --git a/src/application/services/QaGroupModelService.test.ts b/src/application/services/QaGroupModelService.test.ts
index f60f659..05ca74f 100644
--- a/src/application/services/QaGroupModelService.test.ts
+++ b/src/application/services/QaGroupModelService.test.ts
@@ -6,11 +6,36 @@ import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
import { QaGroupModelService } from "./QaGroupModelService";
describe("QaGroupModelService", () => {
- it("builds QA Group templates with a clickable Obsidian backlink", () => {
+ it("builds the default QA Group template with a clickable backlink at the answer end", () => {
const definition = buildQaGroupModelDefinition();
expect(definition.templates[0]?.back).toContain('Open in Obsidian');
- expect(definition.templates[0]?.back).not.toContain('{{Src}}
');
+ expect(definition.templates[0]?.back).toContain('{{S01_A}}
');
+ });
+
+ it("escapes a custom QA Group backlink label", () => {
+ const definition = buildQaGroupModelDefinition({
+ obsidianBacklinkLabel: 'Open & "Now"',
+ });
+
+ expect(definition.templates[0]?.back).toContain('Open <Obsidian> & "Now"');
+ expect(definition.templates[0]?.back).not.toContain('Open & "Now"');
+ });
+
+ it("builds three different QA Group backlink placements", () => {
+ const questionPlacement = buildQaGroupModelDefinition({
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+ const answerFirstPlacement = buildQaGroupModelDefinition({
+ obsidianBacklinkPlacement: "answer-first-line",
+ });
+ const answerLastPlacement = buildQaGroupModelDefinition({
+ obsidianBacklinkPlacement: "answer-last-line",
+ });
+
+ expect(questionPlacement.templates[0]?.back).toContain('{{FrontSide}}\n{{#Src}}Open in Obsidian
{{/Src}}\n\n
');
+ expect(answerFirstPlacement.templates[0]?.back).toContain('
\n\n{{#Src}}Open in Obsidian
{{/Src}}\n{{S01_A}}
');
+ expect(answerLastPlacement.templates[0]?.back).toContain('{{S01_A}}
\n{{#Src}}Open in Obsidian
{{/Src}}');
});
it("creates the QA Group model when it is missing", async () => {
@@ -79,6 +104,27 @@ describe("QaGroupModelService", () => {
]);
});
+ it("updates templates when the configured backlink label or placement drifts", async () => {
+ const ankiGateway = createExistingQaGroupGateway();
+ const service = new QaGroupModelService(ankiGateway);
+ const definition = buildQaGroupModelDefinition({
+ obsidianBacklinkLabel: "Open note",
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+
+ await service.ensureModel({
+ obsidianBacklinkLabel: "Open note",
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+
+ expect(ankiGateway.updatedModelTemplates).toEqual(
+ definition.templates.map((template) => ({
+ modelName: definition.modelName,
+ template,
+ })),
+ );
+ });
+
it("updates drifted CSS when the QA Group model already exists", async () => {
const definition = buildQaGroupModelDefinition();
const ankiGateway = createExistingQaGroupGateway({
diff --git a/src/application/services/QaGroupModelService.ts b/src/application/services/QaGroupModelService.ts
index 56798e5..e09efa6 100644
--- a/src/application/services/QaGroupModelService.ts
+++ b/src/application/services/QaGroupModelService.ts
@@ -1,3 +1,4 @@
+import type { PluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGroupGateway } from "@/application/ports/AnkiGateway";
import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
@@ -5,8 +6,8 @@ import { buildQaGroupModelDefinition } from "./QaGroupModelDefinition";
export class QaGroupModelService {
constructor(private readonly ankiGateway: AnkiGroupGateway) {}
- async ensureModel(): Promise {
- const definition = buildQaGroupModelDefinition();
+ async ensureModel(settings?: Pick): Promise {
+ const definition = buildQaGroupModelDefinition(settings);
const modelNames = await this.runModelAction(
"listNoteModels",
definition.modelName,
diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts
index 8081ab8..7645f29 100644
--- a/src/application/services/QaGroupSyncService.test.ts
+++ b/src/application/services/QaGroupSyncService.test.ts
@@ -43,6 +43,31 @@ describe("QaGroupSyncService", () => {
expect(result.markerWrites[0]?.itemToSlot).toEqual({ "item-1": 1, "item-2": 2 });
});
+ it("ensures the QA Group model using the current backlink settings before syncing", async () => {
+ const ankiGateway = new FakeManualSyncAnkiGateway();
+ const vaultGateway = new FakeManualSyncVaultGateway();
+ const service = new QaGroupSyncService(
+ ankiGateway,
+ undefined,
+ undefined,
+ undefined,
+ () => 1234,
+ () => "group-1",
+ () => "item-1",
+ vaultGateway.createBacklink.bind(vaultGateway),
+ );
+
+ await service.sync([
+ createIndexedGroupBlock(),
+ ], createEmptyPluginState(), createModule3Settings({
+ obsidianBacklinkLabel: 'Open ',
+ obsidianBacklinkPlacement: "answer-first-line",
+ }));
+
+ expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('Open <Vault>');
+ expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('
\n\n{{#Src}}Open <Vault>
{{/Src}}\n{{S01_A}}
');
+ });
+
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();
diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts
index 16eedae..9172fba 100644
--- a/src/application/services/QaGroupSyncService.ts
+++ b/src/application/services/QaGroupSyncService.ts
@@ -71,7 +71,10 @@ export class QaGroupSyncService {
};
}
- await this.qaGroupModelService.ensureModel();
+ await this.qaGroupModelService.ensureModel({
+ obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
+ obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
+ });
const stateIndex = buildGroupStateIndex(state.groupBlocks ?? {});
const ensuredDecks = new Set();
diff --git a/src/application/services/RenderConfigService.ts b/src/application/services/RenderConfigService.ts
index 1826b17..e5c17aa 100644
--- a/src/application/services/RenderConfigService.ts
+++ b/src/application/services/RenderConfigService.ts
@@ -29,6 +29,8 @@ export class RenderConfigService {
noteModel,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
+ obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
+ obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
};
diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts
index 125df02..c33934c 100644
--- a/src/domain/manual-sync/services/DiffPlannerService.test.ts
+++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts
@@ -210,6 +210,54 @@ describe("DiffPlannerService", () => {
expect(plan.toUpdate).toHaveLength(1);
expect(plan.toChangeDeck).toHaveLength(0);
});
+
+ it("schedules an update when only the backlink label changes", () => {
+ const service = new DiffPlannerService();
+ const oldSettings = createModule3Settings({ obsidianBacklinkLabel: "Open in Obsidian" });
+ const newSettings = createModule3Settings({ obsidianBacklinkLabel: "Open note" });
+ const card = createIndexedCard({
+ noteId: 42,
+ rawBlockHash: "same-hash",
+ idMarkerState: "present-valid",
+ noteIdSource: "marker",
+ });
+ const oldRenderPlan = new RenderConfigService().resolve(card, oldSettings);
+ const state = {
+ files: {},
+ cards: {
+ "42": createCardState({ noteId: 42, rawBlockHash: "same-hash", renderConfigHash: oldRenderPlan.renderConfigHash, deck: oldRenderPlan.deck, tagsHint: [] }),
+ },
+ pendingWriteBack: [],
+ };
+
+ const plan = service.plan([card], state, ["notes/example.md"], newSettings);
+
+ expect(plan.toUpdate).toHaveLength(1);
+ });
+
+ it("schedules an update when only the backlink placement changes", () => {
+ const service = new DiffPlannerService();
+ const oldSettings = createModule3Settings({ obsidianBacklinkPlacement: "answer-last-line" });
+ const newSettings = createModule3Settings({ obsidianBacklinkPlacement: "question-last-line" });
+ const card = createIndexedCard({
+ noteId: 42,
+ rawBlockHash: "same-hash",
+ idMarkerState: "present-valid",
+ noteIdSource: "marker",
+ });
+ const oldRenderPlan = new RenderConfigService().resolve(card, oldSettings);
+ const state = {
+ files: {},
+ cards: {
+ "42": createCardState({ noteId: 42, rawBlockHash: "same-hash", renderConfigHash: oldRenderPlan.renderConfigHash, deck: oldRenderPlan.deck, tagsHint: [] }),
+ },
+ pendingWriteBack: [],
+ };
+
+ const plan = service.plan([card], state, ["notes/example.md"], newSettings);
+
+ expect(plan.toUpdate).toHaveLength(1);
+ });
});
function createIndexedCard(overrides: Partial = {}): IndexedCard {
diff --git a/src/domain/manual-sync/services/ManualCardRenderer.test.ts b/src/domain/manual-sync/services/ManualCardRenderer.test.ts
index fbb8ff9..4aca232 100644
--- a/src/domain/manual-sync/services/ManualCardRenderer.test.ts
+++ b/src/domain/manual-sync/services/ManualCardRenderer.test.ts
@@ -11,6 +11,8 @@ describe("ManualCardRenderer", () => {
const resourceResolver = new FakeManualSyncVaultGateway();
const baseContext: ManualCardRenderContext = {
addObsidianBacklink: false,
+ obsidianBacklinkLabel: "Open in Obsidian",
+ obsidianBacklinkPlacement: "answer-last-line",
convertHighlightsToCloze: false,
keepPureTagLinesInCardBody: true,
resourceResolver,
@@ -71,6 +73,72 @@ describe("ManualCardRenderer", () => {
expect(rendered.renderedFields.body).not.toContain('data-tag="行内代码"');
expect(rendered.renderedFields.body).not.toContain('data-tag="代码块"');
});
+
+ it("keeps the default backlink behavior at the end of the answer body", () => {
+ const rendered = renderer.render(createPlannedCard(), {
+ ...baseContext,
+ addObsidianBacklink: true,
+ });
+
+ expect(rendered.renderedFields.title).toBe("标题");
+ expect(rendered.renderedFields.body).toContain('Open in Obsidian
');
+ });
+
+ it("escapes a custom backlink label", () => {
+ const rendered = renderer.render(createPlannedCard({ heading: 'Title & "Quote"' }), {
+ ...baseContext,
+ addObsidianBacklink: true,
+ obsidianBacklinkLabel: '',
+ });
+
+ expect(rendered.renderedFields.body).toContain('<Open & "Obsidian">');
+ expect(rendered.renderedFields.body).toContain('href="obsidian://open?vault=Vault&file=notes/example.md#Title & "Quote""');
+ });
+
+ it("places the backlink on the last line of the question field when configured", () => {
+ const rendered = renderer.render(createPlannedCard(), {
+ ...baseContext,
+ addObsidianBacklink: true,
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+
+ expect(rendered.renderedFields.title).toContain('
正文
');
+ });
+
+ it("places the backlink at the first line of the answer body when configured", () => {
+ const rendered = renderer.render(createPlannedCard(), {
+ ...baseContext,
+ addObsidianBacklink: true,
+ obsidianBacklinkPlacement: "answer-first-line",
+ });
+
+ expect(rendered.renderedFields.body).toMatch(/^正文
');
+ });
+
+ it("routes question-last-line backlinks through the title field for cloze cards", () => {
+ const rendered = renderer.render(createPlannedCard({ cardType: "cloze" }), {
+ ...baseContext,
+ addObsidianBacklink: true,
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+
+ expect(rendered.renderedFields.title).toContain('anki-heading-sync-backlink');
+ expect(rendered.renderedFields.body).not.toContain('anki-heading-sync-backlink');
+ });
+
+ it("does not render backlinks when the toggle is disabled", () => {
+ const rendered = renderer.render(createPlannedCard(), {
+ ...baseContext,
+ addObsidianBacklink: false,
+ obsidianBacklinkLabel: "Custom Label",
+ obsidianBacklinkPlacement: "question-last-line",
+ });
+
+ expect(rendered.renderedFields.title).not.toContain('anki-heading-sync-backlink');
+ expect(rendered.renderedFields.body).not.toContain('anki-heading-sync-backlink');
+ });
});
function createPlannedCard(overrides: Partial = {}): PlannedCard {
diff --git a/src/domain/manual-sync/services/ManualCardRenderer.ts b/src/domain/manual-sync/services/ManualCardRenderer.ts
index 9c0ad1e..157b442 100644
--- a/src/domain/manual-sync/services/ManualCardRenderer.ts
+++ b/src/domain/manual-sync/services/ManualCardRenderer.ts
@@ -4,6 +4,7 @@ import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVault
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
+import { applyObsidianBacklinkPlacement, renderObsidianBacklinkAnchor } from "@/domain/shared/renderObsidianBacklink";
import { preprocessCardBodyMarkdown } from "./preprocessCardBodyMarkdown";
import { renderObsidianTagChipsInHtml } from "./renderObsidianTagChips";
@@ -26,6 +27,8 @@ const WIKILINK_PATTERN = /(?Open in Obsidian`
+ }),
+ label: context.obsidianBacklinkLabel,
+ })
: "";
+ const renderedFields = backlinkAnchor
+ ? applyObsidianBacklinkPlacement(
+ {
+ title: headingResult.html,
+ body: bodyResult.html,
+ },
+ backlinkAnchor,
+ context.obsidianBacklinkPlacement,
+ )
+ : {
+ title: headingResult.html,
+ body: bodyResult.html,
+ };
return {
card: plannedCard.card,
@@ -67,10 +86,7 @@ export class ManualCardRenderer {
deck: plannedCard.deck,
noteModel: plannedCard.noteModel,
renderConfigHash: plannedCard.renderConfigHash,
- renderedFields: {
- title: headingResult.html,
- body: bodyResult.html + backlinkHtml,
- },
+ renderedFields,
media: dedupeMedia([...headingResult.media, ...bodyResult.media]),
};
}
diff --git a/src/domain/shared/renderObsidianBacklink.ts b/src/domain/shared/renderObsidianBacklink.ts
new file mode 100644
index 0000000..6fecbc1
--- /dev/null
+++ b/src/domain/shared/renderObsidianBacklink.ts
@@ -0,0 +1,53 @@
+interface RenderObsidianBacklinkAnchorOptions {
+ href: string;
+ label: string;
+ escapeHref?: boolean;
+}
+
+interface RenderedBacklinkFields {
+ title: string;
+ body: string;
+}
+
+export function renderObsidianBacklinkAnchor({
+ href,
+ label,
+ escapeHref = true,
+}: RenderObsidianBacklinkAnchorOptions): string {
+ return `${escapeHtml(label)}`;
+}
+
+export function applyObsidianBacklinkPlacement(
+ renderedFields: RenderedBacklinkFields,
+ backlinkAnchor: string,
+ placement: "question-last-line" | "answer-first-line" | "answer-last-line",
+): RenderedBacklinkFields {
+ if (placement === "question-last-line") {
+ return {
+ title: renderedFields.title ? `${renderedFields.title}
${backlinkAnchor}` : backlinkAnchor,
+ body: renderedFields.body,
+ };
+ }
+
+ const backlinkLine = `${backlinkAnchor}
`;
+ if (placement === "answer-first-line") {
+ return {
+ title: renderedFields.title,
+ body: renderedFields.body ? `${backlinkLine}${renderedFields.body}` : backlinkLine,
+ };
+ }
+
+ return {
+ title: renderedFields.title,
+ body: renderedFields.body ? `${renderedFields.body}${backlinkLine}` : backlinkLine,
+ };
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
\ No newline at end of file
diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
index ab33d03..beb0020 100644
--- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
+++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts
@@ -45,6 +45,30 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.cardAnswerCutoffMode).toBe("heading-block");
expect(settings.semanticQaMarker).toBe("#anki-list-qa");
expect(settings.semanticQaNoteType).toBe("Semantic QA");
+ expect(settings.obsidianBacklinkLabel).toBe("Open in Obsidian");
+ expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line");
+ });
+
+ it("normalizes blank backlink labels on load and save", async () => {
+ const store = new InMemoryPluginDataStore({
+ settings: {
+ obsidianBacklinkLabel: " ",
+ },
+ });
+ const repository = new DataJsonPluginConfigRepository(store);
+
+ const loaded = await repository.load();
+
+ expect(loaded.obsidianBacklinkLabel).toBe("Open in Obsidian");
+
+ await repository.save({
+ ...loaded,
+ obsidianBacklinkLabel: " Custom Label ",
+ });
+
+ const reloaded = await repository.load();
+
+ expect(reloaded.obsidianBacklinkLabel).toBe("Custom Label");
});
it("persists note field mappings across save and reload", async () => {
diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
index 08b0c6f..c8bcd4e 100644
--- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
+++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_SETTINGS, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings";
+import { mergePluginSettings, normalizePluginSettings, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings";
import type { PluginConfigRepository } from "@/application/ports/PluginConfigRepository";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
@@ -13,25 +13,20 @@ export class DataJsonPluginConfigRepository implements PluginConfigRepository {
async load(): Promise {
const snapshot = (await this.pluginDataStore.load()) ?? {};
- const mergedSettings: PluginSettings = {
- ...DEFAULT_SETTINGS,
- ...snapshot.settings,
- noteFieldMappings: snapshot.settings?.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings,
- includeFolders: snapshot.settings?.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
- excludeFolders: snapshot.settings?.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
- };
+ const mergedSettings = mergePluginSettings(snapshot.settings);
validatePluginSettings(mergedSettings);
return mergedSettings;
}
async save(settings: PluginSettings): Promise {
- validatePluginSettings(settings);
+ const normalizedSettings = normalizePluginSettings(settings);
+ validatePluginSettings(normalizedSettings);
const snapshot = (await this.pluginDataStore.load()) ?? {};
await this.pluginDataStore.save({
...snapshot,
- settings,
+ settings: normalizedSettings,
});
}
}
\ No newline at end of file
diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts
index 91b9667..68aa5d3 100644
--- a/src/presentation/AnkiHeadingSyncPlugin.ts
+++ b/src/presentation/AnkiHeadingSyncPlugin.ts
@@ -1,6 +1,6 @@
import { Plugin } from "obsidian";
-import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
+import { DEFAULT_SETTINGS, normalizePluginSettings, type PluginSettings } from "@/application/config/PluginSettings";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { DeckTemplateInsertionService } from "@/application/services/DeckTemplateInsertionService";
import { CleanupEmptyDecksUseCase } from "@/application/use-cases/CleanupEmptyDecksUseCase";
@@ -69,10 +69,10 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
return;
}
- const nextSettings: PluginSettings = {
+ const nextSettings = normalizePluginSettings({
...this.settings,
...partialSettings,
- };
+ });
try {
await this.pluginConfigRepository.save(nextSettings);
diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts
index 156f3d4..dcfd855 100644
--- a/src/presentation/i18n/messages/en.ts
+++ b/src/presentation/i18n/messages/en.ts
@@ -61,6 +61,20 @@ export const en = {
name: "Add Obsidian backlink",
desc: "Append a backlink to the source heading into synced cards.",
},
+ obsidianBacklinkLabel: {
+ name: "Obsidian backlink label",
+ desc: "The text shown for the backlink. Blank input falls back to Open in Obsidian.",
+ placeholder: "Open in Obsidian",
+ },
+ obsidianBacklinkPlacement: {
+ name: "Obsidian backlink placement",
+ desc: "Choose whether the backlink is appended to the question field or placed at the start or end of the answer body.",
+ options: {
+ questionLastLine: "Last line of question field",
+ answerFirstLine: "First line of answer body",
+ answerLastLine: "Last line of answer body",
+ },
+ },
highlightsToCloze: {
name: "Highlights to Cloze",
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
@@ -272,6 +286,7 @@ export const en = {
fileDeckInsertLocationInvalid: "File deck insert location must be yaml or body.",
folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.",
scopeModeInvalid: "Scope mode must be one of all, include, or exclude.",
+ obsidianBacklinkPlacementInvalid: "Obsidian backlink placement must be question-last-line, answer-first-line, or answer-last-line.",
includeFoldersArray: "Include folders must be an array.",
includeFoldersStrings: "Include folders must only contain strings.",
excludeFoldersArray: "Exclude folders must be an array.",
diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts
index f83dd2e..f3ec595 100644
--- a/src/presentation/i18n/messages/zh.ts
+++ b/src/presentation/i18n/messages/zh.ts
@@ -59,6 +59,20 @@ export const zh = {
name: "添加 Obsidian 回链",
desc: "把来源标题的回链追加到同步后的卡片中。",
},
+ obsidianBacklinkLabel: {
+ name: "Obsidian 回链显示名称",
+ desc: "设置回链显示的文字。留空或只填空格时会回退为 Open in Obsidian。",
+ placeholder: "Open in Obsidian",
+ },
+ obsidianBacklinkPlacement: {
+ name: "Obsidian 回链放置位置",
+ desc: "选择把回链追加到问题栏最后一行,或放到答案正文的第一行 / 最后一行。",
+ options: {
+ questionLastLine: "问题栏的最后一行",
+ answerFirstLine: "答案正文的第一行",
+ answerLastLine: "答案正文的最后一行",
+ },
+ },
highlightsToCloze: {
name: "高亮转 Cloze",
desc: "把 ==highlight== 片段转换成 cloze 卡片使用的挖空格式。",
@@ -270,6 +284,7 @@ export const zh = {
fileDeckInsertLocationInvalid: "文件级 deck 模板插入位置只能是 yaml 或 body。",
folderDeckModeInvalid: "文件夹 deck 模式只能是 off、folder 或 folder-and-file。",
scopeModeInvalid: "运行范围模式只能是 all、include 或 exclude。",
+ obsidianBacklinkPlacementInvalid: "Obsidian 回链放置位置只能是 question-last-line、answer-first-line 或 answer-last-line。",
includeFoldersArray: "包含文件夹必须是数组。",
includeFoldersStrings: "包含文件夹列表中只能包含字符串。",
excludeFoldersArray: "排除文件夹必须是数组。",
diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts
index 01ee001..2829423 100644
--- a/src/presentation/settings/PluginSettingTab.test.ts
+++ b/src/presentation/settings/PluginSettingTab.test.ts
@@ -484,6 +484,8 @@ describe("AnkiHeadingSyncSettingTab", () => {
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, "Card answer cutoff mode").desc).toBe("A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines.");
+ expect(findSetting(container, "Obsidian backlink label").desc).toBe("The text shown for the backlink. Blank input falls back to Open in Obsidian.");
+ expect(findSetting(container, "Obsidian backlink placement").desc).toBe("Choose whether the backlink is appended to the question field or placed at the start or end of the answer body.");
expect(findSetting(container, "Sync Obsidian tags to Anki").desc).toBe("Sync Obsidian-recognized tags from the current note into Anki note tags, including nested tags. On each sync, these tags overwrite the managed tags in Anki based on the current Obsidian content.");
expect(findSetting(container, "Keep pure tag lines in card body").desc).toBe("When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically.");
expect(findSetting(container, "Run scope").desc).toBe("Only process Markdown files in the checked folders below");
@@ -511,6 +513,8 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4");
expect(findSetting(container, "卡片正文截止模式").desc).toBe("有效同步标记始终优先。没有有效标记时,选择继续到整个标题块末尾,或在首个 2+ 连续空行前截止。");
+ expect(findSetting(container, "Obsidian 回链显示名称").desc).toBe("设置回链显示的文字。留空或只填空格时会回退为 Open in Obsidian。");
+ expect(findSetting(container, "Obsidian 回链放置位置").desc).toBe("选择把回链追加到问题栏最后一行,或放到答案正文的第一行 / 最后一行。");
expect(findSetting(container, "同步 Obsidian 标签到 Anki").desc).toBe("将当前笔记中 Obsidian 识别到的标签同步为 Anki 笔记标签,支持嵌套标签。每次同步时,这些标签都会以当前 Obsidian 内容为准覆盖 Anki。");
expect(findSetting(container, "在卡片正文中保留纯标签行").desc).toBe("关闭后,会删除正文中所有只包含标签的整行,例如 “#项目A #重点/案例”。像“这是 #标签 的案例”这类行内标签,或“标签:#项目A”这类带普通文字的行,不会被删除。删除后会自动清理多余空行。");
expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件");
@@ -726,6 +730,45 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(getToggle(findSetting(container, "Keep pure tag lines in card body")).value).toBe(false);
});
+ it("trims the backlink label on save and falls back to the default label when blank", async () => {
+ const plugin = new FakePlugin();
+ const tab = new AnkiHeadingSyncSettingTab(plugin as never);
+ const container = tab.containerEl as unknown as FakeContainerElInstance;
+
+ tab.display();
+
+ const labelInput = getText(findSetting(container, "Obsidian backlink label"));
+ expect(labelInput.value).toBe("Open in Obsidian");
+
+ await labelInput.triggerChange(" Open note ");
+ expect(plugin.settings.obsidianBacklinkLabel).toBe("Open note");
+
+ await labelInput.triggerChange(" ");
+ expect(plugin.settings.obsidianBacklinkLabel).toBe("Open in Obsidian");
+ });
+
+ it("saves and rehydrates the backlink placement dropdown", async () => {
+ const plugin = new FakePlugin();
+ const tab = new AnkiHeadingSyncSettingTab(plugin as never);
+ const container = tab.containerEl as unknown as FakeContainerElInstance;
+
+ tab.display();
+
+ const placementDropdown = getDropdown(findSetting(container, "Obsidian backlink placement"));
+ expect(placementDropdown.value).toBe("answer-last-line");
+ expect(placementDropdown.options.map((option: { value: string }) => option.value)).toEqual([
+ "question-last-line",
+ "answer-first-line",
+ "answer-last-line",
+ ]);
+
+ await placementDropdown.triggerChange("question-last-line");
+ expect(plugin.settings.obsidianBacklinkPlacement).toBe("question-last-line");
+
+ tab.display();
+ expect(getDropdown(findSetting(container, "Obsidian backlink placement")).value).toBe("question-last-line");
+ });
+
it("removes the old folder textareas and hides the folder tree in all mode", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts
index f5279db..817bcf7 100644
--- a/src/presentation/settings/PluginSettingTab.ts
+++ b/src/presentation/settings/PluginSettingTab.ts
@@ -1,6 +1,15 @@
import { PluginSettingTab, Setting } from "obsidian";
-import { isValidHashtagMarker, isValidSemanticQaMarker, type CardAnswerCutoffMode, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
+import {
+ DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ isValidHashtagMarker,
+ isValidSemanticQaMarker,
+ type CardAnswerCutoffMode,
+ type FileDeckInsertLocation,
+ type FolderDeckMode,
+ type ObsidianBacklinkPlacement,
+ type ScopeMode,
+} from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
@@ -157,6 +166,33 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
+ new Setting(containerEl)
+ .setName(t("settings.syncOptions.obsidianBacklinkLabel.name"))
+ .setDesc(t("settings.syncOptions.obsidianBacklinkLabel.desc"))
+ .addText((text) => {
+ text
+ .setPlaceholder(t("settings.syncOptions.obsidianBacklinkLabel.placeholder"))
+ .setValue(settings.obsidianBacklinkLabel)
+ .onChange((value) => {
+ const nextValue = value.trim();
+ void this.plugin.updateSettings({
+ obsidianBacklinkLabel: nextValue || DEFAULT_OBSIDIAN_BACKLINK_LABEL,
+ });
+ });
+ });
+
+ new Setting(containerEl)
+ .setName(t("settings.syncOptions.obsidianBacklinkPlacement.name"))
+ .setDesc(t("settings.syncOptions.obsidianBacklinkPlacement.desc"))
+ .addDropdown((dropdown) => {
+ dropdown.addOption("question-last-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.questionLastLine"));
+ dropdown.addOption("answer-first-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.answerFirstLine"));
+ dropdown.addOption("answer-last-line", t("settings.syncOptions.obsidianBacklinkPlacement.options.answerLastLine"));
+ dropdown.setValue(settings.obsidianBacklinkPlacement).onChange((value) => {
+ void this.plugin.updateSettings({ obsidianBacklinkPlacement: value as ObsidianBacklinkPlacement });
+ });
+ });
+
new Setting(containerEl)
.setName(t("settings.syncOptions.highlightsToCloze.name"))
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))