feat: 新增 cardAnswerCutoffMode 卡片正文截止模式,支持 heading-block / double-blank-lines,并统一 marker/GI 写回归整规则

feat: add cardAnswerCutoffMode with heading-block / double-blank-lines and unify marker/GI writeback normalization.
This commit is contained in:
Dusk 2026-04-23 10:16:41 +08:00
parent 67854cf154
commit 4052eff0e7
23 changed files with 1051 additions and 249 deletions

View file

@ -0,0 +1,120 @@
# Card Answer Cutoff Decisions
Date: 2026-04-23
## Decision 1: Add a new explicit settings enum
- Add `cardAnswerCutoffMode` to `PluginSettings` as:
- `heading-block`
- `double-blank-lines`
- Default value is `heading-block`.
- Validation rejects anything else.
Reason:
- This matches the plan and keeps the new behavior fully explicit in persistence, tests, and fingerprint invalidation.
## Decision 2: Use one shared boundary helper for all three parsing routes
- Introduce a shared helper under manual-sync parsing services.
- The helper will accept:
- source lines for the current region
- absolute start line
- fallback line for empty content
- cutoff mode
- marker matcher/parser callbacks
- The helper will return at least:
- `contentLines`
- `trailingLines`
- `contentEndLine`
- `markerLine`
- `markerIndent`
- marker parse result when valid
- marker presence state
Reason:
- The actual repository currently duplicates this logic three times. Consolidation is the smallest change that enforces plan-wide consistency.
## Decision 3: Marker precedence scans the whole region, not only the last non-empty line
- The helper will search for the first valid marker candidate inside the region.
- A valid marker always wins over blank-line cutoff.
- Only valid markers lock the cutoff boundary.
- Invalid marker candidates do not lock the boundary.
- If no valid marker exists and mode is `double-blank-lines`, the first run of 2 or more consecutive blank lines ends the content.
- If neither applies, fallback remains the full heading block / child region / group block.
Reason:
- The plan requires old layout compatibility and new layout support where marker/GI is moved before remarks.
## Decision 4: Preserve current hashing semantics except for intentional cutoff changes
- Normal QA/Cloze `rawBlockText` stays `heading + trimmed content lines`.
- Semantic QA `rawBlockText` stays `semantic-qa child identity + parent display title + child label + bodyMarkdown`.
- QA Group `rawBlockText` stays based on stem, parsed items, and normalized body lines.
- The only intended hash shifts are those caused by the new cutoff rule excluding remarks/trailing content from the answer region.
Reason:
- This keeps state recovery behavior as stable as possible while still honoring the new contract.
## Decision 5: Normalize marker/GI writeback with a shared blank-tail routine
- Card and group marker services will share a common normalization helper.
- The writeback contract is:
- trim blank lines immediately after the answer region
- remove existing marker line when replacing
- insert the new marker at `contentEndLine`
- remove any blank lines between marker and trailing content
- if trailing non-blank content exists, insert exactly two blank lines after the marker
- preserve trailing non-blank content exactly as scanned
- QA Group writeback will continue to strip legacy inner ID markers.
Reason:
- The current services already do most of this. Sharing the normalization step reduces divergence and makes the plans rule explicit.
## Decision 6: Thread cutoff mode through indexing context only
- Add the setting to `CardIndexingContext`.
- `FileIndexerService` passes it into `CardIndexingService.index()` for both vault and single-file indexing.
- No rendering-layer contract changes are needed.
Reason:
- The feature affects indexing and writeback placement, not render output formatting.
## Decision 7: Put cutoff mode into the deck-rules fingerprint path
- Update `createDeckRulesFingerprint()` to include `cardAnswerCutoffMode`.
- Keep the existing fingerprint path rather than adding a parallel reindex fingerprint.
Reason:
- The repository already uses this fingerprint to force rereads on unchanged files. Reusing it is the smallest compatible integration.
## Decision 8: Keep the settings UI shape consistent with the repository
- Add one dropdown setting labeled with the two cutoff modes.
- Add concise zh/en descriptions that mirror the user-facing semantics from the plan.
- Cover both locales in existing settings tab tests.
Reason:
- The existing settings page relies on dropdown/toggle controls and is already tested that way.
## Decision 9: Test coverage will be expanded at the service level first
- Primary behavior tests go into:
- `CardIndexingService.test.ts`
- `SemanticQaListParser.test.ts`
- `CardMarkerService.test.ts`
- `GroupMarkerService.test.ts`
- `FileIndexerService.test.ts`
- `ManualSyncService.test.ts`
- `PluginSettings.test.ts`
- `PluginSettingTab.test.ts`
- Add QA Group parser/service tests only where the cutoff or writeback behavior actually changes.
Reason:
- These files already exercise the real runtime path and minimize new test scaffolding.
## Confirmed Non-Goals
- No expansion of QA Group answer modeling beyond the existing “first second-level item” contract.
- No new sync pipeline or new persistence store shape.
- No unrelated deck, render, or note-field-mapping refactor.

View file

