feat: 支持可配置 Obsidian 回链显示名称与位置 / configurable Obsidian backlink label and placement

覆盖普通卡、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.
This commit is contained in:
Dusk 2026-04-23 23:08:00 +08:00
parent 978a8c1e02
commit b82e1dd53d
22 changed files with 750 additions and 31 deletions

View file

@ -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<br><br>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` 末尾追加 `<br>` + 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 负责两件事:
- 生成 `<a class="anki-heading-sync-backlink" href="...">label</a>`
- 在普通卡与 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}}` 后、`<hr id="answer">` 前
- `answer-first-line`: `<div class="a">...</div>`
- `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` 已是块级 `<p>...</p>` 形态
- 保持 body 两种 placement 都使用独立一行块级 HTML更稳定也更接近“第一行 / 最后一行”的视觉语义
- 不会影响 Cloze 合并结构或 note field mapping

View file

@ -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` 末尾追加 `<p><a ...>Open in Obsidian</a></p>`
- 没有 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<br><br>body` 合并形状就会自然继承新位置。
### 3. QA Group 12 也仍然硬编码默认回链
`src/application/services/QaGroupModelDefinition.ts` 当前在模板生成里写死:
- back 模板固定把 `<a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a>` 放在答案容器后
- `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 开关语义

View file

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

View file

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

View file

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

View file

@ -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}}<p>${backlinkAnchor}</p>{{/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}}<div class="stem">{{Stem}}</div>\n<div class="q">{{${slotId}_Q}}</div>{{/${slotId}_A}}{{/${slotId}_Q}}`,
back: `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/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<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>`;
}
if (backlinkPlacement === "answer-first-line") {
return `{{FrontSide}}\n\n<hr id="answer">\n\n${backlinkLine}\n<div class="a">{{${slotId}_A}}</div>`;
}
return `{{FrontSide}}\n\n<hr id="answer">\n\n<div class="a">{{${slotId}_A}}</div>\n${backlinkLine}`;
}
export const QA_GROUP_MODEL_CSS = [
".card {",
" font-family: 'Helvetica Neue', Arial, sans-serif;",

View file

@ -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('<a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a>');
expect(definition.templates[0]?.back).not.toContain('<div class="meta">{{Src}}</div>');
expect(definition.templates[0]?.back).toContain('<div class="a">{{S01_A}}</div>');
});
it("escapes a custom QA Group backlink label", () => {
const definition = buildQaGroupModelDefinition({
obsidianBacklinkLabel: 'Open <Obsidian> & "Now"',
});
expect(definition.templates[0]?.back).toContain('Open &lt;Obsidian&gt; &amp; &quot;Now&quot;');
expect(definition.templates[0]?.back).not.toContain('Open <Obsidian> & "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}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}\n\n<hr id="answer">');
expect(answerFirstPlacement.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/Src}}\n<div class="a">{{S01_A}}</div>');
expect(answerLastPlacement.templates[0]?.back).toContain('<div class="a">{{S01_A}}</div>\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open in Obsidian</a></p>{{/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({

View file

@ -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<void> {
const definition = buildQaGroupModelDefinition();
async ensureModel(settings?: Pick<PluginSettings, "obsidianBacklinkLabel" | "obsidianBacklinkPlacement">): Promise<void> {
const definition = buildQaGroupModelDefinition(settings);
const modelNames = await this.runModelAction(
"listNoteModels",
definition.modelName,

View file

@ -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 <Vault>',
obsidianBacklinkPlacement: "answer-first-line",
}));
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('Open &lt;Vault&gt;');
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open &lt;Vault&gt;</a></p>{{/Src}}\n<div class="a">{{S01_A}}</div>');
});
it("preserves slots across reorder and reuses a free slot for a new item", async () => {
const ankiGateway = new FakeManualSyncAnkiGateway();
const vaultGateway = new FakeManualSyncVaultGateway();

View file

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

View file

@ -29,6 +29,8 @@ export class RenderConfigService {
noteModel,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
};

View file

@ -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> = {}): IndexedCard {

View file

@ -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('<p><a class="anki-heading-sync-backlink" href="obsidian://open?vault=Vault&amp;file=notes/example.md#标题">Open in Obsidian</a></p>');
});
it("escapes a custom backlink label", () => {
const rendered = renderer.render(createPlannedCard({ heading: 'Title & "Quote"' }), {
...baseContext,
addObsidianBacklink: true,
obsidianBacklinkLabel: '<Open & "Obsidian">',
});
expect(rendered.renderedFields.body).toContain('&lt;Open &amp; &quot;Obsidian&quot;&gt;');
expect(rendered.renderedFields.body).toContain('href="obsidian://open?vault=Vault&amp;file=notes/example.md#Title &amp; &quot;Quote&quot;"');
});
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('<br><a class="anki-heading-sync-backlink"');
expect(rendered.renderedFields.body).toBe('<p>正文</p>');
});
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(/^<p><a class="anki-heading-sync-backlink"/);
expect(rendered.renderedFields.body).toContain('<p>正文</p>');
});
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<IndexedCard> = {}): PlannedCard {

View file

@ -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 = /(?<!!)\[\[([^\]]+)\]\]/g;
export interface ManualCardRenderContext {
addObsidianBacklink: boolean;
obsidianBacklinkLabel: string;
obsidianBacklinkPlacement: "question-last-line" | "answer-first-line" | "answer-last-line";
convertHighlightsToCloze: boolean;
keepPureTagLinesInCardBody: boolean;
resourceResolver: ManualSyncVaultGateway;
@ -46,8 +49,9 @@ export class ManualCardRenderer {
plannedCard.card.cardType === "cloze",
false,
);
const backlinkHtml = context.addObsidianBacklink
? `<p><a class="anki-heading-sync-backlink" href="${escapeHtml(context.resourceResolver.createBacklink({
const backlinkAnchor = context.addObsidianBacklink
? renderObsidianBacklinkAnchor({
href: context.resourceResolver.createBacklink({
filePath: plannedCard.card.filePath,
sourceContent: plannedCard.card.sourceContent,
headingLine: plannedCard.card.blockStartLine,
@ -58,8 +62,23 @@ export class ManualCardRenderer {
markerLine: plannedCard.card.markerLine,
headingLevel: plannedCard.card.headingLevel,
headingText: plannedCard.card.backlinkHeadingText,
}))}">Open in Obsidian</a></p>`
}),
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]),
};
}

View file

@ -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 `<a class="anki-heading-sync-backlink" href="${escapeHref ? escapeHtml(href) : href}">${escapeHtml(label)}</a>`;
}
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}<br>${backlinkAnchor}` : backlinkAnchor,
body: renderedFields.body,
};
}
const backlinkLine = `<p>${backlinkAnchor}</p>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View file

@ -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 () => {

View file

@ -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<PluginSettings> {
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<void> {
validatePluginSettings(settings);
const normalizedSettings = normalizePluginSettings(settings);
validatePluginSettings(normalizedSettings);
const snapshot = (await this.pluginDataStore.load()) ?? {};
await this.pluginDataStore.save({
...snapshot,
settings,
settings: normalizedSettings,
});
}
}

View file

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

View file

@ -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.",

View file

@ -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: "排除文件夹必须是数组。",

View file

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

View file

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