mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
67854cf154
commit
4052eff0e7
23 changed files with 1051 additions and 249 deletions
120
docs/card-answer-cutoff-decisions.md
Normal file
120
docs/card-answer-cutoff-decisions.md
Normal 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 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.
|
||||
129
docs/card-answer-cutoff-gap-report.md
Normal file
129
docs/card-answer-cutoff-gap-report.md
Normal 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.
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
205
src/domain/manual-sync/services/AnswerBoundaryParser.ts
Normal file
205
src/domain/manual-sync/services/AnswerBoundaryParser.ts
Normal 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("~~~");
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "", "");
|
||||
}
|
||||
}
|
||||
98
src/domain/manual-sync/services/QaGroupBlockParser.test.ts
Normal file
98
src/domain/manual-sync/services/QaGroupBlockParser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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] ?? "";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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。",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue