fix(release): clean remaining pre-release state issues

中文: 修复 pendingWriteBack 持久化加载,丢弃旧 Semantic QA 状态,并修正发布链接与多级列表问答文案。

English: Preserves pendingWriteBack state loading, drops old Semantic QA state, and fixes release links and QA Group wording.
This commit is contained in:
Dusk 2026-04-27 21:11:48 +08:00
parent a9033664b8
commit 98d64dac21
9 changed files with 452 additions and 45 deletions

View file

@ -6,7 +6,7 @@ Anki Heading Sync is an Obsidian desktop plugin for syncing Markdown heading blo
- Sync basic Q&A cards from heading blocks
- Sync cloze cards from heading blocks with cloze-specific recognition
- Sync QA Group list blocks into managed group notes
- Sync QA Group list blocks into multi-level list QA notes
- Sync the current file or all in-scope files in the vault
- Write Anki IDs and GI markers back into Markdown after sync
- Support deck routing, Obsidian backlinks, tag sync, and current-file reset

View file

@ -0,0 +1,53 @@
# Pre-release Remaining Fixes Decisions
## Pending writeback validation rules
- Preserve modern `PendingWriteBackState` entries during load only when all required fields are valid.
- Required fields:
- `filePath`: non-empty string
- `blockStartLine`: positive integer
- `expectedFileHash`: non-empty string
- `targetMarker`: non-empty string
- `rawBlockHash`: non-empty string
- `targetNoteId`: positive integer
- `markerKind` rules:
- `undefined` is valid
- `"card-id"` is valid
- `"group-gi"` is valid
- any other value is invalid and drops the item
- `targetGroupId` rules:
- if absent, keep normal validation result
- if present, it must be a non-empty string
- malformed `targetGroupId` drops the item
## Legacy pending dropping rule
- Do not migrate legacy pending entries that only contain legacy identity fields such as `cardId` or `noteId`.
- Any pending entry missing `targetNoteId` is dropped.
- Do not synthesize or coerce malformed pending items into the modern shape.
## Old Semantic QA state dropping rule
- Current supported loaded card state remains limited to:
- `basic`
- `cloze`
- Removed card types such as `semantic-qa` are dropped during load.
- Unknown removed card types are also dropped during load.
- Dropped cards are not written into `state.cards`.
- File `noteIds` are rebuilt from the filtered migrated card map so dropped cards cannot leave dangling note IDs behind.
## README wording decision
- Replace `managed group notes` with current-product wording centered on multi-level list QA cards/notes.
- Keep the README change focused to the active feature description only.
## i18n wording decision
- Remove user-facing managed-model narrative from active settings copy.
- Replace old wording such as `QA Group 12`, `ObsiAnki QA Group 12`, `Managed note type`, and `Managed model contract` with current route wording for multi-level list QA notes.
- Keep internal model-definition constants unchanged unless a user-facing behavior requires a rename.
## Intentional deviations from the plan
- Internal technical constants such as `QA_GROUP_MODEL_NAME` are not renamed in this round because the requested cleanup is limited to active user-facing wording and persistence behavior.
- Historical audit docs are left untouched even if they still mention old QA Group 12 or Semantic QA history.

View file

@ -0,0 +1,90 @@
# Pre-release Remaining Fixes Gap Report
## Scope
This report records the real repository state on 2026-04-27 before implementing the remaining pre-release fixes.
## Pending writeback load behavior
- Runtime state shape already defines modern `PendingWriteBackState` in `src/domain/manual-sync/entities/PluginState.ts` with:
- `filePath`
- `blockStartLine`
- `expectedFileHash`
- `targetMarker`
- `rawBlockHash`
- `targetNoteId`
- optional `markerKind`
- optional `targetGroupId`
- `src/application/services/MarkdownWriteBackService.ts` already emits modern pending entries for both:
- card ID marker writes with `markerKind: "card-id"`
- group GI writes with `markerKind: "group-gi"` and `targetGroupId`
- `src/infrastructure/persistence/DataJsonPluginStateRepository.ts` currently drops all persisted pending entries on load by returning `pendingWriteBack: []` from `migratePluginState()`.
- Result:
- writeback conflicts are persisted during a session
- after Obsidian restart, the recovery queue is lost
## Malformed and legacy pending handling
- `DataJsonPluginStateRepository.ts` declares `LegacyPendingWriteBackState`, but current load migration does not validate or preserve any pending items.
- Current repository behavior therefore treats all pending shapes the same way:
- valid modern items are dropped
- malformed items are dropped
- legacy `cardId` / `noteId` only items are dropped
- There is currently no explicit validation contract for pending items during load.
## Old Semantic QA state behavior
- `DataJsonPluginStateRepository.ts` still migrates every loaded card through `migrateCardState()`.
- `sanitizeCardType()` currently keeps `"cloze"` and converts every other value to `"basic"`.
- Result:
- removed `semantic-qa` cards are silently converted to `basic`
- converted cards remain in `state.cards`
- converted cards still contribute note IDs to `files[*].noteIds` through `collectMigratedFileNoteIds()`
- Current test coverage in `src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts` explicitly asserts this silent conversion.
## Release link status
- `manifest.json` currently has:
- `author: "Dusk"`
- `authorUrl: "https://github.com/panAtGitHub"`
- Required repository link is not yet applied.
## README wording status
- `README.md` still says:
- `Sync QA Group list blocks into managed group notes`
- This is stale product language relative to the current user-facing route.
## i18n legacy wording status
- Active user-facing wording in `src/presentation/i18n/messages/en.ts` and `src/presentation/i18n/messages/zh.ts` still includes:
- `QA Group 12`
- `ObsiAnki QA Group 12`
- `Managed note type`
- `Managed model contract`
- `托管笔记类型`
- `托管模型约束`
- These messages are still part of the live settings UI.
- Current active text suggests a fixed managed-model narrative that does not match the intended release wording.
## Build and sync script status
- `scripts/stage-plugin-dist.mjs` stages:
- `manifest.json`
- `versions.json`
- optional `styles.css`
- generated package `README.md`
- `scripts/sync-plugin-dist.mjs` copies from `dist/plugin`:
- `main.js`
- `manifest.json`
- optional `styles.css`
- The generic sync script does not delete `data.json`, but repository instructions still require strict file selection during final vault sync.
## Exact files likely to change
- `src/infrastructure/persistence/DataJsonPluginStateRepository.ts`
- `src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts`
- `manifest.json`
- `README.md`
- `src/presentation/i18n/messages/en.ts`
- `src/presentation/i18n/messages/zh.ts`