@ -0,0 +1,129 @@
# Card Answer Cutoff Gap Report
Date: 2026-04-23
## Real Code Review Summary
- Settings currently live in `src/application/config/PluginSettings.ts` and are persisted by merge-with-defaults in `src/presentation/AnkiHeadingSyncPlugin.ts` via `DataJsonPluginConfigRepository`.
- The settings UI is rendered in `src/presentation/settings/PluginSettingTab.ts` and already has test coverage in `src/presentation/settings/PluginSettingTab.test.ts`.
- Normal QA/Cloze boundary parsing is inside `extractMarker()` in `src/domain/manual-sync/services/CardIndexingService.ts`.
- Semantic QA child-card boundary parsing is separate in `extractTrailingMarker()` in `src/domain/manual-sync/services/SemanticQaListParser.ts`.
- QA Group boundary parsing is separate again in `extractTrailingGroupMarker()` in `src/domain/manual-sync/services/QaGroupBlockParser.ts`.
- Card ID writeback is handled by `src/domain/manual-sync/services/CardMarkerService.ts`.
- QA Group GI writeback is handled by `src/domain/manual-sync/services/GroupMarkerService.ts`.
- Re-index invalidation is controlled by `createDeckRulesFingerprint()` in `src/application/services/FileIndexerService.ts` and respected by `FileIndexerService.indexVault()`.
## Current Behavior vs Plan
### 1. Settings and persistence
Current:
- No `cardAnswerCutoffMode` exists.
- Validation has no cutoff-mode enum guard.
- Settings UI has no control for answer cutoff behavior.
- Existing settings tests cover defaults and validation only.
Gap:
- Need new union setting, default, validation, i18n labels, UI control, and tests.
### 2. Normal QA/Cloze cutoff
Current:
- `CardIndexingService.extractMarker()` only checks the last non-empty line of the heading block.
- A valid trailing `<!--ID: ...-->` wins.
- If the last non-empty line is not an ID marker, the whole heading block remains body content.
- Invalid non-trailing marker text is treated as ordinary body text.
Gap:
- No configurable blank-line cutoff.
- No shared helper.
- Marker precedence is only implemented for a single trailing-line layout, not for a marker that appears before remarks.
### 3. Semantic QA cutoff
Current:
- `SemanticQaListParser.extractTrailingMarker()` repeats the same “last non-empty line only” rule for each child region.
- No blank-line cutoff.
- No trailing-content split is returned, so remarks after a marker cannot be modeled explicitly.
Gap:
- Must reuse shared cutoff logic and support marker-before-remarks + double-blank-lines semantics.
### 4. QA Group cutoff
Current:
- `QaGroupBlockParser.extractTrailingGroupMarker()` also only recognizes a GI marker on the last non-empty line of the group block.
- Legacy inner `<!--ID: ...-->` lines are stripped from `rawBlockText`, but not from boundary calculation.
- `contentEndLine` is currently the last non-empty line of the GI-stripped block.
Gap:
- Needs the same shared boundary logic, but with GI markers instead of ID markers.
- Must keep existing item-answer contract unchanged while changing only boundary semantics and GI placement normalization.
### 5. Marker/GI writeback normalization
Current:
- `CardMarkerService.applyWrite()` and `GroupMarkerService.applyWrite()` already insert the marker at `contentEndLine`, remove following blank lines, and then ensure two blank lines if trailing content exists.
- Both services preserve non-blank trailing content after the insertion point.
- Group writeback additionally removes legacy inner ID markers.
Gap:
- The normalization behavior is duplicated rather than shared.
- Current parsing never exposes “trailing after cutoff”, so writeback stability depends on line surgery alone.
- Need explicit normalization contract: trim answer tail blanks, write marker, keep exactly two blank lines, then append trailing content unchanged.
### 6. Fingerprint invalidation
Current:
- `createDeckRulesFingerprint()` hashes heading levels, QA Group marker, semantic marker, default deck, file deck toggles, file deck marker, and folder mode.
- Tests already prove fingerprint changes force re-read and deck migration checks on otherwise unchanged files.
Gap:
- New cutoff mode must participate in the same fingerprint so switching modes forces reread/reindex.
## Integration Points To Change
- `src/application/config/PluginSettings.ts`
- `src/application/config/PluginSettings.test.ts`
- `src/presentation/settings/PluginSettingTab.ts`
- `src/presentation/settings/PluginSettingTab.test.ts`
- `src/presentation/i18n/messages/zh.ts`
- `src/presentation/i18n/messages/en.ts`
- `src/application/services/FileIndexerService.ts`
- `src/application/services/FileIndexerService.test.ts`
- `src/domain/manual-sync/services/CardIndexingService.ts`
- `src/domain/manual-sync/services/SemanticQaListParser.ts`
- `src/domain/manual-sync/services/QaGroupBlockParser.ts`
- `src/domain/manual-sync/services/CardMarkerService.ts`
- `src/domain/manual-sync/services/GroupMarkerService.ts`
- Relevant tests for the above parsers and writeback services
- `src/application/services/ManualSyncService.test.ts` for unchanged-file reread behavior
## Shared Helper Candidates
The repository already has three near-duplicate local boundary functions:
- `extractMarker()` in `CardIndexingService.ts`
- `extractTrailingMarker()` in `SemanticQaListParser.ts`
- `extractTrailingGroupMarker()` in `QaGroupBlockParser.ts`
These are the correct consolidation targets. A shared helper should become the single owner of:
- valid-marker precedence
- invalid-marker behavior
- blank-line cutoff behavior
- trailing-content capture
- `contentEndLine`
- marker line / indent metadata
## Scope Drift Risks
- QA Group parsing currently uses the entire body block for `rawBlockText`; changing item extraction or answer selection would exceed scope.
- Semantic QA raw hash semantics must remain stable except where the cutoff contract intentionally excludes trailing remarks.
- Existing invalid-marker compatibility must remain: invalid markers do not lock identity and do not gain precedence.
- Writeback must stay bottom-up and source-content-based through `MarkdownWriteBackService`; no parallel write pipeline should be introduced.
## Required Deviation From The Plan
- The plan asks for a shared helper returning marker info and trailing content. In this codebase, the helper must be parameterized by marker service type because ID and GI markers use different parse/create payloads.
- The settings UI is currently organized as a single settings page with dropdowns/toggles, not a separate radio-group abstraction. The new mode should therefore be added as a dropdown in the existing page structure to stay consistent with the repository.

View file

@ -47,6 +47,7 @@ describe("PluginSettings", () => {
expect(DEFAULT_SETTINGS.fileDeckInsertLocation).toBe("body");
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off");
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
});
it("rejects invalid QA Group markers and semantic marker collisions", () => {
@ -79,5 +80,12 @@ describe("PluginSettings", () => {
folderDeckMode: "tree" as never,
});
}, "errors.settings.folderDeckModeInvalid");
expectPluginUserError(() => {
validatePluginSettings({
...DEFAULT_SETTINGS,
cardAnswerCutoffMode: "marker-only" as never,
});
}, "errors.settings.cardAnswerCutoffModeInvalid");
});
});

View file

