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"))