diff --git a/src/analysis/templateMatching.ts b/src/analysis/templateMatching.ts index b1454ed..94cc655 100644 --- a/src/analysis/templateMatching.ts +++ b/src/analysis/templateMatching.ts @@ -362,6 +362,8 @@ export async function buildCandidateNodes( if (section.sectionType) effectiveType = section.sectionType; displayHeading = section.heading; // canonical heading from file (e.g. with ^block-id) } + // Note: We don't skip candidate nodes if section is not found, as the heading might be valid + // but not yet parsed correctly, or it might be a note-level reference } candidateNodes.push({ diff --git a/src/interview/targetTypeResolver.ts b/src/interview/targetTypeResolver.ts index efe9fed..58df17e 100644 --- a/src/interview/targetTypeResolver.ts +++ b/src/interview/targetTypeResolver.ts @@ -28,7 +28,7 @@ export function getSectionTypeForHeading(content: string, heading: string): stri for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === undefined) continue; - const match = line.match(/^(#{1,6})\s+(.+?)(?:\s+\^[\w-]+)?\s*$/); + const match = line.match(/^(#{1,6})\s+(.+?)(?:\s*\^[\w-]+)?\s*$/); if (match && match[2]) { const lineHeading = match[2].trim(); if (lineHeading.toLowerCase() === headingNorm) { @@ -115,7 +115,7 @@ export async function getHeadingsWithSectionTypes( for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === undefined) continue; - const match = line.match(/^(#{1,6})\s+(.+?)(?:\s+\^[\w-]+)?\s*$/); + const match = line.match(/^(#{1,6})\s+(.+?)(?:\s*\^[\w-]+)?\s*$/); if (match && match[1] && match[2]) { const level = match[1].length; const heading = match[2].trim(); diff --git a/src/mapping/sectionParser.ts b/src/mapping/sectionParser.ts index 5a89b49..5c7ffee 100644 --- a/src/mapping/sectionParser.ts +++ b/src/mapping/sectionParser.ts @@ -17,8 +17,8 @@ export interface NoteSection { /** Match `> [!section] type` callout (type = word characters). */ const SECTION_CALLOUT_REGEX = /^\s*>\s*\[!section\]\s*(\S+)\s*$/i; -/** Match block ID at end of heading text: `... ^block-id`. */ -const BLOCK_ID_IN_HEADING_REGEX = /\s+\^([a-zA-Z0-9_-]+)\s*$/; +/** Match block ID at end of heading text: `... ^block-id` or `...^block-id` (with or without space before ^). */ +const BLOCK_ID_IN_HEADING_REGEX = /\s*\^([a-zA-Z0-9_-]+)\s*$/; /** * Split markdown content into sections by headings. diff --git a/src/tests/analysis/testMultipleEdges.test.ts b/src/tests/analysis/testMultipleEdges.test.ts index b7457f5..88b1cb9 100644 --- a/src/tests/analysis/testMultipleEdges.test.ts +++ b/src/tests/analysis/testMultipleEdges.test.ts @@ -136,7 +136,8 @@ type: experience if (!h) return ""; let s = h.trim(); if (!s) return ""; - s = s.replace(/\s+\^[a-zA-Z0-9_-]+\s*$/, "").trim(); + // Use \s* instead of \s+ to match both " ^block" and "^block" (with or without space before ^) + s = s.replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, "").trim(); s = s.replace(/\s+[a-zA-Z0-9_-]+\s*$/, "").trim(); return s || ""; }; diff --git a/src/tests/unresolvedLink/linkHelpers.test.ts b/src/tests/unresolvedLink/linkHelpers.test.ts index 2cd05f4..e96b4ed 100644 --- a/src/tests/unresolvedLink/linkHelpers.test.ts +++ b/src/tests/unresolvedLink/linkHelpers.test.ts @@ -50,12 +50,10 @@ describe("normalizeHeadingForMatch", () => { expect(normalizeHeadingForMatch("Kontext ^context-block")).toBe("Kontext"); }); - it("strips one trailing word (Obsidian UI link form)", () => { - expect(normalizeHeadingForMatch("Überschrift Block")).toBe("Überschrift"); - expect(normalizeHeadingForMatch("Kontext")).toBe("Kontext"); - }); - - it("leaves heading without block suffix unchanged", () => { + it("leaves heading without caret unchanged (idempotency)", () => { + // When no ^ is present, return as-is. Input may be canonical form from "Titel ^Block". + // This ensures "Lars ist gut" (from "Lars ist gut ^PersLars") is not incorrectly shortened. + expect(normalizeHeadingForMatch("Lars ist gut")).toBe("Lars ist gut"); expect(normalizeHeadingForMatch("Überschrift")).toBe("Überschrift"); expect(normalizeHeadingForMatch("Kontext")).toBe("Kontext"); }); @@ -70,9 +68,10 @@ describe("headingsMatch", () => { it("matches heading with block-id variants", () => { expect(headingsMatch("Überschrift", "Überschrift ^Block")).toBe(true); expect(headingsMatch("Überschrift ^Block", "Überschrift")).toBe(true); - expect(headingsMatch("Überschrift Block", "Überschrift")).toBe(true); - expect(headingsMatch("Überschrift", "Überschrift Block")).toBe(true); - expect(headingsMatch("Überschrift ^Block", "Überschrift Block")).toBe(true); + // Multi-word with ^: "Lars ist gut ^PersLars" <-> "Lars ist gut" (idempotent) + expect(headingsMatch("Lars ist gut ^PersLars", "Lars ist gut")).toBe(true); + expect(headingsMatch("Lars ist gut", "Lars ist gut ^PersLars")).toBe(true); + expect(headingsMatch("Nächster Schritt ^next", "Nächster Schritt")).toBe(true); }); it("does not match different headings", () => { diff --git a/src/unresolvedLink/linkHelpers.ts b/src/unresolvedLink/linkHelpers.ts index eb85159..f3a7993 100644 --- a/src/unresolvedLink/linkHelpers.ts +++ b/src/unresolvedLink/linkHelpers.ts @@ -6,20 +6,28 @@ import { App, TFile } from "obsidian"; /** * Normalize heading string for comparison only (Inspect Chains / Chain Workbench). - * Maps "Überschrift", "Überschrift ^Block", "Überschrift Block" to the same canonical form - * so that Obsidian UI links (no ^) and plugin/sectionParser variants match. - * - Strip trailing block-id with caret: \s+\^[a-zA-Z0-9_-]+$ - * - Strip one trailing word (Obsidian stores "Überschrift Block" without ^) + * Maps "Überschrift ^Block", "Lars ist gut ^PersLars" etc. to canonical form. + * - Strip trailing block-id with caret: \s*\^[a-zA-Z0-9_-]+$ (with or without space before ^) + * - When no ^ is present, return as-is (idempotency for multi-word headings) * Use only for equality checks, not for display or stored links. */ export function normalizeHeadingForMatch(heading: string | null): string | null { if (heading === null || heading === undefined) return null; let s = heading.trim(); if (!s) return null; - // 1) Remove optional block-id suffix with caret: "Überschrift ^Block" -> "Überschrift" - s = s.replace(/\s+\^[a-zA-Z0-9_-]+\s*$/, "").trim(); - // 2) Remove one trailing word (Obsidian link form "Überschrift Block" -> "Überschrift") - s = s.replace(/\s+[a-zA-Z0-9_-]+\s*$/, "").trim(); + // 1) Remove optional block-id suffix with caret: "Überschrift ^Block" or "Überschrift^Block" -> "Überschrift" + // Use \s* instead of \s+ to match both " ^block" and "^block" (with or without space before ^) + const hadCaretBlockId = /\s*\^[a-zA-Z0-9_-]+\s*$/.test(s); + if (hadCaretBlockId) { + s = s.replace(/\s*\^[a-zA-Z0-9_-]+\s*$/, "").trim(); + // Do NOT apply step 2: the ^BlockID was the block reference; the rest is the title. + // This ensures idempotency: norm(norm(x)) === norm(x) when x is already normalized. + return s || null; + } + // 2) When no ^ was present: return as-is for idempotency. The input may be the canonical + // form from a previous normalization (e.g. key "Lars ist gut" from "Lars ist gut ^PersLars"). + // Applying step 2 would incorrectly remove words, breaking headingsMatch. + // Obsidian format "Titel BlockID" (without ^) is not handled; use ^ format for reliability. return s || null; } diff --git a/src/workbench/createSectionAction.ts b/src/workbench/createSectionAction.ts index 2ac3e6b..dafac3a 100644 --- a/src/workbench/createSectionAction.ts +++ b/src/workbench/createSectionAction.ts @@ -231,18 +231,22 @@ async function insertEdgeIntoFile( zoneContent.includes(`> [!${wrapperCalloutType}]`) && zoneContent.includes(wrapperTitle); let insertLine = zone.endLine - 1; - const edgeLinesToInsert = [ - EDGE_GROUP_SEPARATOR_LINE, - ...newEdgeLines.split("\n").filter((line) => line.trim().length > 0), - ]; if (hasWrapper) { const wrapperEnd = findWrapperBlockEnd(lines, zone.startLine, wrapperCalloutType, wrapperTitle); if (wrapperEnd != null) { const afterSameType = findInsertLineAfterSameEdgeType(lines, zone.startLine, wrapperEnd, edgeType); const lastContentLine = findLastContentLineInWrapper(lines, zone.startLine, wrapperEnd); insertLine = afterSameType ?? (lastContentLine + 1); + const edgeContentLines = newEdgeLines.split("\n").filter((line) => line.trim().length > 0); + const edgeLinesToInsert = afterSameType !== null + ? edgeContentLines + : [EDGE_GROUP_SEPARATOR_LINE, ...edgeContentLines]; removeBlanksAndInsert(lines, insertLine, wrapperEnd, edgeLinesToInsert); } else { + const edgeLinesToInsert = [ + EDGE_GROUP_SEPARATOR_LINE, + ...newEdgeLines.split("\n").filter((line) => line.trim().length > 0), + ]; lines.splice(insertLine, 0, ...edgeLinesToInsert); } } else { @@ -315,10 +319,10 @@ async function insertEdgeIntoSection( const afterSameType = findInsertLineAfterSameEdgeType(lines, sectionStart, wrapperEnd, edgeType); const lastContentLine = findLastContentLineInWrapper(lines, sectionStart, wrapperEnd); const insertLine = afterSameType ?? (lastContentLine + 1); - const edgeLines = [ - EDGE_GROUP_SEPARATOR_LINE, - ...newEdgeLines.split("\n").filter((line) => line.trim().length > 0), - ]; + const edgeContentLines = newEdgeLines.split("\n").filter((line) => line.trim().length > 0); + const edgeLines = afterSameType !== null + ? edgeContentLines + : [EDGE_GROUP_SEPARATOR_LINE, ...edgeContentLines]; removeBlanksAndInsert(lines, insertLine, wrapperEnd, edgeLines); } else { const wrapper = `\n> [!${wrapperCalloutType}]${foldMarker}${wrapperTitle ? ` ${wrapperTitle}` : ""}\n${newEdgeLines.trimEnd()}`;