@ -4,10 +4,12 @@ import { PluginUserError } from "@/application/errors/PluginUserError";
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 interface PluginSettings {
qaHeadingLevel: number;
clozeHeadingLevel: number;
cardAnswerCutoffMode: CardAnswerCutoffMode;
qaGroupMarker: string;
qaNoteType: string;
clozeNoteType: string;
@ -31,6 +33,7 @@ export interface PluginSettings {
export const DEFAULT_SETTINGS: PluginSettings = {
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
cardAnswerCutoffMode: "heading-block",
qaGroupMarker: "#anki-list",
qaNoteType: "Basic",
clozeNoteType: "Cloze",
@ -74,6 +77,10 @@ export function validatePluginSettings(settings: PluginSettings): void {
throw new PluginUserError("errors.settings.headingLevelsDifferent");
}
if (settings.cardAnswerCutoffMode !== "heading-block" && settings.cardAnswerCutoffMode !== "double-blank-lines") {
throw new PluginUserError("errors.settings.cardAnswerCutoffModeInvalid");
}
if (!settings.qaNoteType.trim()) {
throw new PluginUserError("errors.settings.qaNoteTypeRequired");
}

View file

@ -126,4 +126,59 @@ describe("FileIndexerService", () => {
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
it("changes the fingerprint and forces re-read when card answer cutoff mode changed", async () => {
const content = ["#### One", "Body"].join("\n");
const oldSettings = createModule3Settings({ cardAnswerCutoffMode: "heading-block" });
const newSettings = createModule3Settings({ cardAnswerCutoffMode: "double-blank-lines" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
noteIds: [10],
},
},
cards: {
"10": {
noteId: 10,
filePath: "notes/one.md",
heading: "One",
backlinkHeadingText: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 16,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "Obsidian",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
const result = await service.indexVault(newSettings, state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
});

View file

@ -55,6 +55,7 @@ export class FileIndexerService {
const indexedFile = this.cardIndexingService.index(sourceFile, {
qaHeadingLevel: settings.qaHeadingLevel,
clozeHeadingLevel: settings.clozeHeadingLevel,
cardAnswerCutoffMode: settings.cardAnswerCutoffMode,
qaGroupMarker: settings.qaGroupMarker,
semanticQaMarker: settings.semanticQaMarker,
fileStamp,
@ -137,6 +138,7 @@ export class FileIndexerService {
const indexedFile = this.cardIndexingService.index(sourceFile, {
qaHeadingLevel: settings.qaHeadingLevel,
clozeHeadingLevel: settings.clozeHeadingLevel,
cardAnswerCutoffMode: settings.cardAnswerCutoffMode,
qaGroupMarker: settings.qaGroupMarker,
semanticQaMarker: settings.semanticQaMarker,
fileStamp,
@ -269,13 +271,14 @@ export function createFileStamp(mtime: number, size: number): string {
return `${mtime}:${size}`;
}
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v1";
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v2";
export function createDeckRulesFingerprint(settings: PluginSettings): string {
return hashString(JSON.stringify({
version: DECK_RULES_FINGERPRINT_VERSION,
qaHeadingLevel: settings.qaHeadingLevel,
clozeHeadingLevel: settings.clozeHeadingLevel,
cardAnswerCutoffMode: settings.cardAnswerCutoffMode,
qaGroupMarker: settings.qaGroupMarker,
semanticQaMarker: settings.semanticQaMarker,
defaultDeck: settings.defaultDeck,

View file

@ -0,0 +1,205 @@
import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings";
export type MarkerBoundaryState = "missing" | "present-valid" | "present-invalid";
export interface BoundaryMarkerAdapter<TMarker> {
isCandidate(line: string): boolean;
parse(line: string, lineIndex: number): TMarker | null;
}
export interface ResolveAnswerBoundaryInput<TMarker> {
lines: string[];
startLine: number;
fallbackLine: number;
cutoffMode: CardAnswerCutoffMode;
markerAdapter: BoundaryMarkerAdapter<TMarker>;
}
export interface AnswerBoundary<TMarker> {
contentLines: string[];
trailingLines: string[];
contentEndLine: number;
markerLine?: number;
markerIndent?: string;
marker?: TMarker;
markerState: MarkerBoundaryState;
}
interface MarkerCandidate<TMarker> {
index: number;
indent: string;
marker?: TMarker;
valid: boolean;
}
interface BlankRun {
start: number;
end: number;
}
export function resolveAnswerBoundary<TMarker>(input: ResolveAnswerBoundaryInput<TMarker>): AnswerBoundary<TMarker> {
const candidates = collectMarkerCandidates(input.lines, input.startLine, input.markerAdapter);
const validMarker = candidates.find((candidate) => candidate.valid);
if (validMarker?.marker) {
const contentLines = input.lines.slice(0, validMarker.index);
return {
contentLines,
trailingLines: stripLeadingBlankLines(input.lines.slice(validMarker.index + 1)),
contentEndLine: findContentEndLine(contentLines, input.startLine, input.fallbackLine),
markerLine: input.startLine + validMarker.index,
markerIndent: validMarker.indent,
marker: validMarker.marker,
markerState: "present-valid",
};
}
const blankRun = input.cutoffMode === "double-blank-lines" ? findFirstDoubleBlankRun(input.lines) : undefined;
if (blankRun) {
const invalidTrailingMarker = candidates.find((candidate) => !candidate.valid && candidate.index >= blankRun.end);
const contentLines = input.lines.slice(0, blankRun.start);
return {
contentLines,
trailingLines: input.lines.slice(blankRun.end),
contentEndLine: findContentEndLine(contentLines, input.startLine, input.fallbackLine),
markerLine: invalidTrailingMarker ? input.startLine + invalidTrailingMarker.index : undefined,
markerIndent: invalidTrailingMarker?.indent,
markerState: invalidTrailingMarker ? "present-invalid" : "missing",
};
}
const lastNonEmptyIndex = findLastNonEmptyLineIndex(input.lines);
const trailingInvalidMarker = lastNonEmptyIndex === undefined
? undefined
: candidates.find((candidate) => !candidate.valid && candidate.index === lastNonEmptyIndex);
if (trailingInvalidMarker) {
const contentLines = input.lines.filter((_line, index) => index !== trailingInvalidMarker.index);
return {
contentLines,
trailingLines: [],
contentEndLine: findContentEndLine(contentLines, input.startLine, input.fallbackLine),
markerLine: input.startLine + trailingInvalidMarker.index,
markerIndent: trailingInvalidMarker.indent,
markerState: "present-invalid",
};
}
return {
contentLines: input.lines,
trailingLines: [],
contentEndLine: findContentEndLine(input.lines, input.startLine, input.fallbackLine),
markerState: "missing",
};
}
function collectMarkerCandidates<TMarker>(
lines: string[],
startLine: number,
markerAdapter: BoundaryMarkerAdapter<TMarker>,
): MarkerCandidate<TMarker>[] {
const candidates: MarkerCandidate<TMarker>[] = [];
let fenceMarker: string | null = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (isFenceLine(trimmed)) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
continue;
}
if (fenceMarker || !markerAdapter.isCandidate(line)) {
continue;
}
const marker = markerAdapter.parse(line, startLine + index);
candidates.push({
index,
indent: leadingWhitespace(line),
marker: marker ?? undefined,
valid: Boolean(marker),
});
}
return candidates;
}
function findFirstDoubleBlankRun(lines: string[]): BlankRun | undefined {
let fenceMarker: string | null = null;
let blankStart: number | undefined;
let blankCount = 0;
for (let index = 0; index < lines.length; index += 1) {
const trimmed = lines[index]?.trim() ?? "";
if (isFenceLine(trimmed)) {
if (blankCount >= 2 && blankStart !== undefined) {
return { start: blankStart, end: index };
}
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
blankStart = undefined;
blankCount = 0;
continue;
}
if (fenceMarker) {
continue;
}
if (!trimmed) {
blankStart ??= index;
blankCount += 1;
continue;
}
if (blankCount >= 2 && blankStart !== undefined) {
return { start: blankStart, end: index };
}
blankStart = undefined;
blankCount = 0;
}
if (blankCount >= 2 && blankStart !== undefined) {
return { start: blankStart, end: lines.length };
}
return undefined;
}
function findContentEndLine(lines: string[], startLine: number, fallbackLine: number): number {
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (lines[index].trim()) {
return startLine + index;
}
}
return fallbackLine;
}
function findLastNonEmptyLineIndex(lines: string[]): number | undefined {
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (lines[index].trim()) {
return index;
}
}
return undefined;
}
function stripLeadingBlankLines(lines: string[]): string[] {
let startIndex = 0;
while (startIndex < lines.length && !lines[startIndex].trim()) {
startIndex += 1;
}
return lines.slice(startIndex);
}
function leadingWhitespace(line: string): string {
const match = line.match(/^[ \t]*/);
return match?.[0] ?? "";
}
function isFenceLine(trimmedLine: string): boolean {
return trimmedLine.startsWith("```") || trimmedLine.startsWith("~~~");
}

View file

@ -154,13 +154,13 @@ describe("CardIndexingService", () => {
});
});
it("does not treat a non-trailing ID marker as the marker slot", () => {
it("treats a valid ID marker before trailing remarks as the body cutoff", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "<!--ID: 42-->", "Answer"].join("\n"),
content: ["#### Prompt", "Answer", "<!--ID: 42-->", "", "", "Remarks"].join("\n"),
},
{
qaHeadingLevel: 4,
@ -171,10 +171,93 @@ describe("CardIndexingService", () => {
},
);
expect(indexedFile.cards[0]).toMatchObject({
noteId: 42,
noteIdSource: "marker",
idMarkerState: "present-valid",
bodyMarkdown: "Answer",
contentEndLine: 2,
markerLine: 3,
});
});
it("cuts normal card content at the first 2+ blank lines when no valid marker exists", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "Answer", "", "", "Remarks"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
cardAnswerCutoffMode: "double-blank-lines",
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards[0]).toMatchObject({
noteId: undefined,
idMarkerState: "missing",
bodyMarkdown: "<!--ID: 42-->\nAnswer",
bodyMarkdown: "Answer",
contentEndLine: 2,
markerLine: undefined,
});
});
it("keeps the old marker-after-remarks layout compatible and lets a valid marker beat blank-line cutoff", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "Answer", "", "", "Remarks", "<!--ID: 42-->"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
cardAnswerCutoffMode: "double-blank-lines",
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards[0]).toMatchObject({
noteId: 42,
noteIdSource: "marker",
idMarkerState: "present-valid",
bodyMarkdown: "Answer\n\n\nRemarks",
contentEndLine: 5,
markerLine: 6,
});
});
it("applies the same double-blank-line cutoff to cloze cards", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["##### Cloze", "{{c1::Answer}}", "", "", "Remarks"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
cardAnswerCutoffMode: "double-blank-lines",
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards[0]).toMatchObject({
cardType: "cloze",
bodyMarkdown: "{{c1::Answer}}",
contentEndLine: 2,
});
});

View file

@ -1,3 +1,4 @@
import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings";
import type { SourceFile } from "@/domain/card/entities/SourceFile";
import { createIndexedCardSyncKey, type IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { buildGroupSrc, createIndexedGroupSyncKey, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
@ -9,6 +10,7 @@ import { CardMarkerService } from "./CardMarkerService";
import { DeckExtractionService } from "./DeckExtractionService";
import { QaGroupBlockParser } from "./QaGroupBlockParser";
import { SemanticQaListParser } from "./SemanticQaListParser";
import { resolveAnswerBoundary } from "./AnswerBoundaryParser";
interface HeadingMatch {
level: number;
@ -16,17 +18,10 @@ interface HeadingMatch {
lineIndex: number;
}
interface MarkerExtractionResult {
bodyLines: string[];
markerNoteId?: number;
contentEndLine: number;
markerLine?: number;
idMarkerState: IndexedCard["idMarkerState"];
}
export interface CardIndexingContext {
qaHeadingLevel: number;
clozeHeadingLevel: number;
cardAnswerCutoffMode?: CardAnswerCutoffMode;
qaGroupMarker?: string;
semanticQaMarker?: string;
fileStamp: string;
@ -72,6 +67,7 @@ export class CardIndexingService {
const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
const cutoffMode = context.cardAnswerCutoffMode ?? "heading-block";
const qaGroupMarker = context.qaGroupMarker ?? "#anki-list";
if (cardType === "basic" && this.qaGroupBlockParser.isQaGroupHeading(heading.text, qaGroupMarker)) {
const parsedGroupBlock = this.qaGroupBlockParser.parse({
@ -79,6 +75,7 @@ export class CardIndexingService {
marker: qaGroupMarker,
bodyLines,
bodyStartLine: heading.lineIndex + 2,
cardAnswerCutoffMode: cutoffMode,
});
const src = buildGroupSrc(sourceFile.path, heading.text);
const resolvedGroupIdentity = this.resolveGroupIdentity(
@ -139,6 +136,7 @@ export class CardIndexingService {
marker: semanticQaMarker,
bodyLines,
bodyStartLine: heading.lineIndex + 2,
cardAnswerCutoffMode: cutoffMode,
})) {
const resolvedIdentity = this.resolveIdentity(
sourceFile.path,
@ -188,8 +186,14 @@ export class CardIndexingService {
continue;
}
const marker = extractMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1, this.markerService);
const trimmedBodyLines = trimBlankEdges(marker.bodyLines);
const boundary = resolveAnswerBoundary({
lines: bodyLines,
startLine: heading.lineIndex + 2,
fallbackLine: heading.lineIndex + 1,
cutoffMode,
markerAdapter: this.markerService,
});
const trimmedBodyLines = trimBlankEdges(boundary.contentLines);
const bodyMarkdown = trimmedBodyLines.join("\n");
const rawBlockText = [lines[heading.lineIndex], ...trimmedBodyLines].join("\n").trimEnd();
const rawBlockHash = hashString(rawBlockText);
@ -197,7 +201,7 @@ export class CardIndexingService {
sourceFile.path,
heading.lineIndex + 1,
rawBlockHash,
marker.markerNoteId,
boundary.marker?.noteId,
knownCardsByBlockKey,
pendingByBlockKey,
usedNoteIds,
@ -210,7 +214,7 @@ export class CardIndexingService {
cards.push({
noteId: resolvedIdentity.noteId,
syncKey: createIndexedCardSyncKey(sourceFile.path, heading.lineIndex + 1, rawBlockHash),
idMarkerState: marker.idMarkerState,
idMarkerState: boundary.markerState,
noteIdSource: resolvedIdentity.noteIdSource,
filePath: sourceFile.path,
cardType,
@ -223,8 +227,9 @@ export class CardIndexingService {
blockStartLine: heading.lineIndex + 1,
bodyStartLine: heading.lineIndex + 2,
blockEndLine: blockEndLineIndex,
contentEndLine: marker.contentEndLine,
markerLine: marker.markerLine,
contentEndLine: boundary.contentEndLine,
markerLine: boundary.markerLine,
markerIndent: boundary.markerIndent,
rawBlockText,
rawBlockHash,
deckHint: extractedDeck.explicitDeckHint,
@ -474,56 +479,6 @@ function trimBlankEdges(lines: string[]): string[] {
return lines.slice(startIndex, endIndex);
}
function extractMarker(
bodyLines: string[],
bodyStartLine: number,
headingLine: number,
markerService: CardMarkerService,
): MarkerExtractionResult {
let lastNonEmptyIndex = bodyLines.length - 1;
while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) {
lastNonEmptyIndex -= 1;
}
if (lastNonEmptyIndex < 0) {
return {
bodyLines,
contentEndLine: headingLine,
idMarkerState: "missing",
};
}
const lastLine = bodyLines[lastNonEmptyIndex];
if (!markerService.isCandidate(lastLine)) {
return {
bodyLines,
contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine),
idMarkerState: "missing",
};
}
const parsedMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
const nextBodyLines = bodyLines.filter((_line, index) => index !== lastNonEmptyIndex);
return {
bodyLines: nextBodyLines,
markerNoteId: parsedMarker?.noteId,
contentEndLine: findContentEndLine(nextBodyLines, bodyStartLine, headingLine),
markerLine: bodyStartLine + lastNonEmptyIndex,
idMarkerState: parsedMarker ? "present-valid" : "present-invalid",
};
}
function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number {
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
if (bodyLines[index].trim()) {
return bodyStartLine + index;
}
}
return headingLine;
}
function computeLineStartOffsets(content: string): number[] {
const offsets = [0];

View file

@ -47,4 +47,64 @@ describe("CardMarkerService", () => {
"<!--ID: 22-->",
].join("\n"));
});
it("writes a new marker after the answer and keeps exactly two blank lines before trailing remarks", () => {
const service = new CardMarkerService();
const sourceContent = [
"#### Prompt",
"Answer",
"",
"",
"Remarks",
].join("\n");
const nextContent = service.applyBatch(sourceContent, [{
filePath: "notes/example.md",
noteId: 42,
blockStartLine: 1,
contentEndLine: 2,
blockEndLine: 5,
sourceContent,
}]);
expect(nextContent).toBe([
"#### Prompt",
"Answer",
"<!--ID: 42-->",
"",
"",
"Remarks",
].join("\n"));
});
it("preserves the normalized marker-before-remarks layout when replacing an existing marker", () => {
const service = new CardMarkerService();
const sourceContent = [
"#### Prompt",
"Answer",
"<!--ID: 11-->",
"",
"",
"Remarks",
].join("\n");
const nextContent = service.applyBatch(sourceContent, [{
filePath: "notes/example.md",
noteId: 42,
blockStartLine: 1,
contentEndLine: 2,
blockEndLine: 6,
markerLine: 3,
sourceContent,
}]);
expect(nextContent).toBe([
"#### Prompt",
"Answer",
"<!--ID: 42-->",
"",
"",
"Remarks",
].join("\n"));
});
});

View file

@ -1,6 +1,8 @@
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { IdMarker } from "@/domain/manual-sync/entities/IdMarker";
import { applyNormalizedMarkerWrite } from "./MarkerWritebackNormalization";
export interface MarkerWriteRequest {
filePath: string;
noteId: number;
@ -112,31 +114,12 @@ export class CardMarkerService {
throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`);
}
const removalIndexes = new Set<number>();
if (write.markerLine !== undefined) {
removalIndexes.add(write.markerLine - 1);
}
for (let lineIndex = write.contentEndLine; lineIndex < write.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
removalIndexes.add(lineIndex);
}
}
const sortedRemovals = [...removalIndexes].sort((left, right) => right - left);
const insertionIndex = write.contentEndLine - [...removalIndexes].filter((lineIndex) => lineIndex < write.contentEndLine).length;
for (const lineIndex of sortedRemovals) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${this.create(write.noteId).raw}`);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
applyNormalizedMarkerWrite(lines, {
contentEndLine: write.contentEndLine,
blockEndLine: write.blockEndLine,
markerLine: write.markerLine,
markerIndent: write.markerIndent,
renderedMarker: this.create(write.noteId).raw,
});
}
}

View file

@ -45,4 +45,38 @@ describe("GroupMarkerService", () => {
"#### Next heading",
].join("\n"));
});
it("writes GI after the answer region and keeps exactly two blank lines before trailing remarks", () => {
const service = new GroupMarkerService();
const sourceContent = [
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"",
"",
"Remarks",
].join("\n");
const nextContent = service.applyBatch(sourceContent, [{
syncKey: "notes/example.md\u0000group\u00001\u0000hash",
filePath: "notes/example.md",
blockStartLine: 1,
contentEndLine: 3,
blockEndLine: 6,
noteId: 42,
itemToSlot: { item_a: 1 },
freeSlots: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
sourceContent,
}]);
expect(nextContent).toBe([
"#### Concepts #anki-list",
"- Alpha",
" - First answer",
"<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->",
"",
"",
"Remarks",
].join("\n"));
});
});

View file

@ -1,5 +1,7 @@
import type { GroupMarker } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { applyNormalizedMarkerWrite } from "./MarkerWritebackNormalization";
export interface ParsedGroupMarker extends GroupMarker {
raw: string;
lineIndex: number;
@ -145,32 +147,14 @@ export class GroupMarkerService {
}
applyWrite(lines: string[], write: GroupMarkerWriteRequest): void {
const uniqueRemovals = new Set<number>(collectLegacyIdMarkerLineIndexes(lines, write.blockStartLine, write.blockEndLine));
if (write.markerLine !== undefined) {
uniqueRemovals.add(write.markerLine - 1);
}
for (let lineIndex = write.contentEndLine; lineIndex < write.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
uniqueRemovals.add(lineIndex);
}
}
const removalIndexes = [...uniqueRemovals].sort((left, right) => right - left);
const insertionIndex = write.contentEndLine - [...uniqueRemovals].filter((lineIndex) => lineIndex < write.contentEndLine).length;
for (const lineIndex of removalIndexes) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, `${write.markerIndent ?? ""}${serializeGroupMarker(write.noteId, write.itemToSlot, write.freeSlots)}`);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
applyNormalizedMarkerWrite(lines, {
contentEndLine: write.contentEndLine,
blockEndLine: write.blockEndLine,
markerLine: write.markerLine,
markerIndent: write.markerIndent,
renderedMarker: serializeGroupMarker(write.noteId, write.itemToSlot, write.freeSlots),
additionalRemovalLineIndexes: collectLegacyIdMarkerLineIndexes(lines, write.blockStartLine, write.blockEndLine),
});
}
}

View file

@ -0,0 +1,37 @@
export interface NormalizedMarkerWriteInput {
contentEndLine: number;
blockEndLine: number;
markerLine?: number;
markerIndent?: string;
renderedMarker: string;
additionalRemovalLineIndexes?: number[];
}
export function applyNormalizedMarkerWrite(lines: string[], input: NormalizedMarkerWriteInput): void {
const removalIndexes = new Set<number>(input.additionalRemovalLineIndexes ?? []);
if (input.markerLine !== undefined) {
removalIndexes.add(input.markerLine - 1);
}
for (let lineIndex = input.contentEndLine; lineIndex < input.blockEndLine; lineIndex += 1) {
if (!(lines[lineIndex] ?? "").trim()) {
removalIndexes.add(lineIndex);
}
}
const sortedRemovals = [...removalIndexes].sort((left, right) => right - left);
const insertionIndex = input.contentEndLine - [...removalIndexes].filter((lineIndex) => lineIndex < input.contentEndLine).length;
for (const lineIndex of sortedRemovals) {
lines.splice(lineIndex, 1);
}
lines.splice(insertionIndex, 0, `${input.markerIndent ?? ""}${input.renderedMarker}`);
while (insertionIndex + 1 < lines.length && !(lines[insertionIndex + 1] ?? "").trim()) {
lines.splice(insertionIndex + 1, 1);
}
if (insertionIndex + 1 < lines.length) {
lines.splice(insertionIndex + 1, 0, "", "");
}
}

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { QaGroupBlockParser } from "./QaGroupBlockParser";
const VALID_GI_MARKER = "<!--GI:n=42;i=item_a:1;f=2,3,4,5,6,7,8,9,10,11,12-->";
describe("QaGroupBlockParser", () => {
it("cuts a new group block at the first 2+ blank lines when no GI marker exists", () => {
const parser = new QaGroupBlockParser();
const block = parser.parse({
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
cardAnswerCutoffMode: "double-blank-lines",
bodyStartLine: 2,
bodyLines: [
"- Alpha",
" - First answer",
"",
"",
"Remarks",
],
});
expect(block).toMatchObject({
markerState: "missing",
contentEndLine: 3,
markerLine: undefined,
items: [
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
],
});
expect(block.rawBlockText).not.toContain("Remarks");
});
it("treats a GI marker before trailing remarks as the group cutoff", () => {
const parser = new QaGroupBlockParser();
const block = parser.parse({
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
bodyStartLine: 2,
bodyLines: [
"- Alpha",
" - First answer",
VALID_GI_MARKER,
"",
"",
"Remarks",
],
});
expect(block).toMatchObject({
markerState: "present-valid",
contentEndLine: 3,
markerLine: 4,
groupMarker: {
noteId: 42,
},
items: [
{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 },
],
});
expect(block.rawBlockText).not.toContain("Remarks");
});
it("keeps the old GI-after-remarks layout compatible and lets a valid GI beat blank-line cutoff", () => {
const parser = new QaGroupBlockParser();
const input = {
parentHeadingText: "Concepts #anki-list",
marker: "#anki-list",
bodyStartLine: 2,
cardAnswerCutoffMode: "double-blank-lines" as const,
bodyLines: [
"- Alpha",
" - First answer",
"",
"",
"Remarks",
VALID_GI_MARKER,
],
};
const firstParse = parser.parse(input);
const secondParse = parser.parse(input);
expect(firstParse).toMatchObject({
markerState: "present-valid",
contentEndLine: 6,
markerLine: 7,
groupMarker: {
noteId: 42,
},
});
expect(firstParse.rawBlockText).toContain("Remarks");
expect(secondParse.rawBlockHash).toBe(firstParse.rawBlockHash);
});
});

View file

@ -1,7 +1,9 @@
import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings";
import { hashString } from "@/domain/shared/hash";
import type { GroupItem, GroupMarkerState } from "@/domain/manual-sync/entities/IndexedGroupCardBlock";
import { GroupMarkerService, type ParsedGroupMarker } from "./GroupMarkerService";
import { resolveAnswerBoundary } from "./AnswerBoundaryParser";
const LIST_ITEM_REGEXP = /^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/;
@ -10,6 +12,7 @@ export interface QaGroupBlockParserInput {
marker: string;
bodyLines: string[];
bodyStartLine: number;
cardAnswerCutoffMode?: CardAnswerCutoffMode;
}
export interface ParsedQaGroupBlock {
@ -43,17 +46,23 @@ export class QaGroupBlockParser {
parse(input: QaGroupBlockParserInput): ParsedQaGroupBlock {
const stem = stripQaGroupMarker(input.parentHeadingText, input.marker);
const trailingMarker = extractTrailingGroupMarker(input.bodyLines, input.bodyStartLine, this.groupMarkerService);
const rootItems = collectRootListItems(trailingMarker.bodyLines);
const boundary = resolveAnswerBoundary({
lines: input.bodyLines,
startLine: input.bodyStartLine,
fallbackLine: input.bodyStartLine - 1,
cutoffMode: input.cardAnswerCutoffMode ?? "heading-block",
markerAdapter: this.groupMarkerService,
});
const rootItems = collectRootListItems(boundary.contentLines);
const items: GroupItem[] = [];
for (let index = 0; index < rootItems.length; index += 1) {
const item = rootItems[index];
const nextItem = rootItems[index + 1];
const childRegion = collectChildRegion(
trailingMarker.bodyLines,
boundary.contentLines,
item.index + 1,
nextItem?.index ?? trailingMarker.bodyLines.length,
nextItem?.index ?? boundary.contentLines.length,
item.indent.length,
);
@ -73,7 +82,7 @@ export class QaGroupBlockParser {
});
}
const normalizedBodyLines = trimBlankEdges(trailingMarker.bodyLines.filter((line) => !isLegacyCardMarkerLine(line)));
const normalizedBodyLines = trimBlankEdges(boundary.contentLines.filter((line) => !isLegacyCardMarkerLine(line)));
const rawBlockText = [
`qa-group:${stem}`,
...items.map((item) => `${item.title}\n${item.answer}`),
@ -83,11 +92,11 @@ export class QaGroupBlockParser {
return {
stem,
items,
markerState: trailingMarker.markerState,
groupMarker: trailingMarker.groupMarker,
contentEndLine: findContentEndLine(trailingMarker.bodyLines, input.bodyStartLine, input.bodyStartLine - 1),
markerLine: trailingMarker.markerLine,
markerIndent: trailingMarker.markerIndent,
markerState: boundary.markerState,
groupMarker: boundary.marker,
contentEndLine: boundary.contentEndLine,
markerLine: boundary.markerLine,
markerIndent: boundary.markerIndent,
rawBlockText,
rawBlockHash: hashString(rawBlockText),
};
@ -103,49 +112,6 @@ function stripQaGroupMarker(headingText: string, marker: string): string {
return trimmedHeading.slice(0, trimmedHeading.length - marker.length).trimEnd();
}
function extractTrailingGroupMarker(
rawLines: string[],
bodyStartLine: number,
groupMarkerService: GroupMarkerService,
): {
bodyLines: string[];
markerState: GroupMarkerState;
groupMarker?: ParsedGroupMarker;
markerLine?: number;
markerIndent?: string;
} {
let lastNonEmptyIndex = rawLines.length - 1;
while (lastNonEmptyIndex >= 0 && !rawLines[lastNonEmptyIndex].trim()) {
lastNonEmptyIndex -= 1;
}
if (lastNonEmptyIndex < 0) {
return {
bodyLines: rawLines,
markerState: "missing",
};
}
const lastLine = rawLines[lastNonEmptyIndex];
if (!groupMarkerService.isCandidate(lastLine)) {
return {
bodyLines: rawLines,
markerState: "missing",
};
}
const bodyLines = rawLines.filter((_line, index) => index !== lastNonEmptyIndex);
const parsedMarker = groupMarkerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
return {
bodyLines,
markerState: parsedMarker ? "present-valid" : "present-invalid",
groupMarker: parsedMarker ?? undefined,
markerLine: bodyStartLine + lastNonEmptyIndex,
markerIndent: leadingWhitespace(lastLine),
};
}
function collectRootListItems(lines: string[]): ListItemMatch[] {
const candidates: ListItemMatch[] = [];
let fenceMarker: string | null = null;
@ -268,16 +234,6 @@ function findFirstSecondLevelItem(lines: string[], parentIndentLength: number):
return candidates.find((candidate) => candidate.indent.length === directIndentLength) ?? null;
}
function findContentEndLine(bodyLines: string[], bodyStartLine: number, fallbackLine: number): number {
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
if (bodyLines[index].trim()) {
return bodyStartLine + index;
}
}
return fallbackLine;
}
function trimBlankEdges(lines: string[]): string[] {
let startIndex = 0;
let endIndex = lines.length;

View file

@ -57,4 +57,82 @@ describe("SemanticQaListParser", () => {
expect(parser.isSemanticQaHeading("Concepts #anki-list-qa extra", "#anki-list-qa")).toBe(false);
expect(parser.isSemanticQaHeading("Concepts", "#anki-list-qa")).toBe(false);
});
it("cuts a child answer at the first 2+ blank lines when no valid marker exists", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
cardAnswerCutoffMode: "double-blank-lines",
bodyLines: [
"- Alpha",
" First line",
" ",
" ",
" Remark",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line",
contentEndLine: 3,
markerLine: undefined,
idMarkerState: "missing",
});
});
it("keeps the old marker-after-remarks semantic layout compatible", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
bodyLines: [
"- Alpha",
" First line",
" Remark",
" <!--ID: 12-->",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line\nRemark",
contentEndLine: 4,
markerLine: 5,
markerNoteId: 12,
idMarkerState: "present-valid",
});
});
it("treats a marker before trailing remarks as the semantic child cutoff", () => {
const parser = new SemanticQaListParser();
const cards = parser.parse({
parentHeadingText: "Concepts #anki-list-qa",
marker: "#anki-list-qa",
bodyLines: [
"- Alpha",
" First line",
" <!--ID: 12-->",
" ",
" ",
" Remark",
],
bodyStartLine: 2,
});
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
bodyMarkdown: "First line",
contentEndLine: 3,
markerLine: 4,
markerNoteId: 12,
idMarkerState: "present-valid",
});
});
});

View file

@ -1,7 +1,9 @@
import { hashString } from "@/domain/shared/hash";
import type { IdMarkerState } from "@/domain/manual-sync/entities/IdMarker";
import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings";
import { hashString } from "@/domain/shared/hash";
import { CardMarkerService } from "./CardMarkerService";
import { resolveAnswerBoundary } from "./AnswerBoundaryParser";
const LIST_ITEM_REGEXP = /^([ \t]*)(?:[-+*]|\d+[.)])\s+(.*)$/;
@ -10,6 +12,7 @@ export interface SemanticQaListParserInput {
marker: string;
bodyLines: string[];
bodyStartLine: number;
cardAnswerCutoffMode?: CardAnswerCutoffMode;
}
export interface SemanticQaListCard {
@ -40,14 +43,6 @@ interface ChildRegion {
endIndex: number;
}
interface TrailingMarkerExtractionResult {
bodyLines: string[];
markerNoteId?: number;
markerLine?: number;
markerIndent?: string;
idMarkerState: IdMarkerState;
}
export class SemanticQaListParser {
constructor(private readonly markerService = new CardMarkerService()) {}
@ -80,8 +75,14 @@ export class SemanticQaListParser {
}
const absoluteBodyStartLine = input.bodyStartLine + childRegion.startIndex;
const trailingMarker = extractTrailingMarker(childRegion.rawLines, absoluteBodyStartLine, this.markerService);
const trimmedBodyLines = trimBlankEdges(trailingMarker.bodyLines);
const boundary = resolveAnswerBoundary({
lines: childRegion.rawLines,
startLine: absoluteBodyStartLine,
fallbackLine: input.bodyStartLine + item.index,
cutoffMode: input.cardAnswerCutoffMode ?? "heading-block",
markerAdapter: this.markerService,
});
const trimmedBodyLines = trimBlankEdges(boundary.contentLines);
if (trimmedBodyLines.length === 0) {
continue;
}
@ -106,11 +107,11 @@ export class SemanticQaListParser {
blockStartLine: input.bodyStartLine + item.index,
bodyStartLine: absoluteBodyStartLine,
blockEndLine: input.bodyStartLine + childRegion.endIndex,
contentEndLine: findContentEndLine(trailingMarker.bodyLines, absoluteBodyStartLine, input.bodyStartLine + item.index),
markerLine: trailingMarker.markerLine,
markerIndent: trailingMarker.markerIndent ?? deriveMarkerIndent(trimmedBodyLines, item.indent),
markerNoteId: trailingMarker.markerNoteId,
idMarkerState: trailingMarker.idMarkerState,
contentEndLine: boundary.contentEndLine,
markerLine: boundary.markerLine,
markerIndent: boundary.markerIndent ?? deriveMarkerIndent(trimmedBodyLines, item.indent),
markerNoteId: boundary.marker?.noteId,
idMarkerState: boundary.markerState,
rawBlockText,
rawBlockHash: hashString(rawBlockText),
});
@ -213,43 +214,6 @@ function collectChildRegion(
};
}
function extractTrailingMarker(
rawLines: string[],
bodyStartLine: number,
markerService: CardMarkerService,
): TrailingMarkerExtractionResult {
let lastNonEmptyIndex = rawLines.length - 1;
while (lastNonEmptyIndex >= 0 && !rawLines[lastNonEmptyIndex].trim()) {
lastNonEmptyIndex -= 1;
}
if (lastNonEmptyIndex < 0) {
return {
bodyLines: rawLines,
idMarkerState: "missing",
};
}
const lastLine = rawLines[lastNonEmptyIndex];
if (!markerService.isCandidate(lastLine)) {
return {
bodyLines: rawLines,
idMarkerState: "missing",
};
}
const parsedMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
const nextBodyLines = rawLines.filter((_line, index) => index !== lastNonEmptyIndex);
return {
bodyLines: nextBodyLines,
markerNoteId: parsedMarker?.noteId,
markerLine: bodyStartLine + lastNonEmptyIndex,
markerIndent: leadingWhitespace(lastLine),
idMarkerState: parsedMarker ? "present-valid" : "present-invalid",
};
}
function trimBlankEdges(lines: string[]): string[] {
let startIndex = 0;
let endIndex = lines.length;
@ -283,16 +247,6 @@ function deriveMarkerIndent(bodyLines: string[], itemIndent: string): string {
return `${itemIndent} `;
}
function findContentEndLine(bodyLines: string[], bodyStartLine: number, fallbackLine: number): number {
for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
if (bodyLines[index].trim()) {
return bodyStartLine + index;
}
}
return fallbackLine;
}
function leadingWhitespace(line: string): string {
const match = line.match(/^[ \t]*/);
return match?.[0] ?? "";

View file

@ -42,6 +42,7 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.fileDeckInsertLocation).toBe("body");
expect(settings.folderDeckMode).toBe("off");
expect(settings.qaGroupMarker).toBe("#anki-list");
expect(settings.cardAnswerCutoffMode).toBe("heading-block");
expect(settings.semanticQaMarker).toBe("#anki-list-qa");
expect(settings.semanticQaNoteType).toBe("Semantic QA");
});
@ -84,6 +85,7 @@ describe("DataJsonPluginConfigRepository", () => {
await repository.save({
...DEFAULT_SETTINGS,
cardAnswerCutoffMode: "double-blank-lines",
semanticQaMarker: "#semantic-qa",
semanticQaNoteType: "Semantic QA",
noteFieldMappings: {
@ -100,6 +102,7 @@ describe("DataJsonPluginConfigRepository", () => {
const reloaded = await repository.load();
expect(reloaded.cardAnswerCutoffMode).toBe("double-blank-lines");
expect(reloaded.semanticQaMarker).toBe("#semantic-qa");
expect(reloaded.semanticQaNoteType).toBe("Semantic QA");
expect(reloaded.noteFieldMappings).toEqual({

View file

@ -25,6 +25,14 @@ export const en = {
name: "Cloze heading level",
desc: "Default is H5",
},
cardAnswerCutoffMode: {
name: "Card answer cutoff mode",
desc: "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.",
options: {
headingBlock: "heading-block: whole heading block",
doubleBlankLines: "double-blank-lines: stop before the first 2+ blank lines",
},
},
qaGroup: {
title: "QA Group 12",
marker: {
@ -238,6 +246,7 @@ export const en = {
settings: {
headingLevelsRange: "Heading levels must be integers between 1 and 6.",
headingLevelsDifferent: "QA and Cloze heading levels must be different.",
cardAnswerCutoffModeInvalid: "Card answer cutoff mode must be heading-block or double-blank-lines.",
qaNoteTypeRequired: "QA note type is required.",
qaGroupMarkerRequired: "QA Group marker is required.",
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.",

View file

@ -23,6 +23,14 @@ export const zh = {
name: "Cloze 标题层级",
desc: "默认是 H5",
},
cardAnswerCutoffMode: {
name: "卡片正文截止模式",
desc: "有效同步标记始终优先。没有有效标记时,选择继续到整个标题块末尾,或在首个 2+ 连续空行前截止。",
options: {
headingBlock: "heading-block整个标题块",
doubleBlankLines: "double-blank-lines在首个 2+ 连续空行前截止",
},
},
qaGroup: {
title: "QA Group 12",
marker: {
@ -236,6 +244,7 @@ export const zh = {
settings: {
headingLevelsRange: "标题层级必须是 1 到 6 之间的整数。",
headingLevelsDifferent: "QA 和 Cloze 的标题层级必须不同。",
cardAnswerCutoffModeInvalid: "卡片正文截止模式只能是 heading-block 或 double-blank-lines。",
qaNoteTypeRequired: "QA 笔记类型不能为空。",
qaGroupMarkerRequired: "QA Group 标记不能为空。",
qaGroupMarkerInvalid: "QA Group 标记必须是类似 #anki-list 的 hashtag 样式 token。",

View file

@ -483,6 +483,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
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, "Run scope").desc).toBe("Only process Markdown files in the checked folders below");
await flushAsync();
@ -507,6 +508,7 @@ describe("AnkiHeadingSyncSettingTab", () => {
tab.display();
expect(findSetting(container, "QA 标题层级").desc).toBe("默认是 H4");
expect(findSetting(container, "卡片正文截止模式").desc).toBe("有效同步标记始终优先。没有有效标记时,选择继续到整个标题块末尾,或在首个 2+ 连续空行前截止。");
expect(findSetting(container, "运行范围").desc).toBe("仅处理下方勾选文件夹中的 Markdown 文件");
expect(findSetting(container, "默认牌组").desc).toBe("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。");
@ -677,6 +679,24 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(plugin.settings.semanticQaMarker).toBe("#anki-list-qa");
});
it("saves and rehydrates the card answer cutoff mode", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
const cutoffDropdown = getDropdown(findSetting(container, "Card answer cutoff mode"));
expect(cutoffDropdown.value).toBe("heading-block");
expect(cutoffDropdown.options.map((option: { value: string }) => option.value)).toEqual(["heading-block", "double-blank-lines"]);
await cutoffDropdown.triggerChange("double-blank-lines");
expect(plugin.settings.cardAnswerCutoffMode).toBe("double-blank-lines");
tab.display();
expect(getDropdown(findSetting(container, "Card answer cutoff mode")).value).toBe("double-blank-lines");
});
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,6 @@
import { PluginSettingTab, Setting } from "obsidian";
import { isValidHashtagMarker, isValidSemanticQaMarker, type FileDeckInsertLocation, type FolderDeckMode, type ScopeMode } from "@/application/config/PluginSettings";
import { isValidHashtagMarker, isValidSemanticQaMarker, type CardAnswerCutoffMode, type FileDeckInsertLocation, type FolderDeckMode, 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";
@ -98,6 +98,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName(t("settings.cardAnswerCutoffMode.name"))
.setDesc(t("settings.cardAnswerCutoffMode.desc"))
.addDropdown((dropdown) => {
dropdown.addOption("heading-block", t("settings.cardAnswerCutoffMode.options.headingBlock"));
dropdown.addOption("double-blank-lines", t("settings.cardAnswerCutoffMode.options.doubleBlankLines"));
dropdown.setValue(settings.cardAnswerCutoffMode).onChange((value) => {
void this.plugin.updateSettings({ cardAnswerCutoffMode: value as CardAnswerCutoffMode });
});
});
containerEl.createEl("h3", { text: t("settings.qaGroup.title") });
new Setting(containerEl)
@ -133,7 +144,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
this.renderSemanticQaPreview(containerEl, settings.semanticQaMarker);
this.renderSemanticQaPreview(containerEl, settings.semanticQaMarker, settings.cardAnswerCutoffMode);
this.renderScopeSection(containerEl, settings);
@ -453,7 +464,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
}
private renderSemanticQaPreview(containerEl: HTMLElement, marker: string): void {
private renderSemanticQaPreview(containerEl: HTMLElement, marker: string, cardAnswerCutoffMode: CardAnswerCutoffMode): void {
const sampleHeading = `城市更新 ${marker}`;
containerEl.createEl("h4", { text: t("settings.semanticQa.previewTitle") });
@ -469,6 +480,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
" 对城市更新有系统学习需求的从业者。",
],
bodyStartLine: 2,
cardAnswerCutoffMode,
});
if (previewCards.length === 0) {