From 4052eff0e7531f9f945c75ef139d1001fe2cdc44 Mon Sep 17 00:00:00 2001 From: Dusk Date: Thu, 23 Apr 2026 10:16:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20cardAnswerCutoffMo?= =?UTF-8?q?de=20=E5=8D=A1=E7=89=87=E6=AD=A3=E6=96=87=E6=88=AA=E6=AD=A2?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF=E6=8C=81=20heading-block?= =?UTF-8?q?=20/=20double-blank-lines=EF=BC=8C=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20marker/GI=20=E5=86=99=E5=9B=9E=E5=BD=92=E6=95=B4=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add cardAnswerCutoffMode with heading-block / double-blank-lines and unify marker/GI writeback normalization. --- docs/card-answer-cutoff-decisions.md | 120 ++++++++++ docs/card-answer-cutoff-gap-report.md | 129 +++++++++++ src/application/config/PluginSettings.test.ts | 8 + src/application/config/PluginSettings.ts | 7 + .../services/FileIndexerService.test.ts | 55 +++++ .../services/FileIndexerService.ts | 5 +- .../services/AnswerBoundaryParser.ts | 205 ++++++++++++++++++ .../services/CardIndexingService.test.ts | 89 +++++++- .../services/CardIndexingService.ts | 83 ++----- .../services/CardMarkerService.test.ts | 60 +++++ .../manual-sync/services/CardMarkerService.ts | 35 +-- .../services/GroupMarkerService.test.ts | 34 +++ .../services/GroupMarkerService.ts | 36 +-- .../services/MarkerWritebackNormalization.ts | 37 ++++ .../services/QaGroupBlockParser.test.ts | 98 +++++++++ .../services/QaGroupBlockParser.ts | 82 ++----- .../services/SemanticQaListParser.test.ts | 78 +++++++ .../services/SemanticQaListParser.ts | 80 ++----- .../DataJsonPluginConfigRepository.test.ts | 3 + src/presentation/i18n/messages/en.ts | 9 + src/presentation/i18n/messages/zh.ts | 9 + .../settings/PluginSettingTab.test.ts | 20 ++ src/presentation/settings/PluginSettingTab.ts | 18 +- 23 files changed, 1051 insertions(+), 249 deletions(-) create mode 100644 docs/card-answer-cutoff-decisions.md create mode 100644 docs/card-answer-cutoff-gap-report.md create mode 100644 src/domain/manual-sync/services/AnswerBoundaryParser.ts create mode 100644 src/domain/manual-sync/services/MarkerWritebackNormalization.ts create mode 100644 src/domain/manual-sync/services/QaGroupBlockParser.test.ts diff --git a/docs/card-answer-cutoff-decisions.md b/docs/card-answer-cutoff-decisions.md new file mode 100644 index 0000000..ecf91cf --- /dev/null +++ b/docs/card-answer-cutoff-decisions.md @@ -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 plan’s 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. \ No newline at end of file diff --git a/docs/card-answer-cutoff-gap-report.md b/docs/card-answer-cutoff-gap-report.md new file mode 100644 index 0000000..712724b --- /dev/null +++ b/docs/card-answer-cutoff-gap-report.md @@ -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 `` 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 `` 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. \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 629396f..b545de2 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -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"); }); }); \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 4f1124c..7c82d67 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -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"); } diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index c2af2a2..366fab0 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -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"]); + }); }); \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index 885120f..8ff14bf 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -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, diff --git a/src/domain/manual-sync/services/AnswerBoundaryParser.ts b/src/domain/manual-sync/services/AnswerBoundaryParser.ts new file mode 100644 index 0000000..9df7156 --- /dev/null +++ b/src/domain/manual-sync/services/AnswerBoundaryParser.ts @@ -0,0 +1,205 @@ +import type { CardAnswerCutoffMode } from "@/application/config/PluginSettings"; + +export type MarkerBoundaryState = "missing" | "present-valid" | "present-invalid"; + +export interface BoundaryMarkerAdapter { + isCandidate(line: string): boolean; + parse(line: string, lineIndex: number): TMarker | null; +} + +export interface ResolveAnswerBoundaryInput { + lines: string[]; + startLine: number; + fallbackLine: number; + cutoffMode: CardAnswerCutoffMode; + markerAdapter: BoundaryMarkerAdapter; +} + +export interface AnswerBoundary { + contentLines: string[]; + trailingLines: string[]; + contentEndLine: number; + markerLine?: number; + markerIndent?: string; + marker?: TMarker; + markerState: MarkerBoundaryState; +} + +interface MarkerCandidate { + index: number; + indent: string; + marker?: TMarker; + valid: boolean; +} + +interface BlankRun { + start: number; + end: number; +} + +export function resolveAnswerBoundary(input: ResolveAnswerBoundaryInput): AnswerBoundary { + 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( + lines: string[], + startLine: number, + markerAdapter: BoundaryMarkerAdapter, +): MarkerCandidate[] { + const candidates: MarkerCandidate[] = []; + 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("~~~"); +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index 3815171..609808f 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -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", "", "Answer"].join("\n"), + content: ["#### Prompt", "Answer", "", "", "", "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: "\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", ""].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, }); }); diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index b98f204..af0ab29 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -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]; diff --git a/src/domain/manual-sync/services/CardMarkerService.test.ts b/src/domain/manual-sync/services/CardMarkerService.test.ts index 309de7f..c05e3b7 100644 --- a/src/domain/manual-sync/services/CardMarkerService.test.ts +++ b/src/domain/manual-sync/services/CardMarkerService.test.ts @@ -47,4 +47,64 @@ describe("CardMarkerService", () => { "", ].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", + "", + "", + "", + "Remarks", + ].join("\n")); + }); + + it("preserves the normalized marker-before-remarks layout when replacing an existing marker", () => { + 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: 6, + markerLine: 3, + sourceContent, + }]); + + expect(nextContent).toBe([ + "#### Prompt", + "Answer", + "", + "", + "", + "Remarks", + ].join("\n")); + }); }); diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts index 1834269..f21e0f2 100644 --- a/src/domain/manual-sync/services/CardMarkerService.ts +++ b/src/domain/manual-sync/services/CardMarkerService.ts @@ -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(); - 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, + }); } } diff --git a/src/domain/manual-sync/services/GroupMarkerService.test.ts b/src/domain/manual-sync/services/GroupMarkerService.test.ts index 0e6ebb8..8da4dd9 100644 --- a/src/domain/manual-sync/services/GroupMarkerService.test.ts +++ b/src/domain/manual-sync/services/GroupMarkerService.test.ts @@ -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", + "", + "", + "", + "Remarks", + ].join("\n")); + }); }); diff --git a/src/domain/manual-sync/services/GroupMarkerService.ts b/src/domain/manual-sync/services/GroupMarkerService.ts index 6e494e5..0d02afb 100644 --- a/src/domain/manual-sync/services/GroupMarkerService.ts +++ b/src/domain/manual-sync/services/GroupMarkerService.ts @@ -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(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), + }); } } diff --git a/src/domain/manual-sync/services/MarkerWritebackNormalization.ts b/src/domain/manual-sync/services/MarkerWritebackNormalization.ts new file mode 100644 index 0000000..208c51d --- /dev/null +++ b/src/domain/manual-sync/services/MarkerWritebackNormalization.ts @@ -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(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, "", ""); + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/QaGroupBlockParser.test.ts b/src/domain/manual-sync/services/QaGroupBlockParser.test.ts new file mode 100644 index 0000000..cad0112 --- /dev/null +++ b/src/domain/manual-sync/services/QaGroupBlockParser.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { QaGroupBlockParser } from "./QaGroupBlockParser"; + +const VALID_GI_MARKER = ""; + +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); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/QaGroupBlockParser.ts b/src/domain/manual-sync/services/QaGroupBlockParser.ts index d3fe281..a52ef66 100644 --- a/src/domain/manual-sync/services/QaGroupBlockParser.ts +++ b/src/domain/manual-sync/services/QaGroupBlockParser.ts @@ -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; diff --git a/src/domain/manual-sync/services/SemanticQaListParser.test.ts b/src/domain/manual-sync/services/SemanticQaListParser.test.ts index cfe7238..1aac451 100644 --- a/src/domain/manual-sync/services/SemanticQaListParser.test.ts +++ b/src/domain/manual-sync/services/SemanticQaListParser.test.ts @@ -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", + " ", + ], + 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", + " ", + " ", + " ", + " Remark", + ], + bodyStartLine: 2, + }); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + bodyMarkdown: "First line", + contentEndLine: 3, + markerLine: 4, + markerNoteId: 12, + idMarkerState: "present-valid", + }); + }); }); \ No newline at end of file diff --git a/src/domain/manual-sync/services/SemanticQaListParser.ts b/src/domain/manual-sync/services/SemanticQaListParser.ts index 6aac8a0..caca80f 100644 --- a/src/domain/manual-sync/services/SemanticQaListParser.ts +++ b/src/domain/manual-sync/services/SemanticQaListParser.ts @@ -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] ?? ""; diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts index b617338..ab33d03 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -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({ diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 8f0097f..938667d 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -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.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 522d488..4fb58e2 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -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。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index ffe35bf..0693a56 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -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); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index a377017..e197cb3 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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) {