40
main.js

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,6 @@
"minAppVersion": "1.5.0",
"description": "Focused heading-based Obsidian to Anki sync plugin.",
"author": "Dusk",
"authorUrl": "https://github.com/panAtGitHub",
"authorUrl": "https://github.com/panAtGitHub/AnkiHeadingSync",
"isDesktopOnly": true
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState";
import { createEmptyPluginState, type PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
import { DataJsonPluginStateRepository } from "./DataJsonPluginStateRepository";
@ -115,7 +115,116 @@ describe("DataJsonPluginStateRepository", () => {
expect(loaded.pendingWriteBack).toEqual([]);
});
it("normalizes removed semantic QA card types to basic when loading modern plugin state", async () => {
it("preserves valid modern pending writeback entries across save and load", async () => {
const store = new InMemoryPluginDataStore();
const repository = new DataJsonPluginStateRepository(store);
const pendingWriteBack: PendingWriteBackState[] = [
createPendingWriteBackState({
filePath: "notes/card.md",
blockStartLine: 2,
expectedFileHash: "hash-card",
targetMarker: "<!--ID: 41-->",
rawBlockHash: "raw-card",
targetNoteId: 41,
markerKind: "card-id",
}),
createPendingWriteBackState({
filePath: "notes/group.md",
blockStartLine: 6,
expectedFileHash: "hash-group",
targetMarker: "<!--GI:n=42;i=item_a:1;f=2,3,4-->",
rawBlockHash: "raw-group",
targetNoteId: 42,
markerKind: "group-gi",
targetGroupId: "group-1",
}),
createPendingWriteBackState({
filePath: "notes/legacy-shape-but-modern-fields.md",
blockStartLine: 9,
expectedFileHash: "hash-no-kind",
targetMarker: "<!--ID: 43-->",
rawBlockHash: "raw-no-kind",
targetNoteId: 43,
}),
];
await repository.save({
files: {},
cards: {},
pendingWriteBack,
});
await expect(repository.load()).resolves.toMatchObject({
pendingWriteBack,
});
});
it("drops malformed and legacy pending writeback entries during load", async () => {
const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
pluginState: {
files: {},
cards: {},
pendingWriteBack: [
{
filePath: "notes/missing-note-id.md",
blockStartLine: 1,
expectedFileHash: "hash",
targetMarker: "<!--ID: 1-->",
rawBlockHash: "raw",
},
{
filePath: "notes/wrong-line.md",
blockStartLine: "1" as never,
expectedFileHash: "hash",
targetMarker: "<!--ID: 2-->",
rawBlockHash: "raw",
targetNoteId: 2,
},
{
filePath: "notes/invalid-kind.md",
blockStartLine: 1,
expectedFileHash: "hash",
targetMarker: "<!--ID: 3-->",
rawBlockHash: "raw",
targetNoteId: 3,
markerKind: "other" as never,
},
{
filePath: "notes/invalid-group-id.md",
blockStartLine: 1,
expectedFileHash: "hash",
targetMarker: "<!--GI:n=4;i=item_a:1;f=2-->",
rawBlockHash: "raw",
targetNoteId: 4,
markerKind: "group-gi",
targetGroupId: "",
},
{
filePath: "notes/legacy-only.md",
cardId: "ahs_1",
noteId: 5,
expectedFileHash: "hash",
targetMarker: "<!--AHS:card=ahs_1 note=5-->",
rawBlockHash: "raw",
},
{
filePath: "notes/missing-hash.md",
blockStartLine: 1,
expectedFileHash: "",
targetMarker: "<!--ID: 6-->",
rawBlockHash: "raw",
targetNoteId: 6,
},
],
} as never,
}));
await expect(repository.load()).resolves.toMatchObject({
pendingWriteBack: [],
});
});
it("drops removed semantic QA card state instead of converting it to basic", async () => {
const rawBlockText = ["semantic-qa:核心产品::1", "城市更新", "核心产品", "百人会"].join("\n");
const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
pluginState: {
@ -159,7 +268,94 @@ describe("DataJsonPluginStateRepository", () => {
const loaded = await repository.load();
expect(loaded.cards["52"]?.cardType).toBe("basic");
expect(loaded.cards["52"]?.backlinkHeadingText).toBe("城市更新 #anki-list-qa");
expect(loaded.cards["52"]).toBeUndefined();
expect(loaded.files["notes/example.md"]?.noteIds).toEqual([]);
});
});
it("keeps supported basic and cloze card states while filtering dangling file noteIds", async () => {
const basicRawBlockText = ["#### Prompt", "Answer"].join("\n");
const clozeRawBlockText = ["##### Cloze", "{{c1::Body}}"].join("\n");
const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore({
pluginState: {
files: {
"notes/example.md": {
filePath: "notes/example.md",
fileHash: "hash",
fileStamp: "1:1",
lastIndexedAt: 1,
noteIds: [41, 42, 99],
},
},
cards: {
"41": {
noteId: 41,
filePath: "notes/example.md",
heading: "Prompt",
backlinkHeadingText: "Prompt",
headingLevel: 4,
bodyMarkdown: "Answer",
cardType: "basic",
blockStartOffset: 0,
blockEndOffset: basicRawBlockText.length,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: basicRawBlockText,
rawBlockHash: hashString(basicRawBlockText),
renderConfigHash: "render-basic",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
"42": {
noteId: 42,
filePath: "notes/example.md",
heading: "Cloze",
backlinkHeadingText: "Cloze",
headingLevel: 5,
bodyMarkdown: "{{c1::Body}}",
cardType: "cloze",
blockStartOffset: 0,
blockEndOffset: clozeRawBlockText.length,
blockStartLine: 4,
bodyStartLine: 5,
blockEndLine: 5,
contentEndLine: 5,
rawBlockText: clozeRawBlockText,
rawBlockHash: hashString(clozeRawBlockText),
renderConfigHash: "render-cloze",
deck: "notes",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
},
}));
const loaded = await repository.load();
expect(Object.keys(loaded.cards)).toEqual(["41", "42"]);
expect(loaded.cards["41"]?.cardType).toBe("basic");
expect(loaded.cards["42"]?.cardType).toBe("cloze");
expect(loaded.files["notes/example.md"]?.noteIds).toEqual([41, 42]);
});
});
function createPendingWriteBackState(overrides: Partial<PendingWriteBackState> = {}): PendingWriteBackState {
return {
filePath: overrides.filePath ?? "notes/example.md",
blockStartLine: overrides.blockStartLine ?? 1,
expectedFileHash: overrides.expectedFileHash ?? "hash",
targetMarker: overrides.targetMarker ?? "<!--ID: 41-->",
rawBlockHash: overrides.rawBlockHash ?? "raw-block-hash",
targetNoteId: overrides.targetNoteId ?? 41,
markerKind: overrides.markerKind,
targetGroupId: overrides.targetGroupId,
};
}

View file

@ -62,6 +62,10 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
}
const nextCard = migrateCardState(rawCard, noteId);
if (!nextCard) {
continue;
}
const noteKey = toNoteIdKey(noteId);
const existingCard = cards[noteKey];
if (!existingCard || existingCard.lastSyncedAt <= nextCard.lastSyncedAt) {
@ -99,11 +103,16 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
files,
cards,
groupBlocks,
pendingWriteBack: [],
pendingWriteBack: migratePendingWriteBack(pluginState.pendingWriteBack),
};
}
function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState {
function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState | null {
const cardType = sanitizeCardType(rawCard.cardType);
if (!cardType) {
return null;
}
return {
noteId,
filePath: typeof rawCard.filePath === "string" ? rawCard.filePath : "",
@ -113,7 +122,7 @@ function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState {
: (typeof rawCard.heading === "string" ? rawCard.heading : ""),
headingLevel: typeof rawCard.headingLevel === "number" ? rawCard.headingLevel : 1,
bodyMarkdown: typeof rawCard.bodyMarkdown === "string" ? rawCard.bodyMarkdown : "",
cardType: sanitizeCardType(rawCard.cardType),
cardType,
blockStartOffset: typeof rawCard.blockStartOffset === "number" ? rawCard.blockStartOffset : 0,
blockEndOffset: typeof rawCard.blockEndOffset === "number" ? rawCard.blockEndOffset : 0,
blockStartLine: typeof rawCard.blockStartLine === "number" ? rawCard.blockStartLine : 1,
@ -134,12 +143,12 @@ function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState {
};
}
function sanitizeCardType(value: unknown): CardState["cardType"] {
if (value === "cloze") {
function sanitizeCardType(value: unknown): CardState["cardType"] | undefined {
if (value === "basic" || value === "cloze") {
return value;
}
return "basic";
return undefined;
}
function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: string): GroupBlockState | null {
@ -230,6 +239,53 @@ function collectMigratedFileNoteIds(
return Array.from(noteIds);
}
function migratePendingWriteBack(pendingWriteBack: LegacyPendingWriteBackState[] | undefined): PendingWriteBackState[] {
if (!Array.isArray(pendingWriteBack)) {
return [];
}
return pendingWriteBack.flatMap((rawPending) => {
const nextPending = migratePendingWriteBackState(rawPending);
return nextPending ? [nextPending] : [];
});
}
function migratePendingWriteBackState(rawPending: LegacyPendingWriteBackState): PendingWriteBackState | null {
const filePath = sanitizeNonEmptyString(rawPending.filePath);
const blockStartLine = sanitizePositiveInteger(rawPending.blockStartLine);
const expectedFileHash = sanitizeNonEmptyString(rawPending.expectedFileHash);
const targetMarker = sanitizeNonEmptyString(rawPending.targetMarker);
const rawBlockHash = sanitizeNonEmptyString(rawPending.rawBlockHash);
const targetNoteId = sanitizeNoteId(rawPending.targetNoteId);
if (!filePath || !blockStartLine || !expectedFileHash || !targetMarker || !rawBlockHash || !targetNoteId) {
return null;
}
const markerKind = sanitizeMarkerKind(rawPending.markerKind);
if (rawPending.markerKind !== undefined && !markerKind) {
return null;
}
const targetGroupId = rawPending.targetGroupId === undefined
? undefined
: sanitizeNonEmptyString(rawPending.targetGroupId);
if (rawPending.targetGroupId !== undefined && !targetGroupId) {
return null;
}
return {
filePath,
blockStartLine,
expectedFileHash,
targetMarker,
rawBlockHash,
targetNoteId,
markerKind,
targetGroupId,
};
}
function collectMigratedFileGroupIds(
rawFile: LegacyFileState,
rawGroupBlocks: Record<string, LegacyGroupBlockState>,
@ -243,4 +299,16 @@ function collectMigratedFileGroupIds(
function sanitizeNoteId(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function sanitizePositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function sanitizeNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function sanitizeMarkerKind(value: unknown): PendingWriteBackState["markerKind"] | undefined {
return value === "card-id" || value === "group-gi" ? value : undefined;
}

View file

@ -33,14 +33,14 @@ export const en = {
},
},
qaGroup: {
title: "QA Group 12",
title: "QA Group list notes",
marker: {
name: "QA Group marker",
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one ObsiAnki QA Group 12 note.",
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one QA Group list note.",
placeholder: "#anki-list",
},
managedNoteType: "Managed note type: {{modelName}}. QA Group sync writes Stem / GroupId / Src / S01..S12 directly. The same note type can also appear in the field mapping panels below if you want to reuse it for other routes.",
managedModelContract: "Managed model contract: {{fieldCount}} fields and {{templateCount}} templates are checked automatically during sync.",
managedNoteType: "Current note type: {{modelName}}. This route syncs QA Group list notes using the field mapping shown below.",
managedModelContract: "Current note type summary: {{fieldCount}} fields and {{templateCount}} templates are available for this route.",
},
syncOptions: {
addObsidianBacklink: {
@ -204,9 +204,9 @@ export const en = {
advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.",
markerPlaceholder: "/",
fieldsUnavailable: "-- Read fields first --",
autoManagedField: "Managed automatically",
autoManagedField: "Written automatically",
autoComposedAnswer: "Auto composed",
qaGroupManagedNoteType: "{{modelName}} (managed automatically)",
qaGroupManagedNoteType: "{{modelName}} (for QA Group list notes)",
statusLabel: "Status: ",
cacheEmpty: "There are no cached Anki note templates yet. Read the Anki template config manually.",
cacheSummary: "Currently using {{noteTypeCount}} cached note templates with {{configuredCount}} selected card modes configured.",

View file

@ -31,14 +31,14 @@ export const zh = {
},
},
qaGroup: {
title: "QA Group 12",
title: "问答题(多级列表形式)",
marker: {
name: "QA Group 标记",
desc: "当 QA 标题以这个 hashtag 标记结尾时,整个标题块会作为一张 ObsiAnki QA Group 12 笔记同步。",
desc: "当 QA 标题以这个 hashtag 标记结尾时,整个标题块会同步为一条多级列表问答笔记。",
placeholder: "#anki-list",
},
managedNoteType: "托管笔记类型:{{modelName}}。QA Group 同步会直接写入 Stem / GroupId / Src / S01..S12。如果你想在其他同步路径里复用它它也会出现在下面的字段映射面板中。",
managedModelContract: "托管模型约束:同步时会自动检查 {{fieldCount}} 个字段和 {{templateCount}} 个模板。",
managedNoteType: "当前笔记类型:{{modelName}}。这条路线会按下方字段映射同步多级列表问答内容。",
managedModelContract: "当前笔记类型摘要:这条路线可用 {{fieldCount}} 个字段和 {{templateCount}} 个模板。",
},
syncOptions: {
addObsidianBacklink: {
@ -202,9 +202,9 @@ export const zh = {
advancedHint: "高级项:将 AnkiConnect URL 保留在这里,不单独做连接状态卡片。",
markerPlaceholder: "/",
fieldsUnavailable: "-- 请先读取字段 --",
autoManagedField: "自动维护",
autoManagedField: "自动写入",
autoComposedAnswer: "自动拼接",
qaGroupManagedNoteType: "{{modelName}}自动维护",
qaGroupManagedNoteType: "{{modelName}}用于多级列表问答",
statusLabel: "状态:",
cacheEmpty: "当前没有已缓存的 Anki 笔记模板。请手动读取 Anki 里的模板配置。",
cacheSummary: "当前使用缓存的 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式。",