mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
978a8c1e02
commit
b82e1dd53d
22 changed files with 750 additions and 31 deletions
85
docs/obsidian-backlink-config-decisions.md
Normal file
85
docs/obsidian-backlink-config-decisions.md
Normal 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
|
||||
119
docs/obsidian-backlink-config-gap-report.md
Normal file
119
docs/obsidian-backlink-config-gap-report.md
Normal 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 开关语义
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;",
|
||||
|
|
|
|||
|
|
@ -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 <Obsidian> & "Now"');
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <Vault>');
|
||||
expect(ankiGateway.createdModels[0]?.templates[0]?.back).toContain('<hr id="answer">\n\n{{#Src}}<p><a class="anki-heading-sync-backlink" href="{{Src}}">Open <Vault></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();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export class RenderConfigService {
|
|||
noteModel,
|
||||
mapping,
|
||||
addObsidianBacklink: settings.addObsidianBacklink,
|
||||
obsidianBacklinkLabel: settings.obsidianBacklinkLabel,
|
||||
obsidianBacklinkPlacement: settings.obsidianBacklinkPlacement,
|
||||
convertHighlightsToCloze: settings.convertHighlightsToCloze,
|
||||
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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&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('<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('<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 {
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
53
src/domain/shared/renderObsidianBacklink.ts
Normal file
53
src/domain/shared/renderObsidianBacklink.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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: "排除文件夹必须是数组。",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue