diff --git a/src/workbench/createSectionAction.test.ts b/src/workbench/createSectionAction.test.ts new file mode 100644 index 0000000..acd57e3 --- /dev/null +++ b/src/workbench/createSectionAction.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { removeBlanksAndInsert } from "./edgeInsertHelper"; + +describe("removeBlanksAndInsert", () => { + it("removes blank line at insert position and inserts edge lines", () => { + const lines = [ + "> [!abstract]- 🕸️ Semantic Mapping", + ">", + ">> [!edge] guides", + ">> [[SomeNote#^next]]", + "", + "", + ]; + const edgeLines = [">", ">> [!edge] impacted_by", ">> [[Other#^per]]"]; + removeBlanksAndInsert(lines, 4, 6, edgeLines); + expect(lines).toEqual([ + "> [!abstract]- 🕸️ Semantic Mapping", + ">", + ">> [!edge] guides", + ">> [[SomeNote#^next]]", + ">", + ">> [!edge] impacted_by", + ">> [[Other#^per]]", + ]); + expect(lines.some((l) => l.trim() === "" && !l.startsWith(">"))).toBe(false); + }); + + it("removes multiple consecutive blank lines at insert position", () => { + const lines = [ + ">> [!edge] related_to", + ">> [[#^impact]]", + "", + "", + "", + ]; + const edgeLines = [">", ">> [!edge] foundation_for", ">> [[#^next]]"]; + removeBlanksAndInsert(lines, 2, 5, edgeLines); + expect(lines).toEqual([ + ">> [!edge] related_to", + ">> [[#^impact]]", + ">", + ">> [!edge] foundation_for", + ">> [[#^next]]", + ]); + }); + + it("inserts at position when no blank lines at insert position", () => { + const lines = [ + ">> [!edge] beherrscht_von", + ">> [[#^situation]]", + ">", + ">> [!edge] related_to", + ">> [[#^impact]]", + ]; + const edgeLines = [">", ">> [!edge] foundation_for", ">> [[#^next]]"]; + removeBlanksAndInsert(lines, 3, 5, edgeLines); + expect(lines).toEqual([ + ">> [!edge] beherrscht_von", + ">> [[#^situation]]", + ">", + ">", + ">> [!edge] foundation_for", + ">> [[#^next]]", + ">> [!edge] related_to", + ">> [[#^impact]]", + ]); + }); + + it("result contains no empty string lines within block (only lines starting with >)", () => { + const lines = [ + "> [!abstract]- Title", + ">> [!edge] type_a", + ">> [[A]]", + "", + ]; + const edgeLines = [">", ">> [!edge] type_b", ">> [[B]]"]; + removeBlanksAndInsert(lines, 3, 4, edgeLines); + const inBlock = lines.slice(0, 7); + const hasBlank = inBlock.some((l) => l === "" || (l.trim() === "" && !l.startsWith(">"))); + expect(hasBlank).toBe(false); + }); + + it("insert after last content line: blank at insert position is removed, new edge directly after last >> [[...]]", () => { + const lines = [ + "> [!abstract]- 🕸️ Semantic Mapping", + ">", + ">> [!edge] guides", + ">> [[Geburt unserer Kinder Rouven und Rohan#Nächster Schritt ^next]]", + "", + ]; + const edgeLines = [">", ">> [!edge] impacted_by", ">> [[Geburt unserer Kinder Rouven und Rohan#^per]]"]; + removeBlanksAndInsert(lines, 4, 5, edgeLines); + expect(lines[3]).toBe(">> [[Geburt unserer Kinder Rouven und Rohan#Nächster Schritt ^next]]"); + expect(lines[4]).toBe(">"); + expect(lines[5]).toBe(">> [!edge] impacted_by"); + expect(lines[6]).toBe(">> [[Geburt unserer Kinder Rouven und Rohan#^per]]"); + expect(lines).not.toContain(""); + }); +}); diff --git a/src/workbench/createSectionAction.ts b/src/workbench/createSectionAction.ts index 792fc81..2ac3e6b 100644 --- a/src/workbench/createSectionAction.ts +++ b/src/workbench/createSectionAction.ts @@ -12,6 +12,8 @@ import type { ChainTemplate, ChainRolesConfig, ChainTemplatesConfig } from "../d import type { TemplateMatch } from "../analysis/chainInspector"; import { EntityPickerModal } from "../ui/EntityPickerModal"; import { NoteIndex } from "../entityPicker/noteIndex"; +import { detectZoneFromContent } from "./zoneDetector"; +import { removeBlanksAndInsert } from "./edgeInsertHelper"; /** Common section/node types for dropdown when slot has no allowed_node_types. */ const FALLBACK_SECTION_TYPES = [ @@ -31,10 +33,15 @@ export interface CreateSectionResult { blockId: string | null; sectionType: string | null; sectionBody: string; + /** Edges in der neuen Sektion (mit gewähltem edgeType) */ initialEdges: Array<{ edgeType: string; targetNote: string; targetHeading: string | null; + /** Kanonischer Vorwärts-Typ (für Gegenkante in der anderen Note) */ + forwardEdgeType: string; + /** true = diese Kante ist Rückwärtskante; Gegenkante (Vorwärts) in targetNote einfügen */ + isInverse: boolean; }>; } @@ -76,7 +83,6 @@ export async function createSectionInNote( edgeVocabulary ); - // Chain notes: unique file paths from slot assignments const chainNotePaths = getChainNotePaths(match); const defaultSectionType = getDefaultSectionType(template, todo); const sectionTypeOptions = getSectionTypeOptions(todo, template); @@ -109,6 +115,12 @@ export async function createSectionInNote( sectionContent += `> [!section] ${result.sectionType}\n\n`; } + // Zuerst Fließtext der Sektion + if (result.sectionBody.trim()) { + sectionContent += result.sectionBody.trim() + "\n\n"; + } + + // Abstract-Block mit Kanten am Ende der Sektion if (result.initialEdges.length > 0) { const wrapperType = settings.mappingWrapperCalloutType || "abstract"; const wrapperTitle = settings.mappingWrapperTitle || ""; @@ -127,12 +139,13 @@ export async function createSectionInNote( sectionContent += "\n"; } - if (result.sectionBody.trim()) { - sectionContent += result.sectionBody.trim() + "\n\n"; - } - - // Resolve target file - const targetFile = app.vault.getAbstractFileByPath(result.targetFilePath); + // Resolve target file (modal may store path with or without .md) + const targetPathNorm = result.targetFilePath.endsWith(".md") + ? result.targetFilePath + : `${result.targetFilePath}.md`; + const targetFile = + app.vault.getAbstractFileByPath(result.targetFilePath) ?? + app.vault.getAbstractFileByPath(targetPathNorm); if (!targetFile || !(targetFile instanceof TFile)) { new Notice("Zieldatei nicht gefunden"); return; @@ -144,8 +157,39 @@ export async function createSectionInNote( await app.vault.modify(targetFile, content + sectionToInsert); - // Die Rückwärtskanten stehen in der neuen Sektion (initialEdges sind bereits Inverse-Typen und zeigen auf Quell-Notes). - // Kein separater Eintrag in anderen Notes nötig. + // Gegenkanten in den anderen Notes einfügen (Vorwärts- bzw. Rückwärtskante) + const sourcePath = result.targetFilePath.replace(/\.md$/, ""); + const sourceFragment = + result.blockId && result.heading + ? `${result.heading} ^${result.blockId}` + : result.blockId + ? `^${result.blockId}` + : result.heading ?? ""; + const sourceLink = sourceFragment ? `${sourcePath}#${sourceFragment}` : sourcePath; + + for (const edge of result.initialEdges) { + const otherPathRaw = edge.targetNote.replace(/\.md$/, ""); + const otherFilePath = `${otherPathRaw}.md`; + let otherFile = + app.vault.getAbstractFileByPath(otherFilePath) ?? + app.metadataCache.getFirstLinkpathDest(otherPathRaw, targetFile.path); + if (!otherFile || !(otherFile instanceof TFile)) { + if (debugLogging) { + console.warn("[createSectionInNote] Other note not found for edge, skipping:", otherPathRaw, "resolved from", targetFile.path); + } + continue; + } + + const edgeTypeInOther = edge.isInverse + ? edge.forwardEdgeType + : (vocabulary.getInverse(vocabulary.getCanonical(edge.edgeType) ?? edge.forwardEdgeType) ?? "related_to"); + + if (edge.targetHeading) { + await insertEdgeIntoSection(app, otherFile, edge.targetHeading, edgeTypeInOther, sourceLink, settings, debugLogging); + } else { + await insertEdgeIntoFile(app, otherFile, edgeTypeInOther, sourceLink, settings, debugLogging); + } + } new Notice(`Sektion "${result.heading}" in ${targetFile.basename} erstellt`); @@ -154,6 +198,221 @@ export async function createSectionInNote( } } +/** Trennzeile zwischen Edge-Blöcken im Abstract: genau eine Zeile mit nur ">" (keine echte Leerzeile). */ +const EDGE_GROUP_SEPARATOR_LINE = ">"; + +/** Fügt eine Kante in die Note-Verbindungen-Zone einer Datei ein (ohne Editor). */ +async function insertEdgeIntoFile( + app: App, + file: TFile, + edgeType: string, + targetLink: string, + settings: MindnetSettings, + debugLogging?: boolean +): Promise { + const wrapperCalloutType = settings.mappingWrapperCalloutType || "abstract"; + const wrapperTitle = settings.mappingWrapperTitle || "🕸️ Semantic Mapping"; + const foldMarker = settings.mappingWrapperFolded ? "-" : "+"; + + const content = await app.vault.read(file); + const zone = detectZoneFromContent(content, "note_links"); + const newEdgeLines = `>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + + if (!zone.exists) { + const newZone = `\n\n## Note-Verbindungen\n\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n${newEdgeLines}`; + await app.vault.modify(file, content + newZone); + if (debugLogging) console.log("[createSectionInNote] Edge: created Note-Verbindungen in", file.path); + return; + } + + const lines = content.split(/\r?\n/); + const zoneContent = zone.content; + const hasWrapper = + 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); + removeBlanksAndInsert(lines, insertLine, wrapperEnd, edgeLinesToInsert); + } else { + lines.splice(insertLine, 0, ...edgeLinesToInsert); + } + } else { + const wrapper = `\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n${newEdgeLines.trimEnd()}`; + const wrapperLines = wrapper.trimStart().split("\n").filter((line) => line.trim().length > 0); + lines.splice(insertLine, 0, ...wrapperLines); + await app.vault.modify(file, lines.join("\n")); + if (debugLogging) console.log("[createSectionInNote] Edge: added wrapper + edge in", file.path); + return; + } + + await app.vault.modify(file, lines.join("\n")); + if (debugLogging) console.log("[createSectionInNote] Edge: inserted into", file.path); +} + +/** Findet die Zeilen einer Sektion anhand Überschrift/Block-ID (targetHeading z. B. "Reflexion ^learning"). */ +function findSectionBounds(lines: string[], targetHeading: string): { startLine: number; endLine: number } | null { + const targetNorm = targetHeading.trim(); + const targetBlockId = targetNorm.includes(" ^") ? targetNorm.split(" ^").pop()?.trim() : null; + const targetHeadingOnly = targetBlockId ? targetNorm.replace(/\s*\^\s*\S+$/, "").trim() : targetNorm; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line?.startsWith("## ")) continue; + const afterHash = line.replace(/^##\s+/, "").trim(); + const matchesHeading = afterHash === targetNorm || afterHash === targetHeadingOnly || afterHash.includes(targetHeadingOnly); + const matchesBlockId = !targetBlockId || afterHash.includes(`^${targetBlockId}`); + if (matchesHeading || matchesBlockId) { + let endLine = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j]?.match(/^##\s+/)) { + endLine = j; + break; + } + } + return { startLine: i, endLine }; + } + } + return null; +} + +/** Fügt die Rückwärtskante in die angegebene Sektion ein (in deren Abstract-Block am Sektionsende). */ +async function insertEdgeIntoSection( + app: App, + file: TFile, + targetHeading: string, + edgeType: string, + targetLink: string, + settings: MindnetSettings, + debugLogging?: boolean +): Promise { + const wrapperCalloutType = settings.mappingWrapperCalloutType || "abstract"; + const wrapperTitle = settings.mappingWrapperTitle || "🕸️ Semantic Mapping"; + const foldMarker = settings.mappingWrapperFolded ? "-" : "+"; + + const content = await app.vault.read(file); + const lines = content.split(/\r?\n/); + const bounds = findSectionBounds(lines, targetHeading); + if (!bounds) { + if (debugLogging) console.warn("[createSectionInNote] Section not found for heading:", targetHeading, "in", file.path); + return; + } + + const newEdgeLines = `>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + const sectionStart = bounds.startLine; + const sectionEnd = bounds.endLine; + + const wrapperEnd = findWrapperBlockEnd(lines, sectionStart, wrapperCalloutType, wrapperTitle, sectionEnd); + if (wrapperEnd != null) { + 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), + ]; + removeBlanksAndInsert(lines, insertLine, wrapperEnd, edgeLines); + } else { + const wrapper = `\n> [!${wrapperCalloutType}]${foldMarker}${wrapperTitle ? ` ${wrapperTitle}` : ""}\n${newEdgeLines.trimEnd()}`; + const insertAt = sectionEnd; + const wrapperLines = wrapper.trimStart().split("\n").filter((line) => line.trim().length > 0); + lines.splice(insertAt, 0, "", ...wrapperLines); + } + + await app.vault.modify(file, lines.join("\n")); + if (debugLogging) console.log("[createSectionInNote] Edge: inserted into section", targetHeading, "in", file.path); +} + +/** Findet die Einfügezeile, damit die neue Kante direkt nach der letzten Kante desselben Typs steht. */ +function findInsertLineAfterSameEdgeType( + lines: string[], + wrapperStart: number, + wrapperEnd: number, + edgeType: string +): number | null { + const edgeDeclRegex = /^\s*>>\s*\[!edge\]\s+(.+?)\s*$/; + let lastInsertLine: number | null = null; + + for (let i = wrapperStart; i < wrapperEnd; i++) { + const line = lines[i]; + if (!line?.startsWith(">")) continue; + const m = line.match(edgeDeclRegex); + if (m?.[1]?.trim() === edgeType) { + const nextLine = lines[i + 1]; + const hasLink = nextLine?.trim().match(/^>>\s*\[\[.+\]\]\s*$/); + lastInsertLine = hasLink ? i + 2 : i + 1; + } + } + + return lastInsertLine; +} + +function findWrapperBlockEnd( + lines: string[], + startLine: number, + calloutType: string, + title: string, + maxLine?: number +): number | null { + const end = maxLine ?? lines.length; + const calloutTypeLower = calloutType.toLowerCase(); + const titleLower = title.toLowerCase(); + const calloutHeaderRegex = new RegExp( + `^\\s*>\\s*\\[!${calloutTypeLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\s*[+-]?\\s*(.+)$`, + "i" + ); + + let inWrapper = false; + let quoteLevel = 0; + + for (let i = startLine; i < end; i++) { + const line = lines[i]; + if (line === undefined) continue; + + const trimmed = line.trim(); + const match = line.match(calloutHeaderRegex); + + if (match && match[1]) { + const headerTitle = match[1].trim().toLowerCase(); + if (headerTitle.includes(titleLower) || titleLower.includes(headerTitle)) { + inWrapper = true; + quoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length ?? 0); + continue; + } + } + + if (inWrapper) { + if (trimmed.match(/^\^map-/)) return i + 1; + if (trimmed === "" || !line.startsWith(">")) return i; + const currentQuoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length ?? 0); + if (currentQuoteLevel < quoteLevel) return i; + if (trimmed.match(/^#{1,6}\s+/)) return i; + } + } + + return inWrapper ? end : null; +} + +/** Index der letzten Zeile im Wrapper, die mit ">" beginnt (echter Blockinhalt). */ +function findLastContentLineInWrapper( + lines: string[], + wrapperStart: number, + wrapperEnd: number +): number { + for (let i = wrapperEnd - 1; i >= wrapperStart; i--) { + if (lines[i]?.startsWith(">")) return i; + } + return wrapperStart; +} + function getChainNotePaths(match: TemplateMatch): string[] { const paths = new Set(); for (const a of Object.values(match.slotAssignments)) { @@ -188,15 +447,21 @@ function getSectionTypeOptions(todo: MissingSlotTodo, template: ChainTemplate): return [...FALLBACK_SECTION_TYPES]; } +export interface RequiredEdgeForSlot { + edgeType: string; + targetNote: string; + targetHeading: string | null; + suggestedAlias?: string; + forwardEdgeType: string; + isInverse: boolean; + /** Optionen für Dropdown: kanonischer Typ + Aliase (erster = Vorschlag) */ + allowedTypes: string[]; +} + async function showCreateSectionModal( app: App, todo: MissingSlotTodo, - requiredEdges: Array<{ - edgeType: string; - targetNote: string; - targetHeading: string | null; - suggestedAlias?: string; - }>, + requiredEdges: RequiredEdgeForSlot[], chainNotePaths: string[], activeFilePath: string | null, defaultSectionType: string, @@ -219,6 +484,8 @@ async function showCreateSectionModal( ? activeFilePath : chainNotePaths[0] ?? activeFilePath ?? "") as string; initialEdges: CreateSectionResult["initialEdges"] = []; + /** Gewählter Edge-Typ pro Index (Reihenfolge wie requiredEdges) */ + chosenEdgeTypes: string[] = []; constructor() { super(app); @@ -292,24 +559,30 @@ async function showCreateSectionModal( }); }); - // Initial edges (read-only info, all applied) + // Verbindungen: pro Edge Typ wählbar (Dropdown) if (requiredEdges.length > 0) { contentEl.createEl("h3", { text: "Verbindungen (werden angelegt)" }); - const ul = contentEl.createEl("ul", { cls: "create-section-edges-list" }); - for (const edge of requiredEdges) { - const li = ul.createEl("li"); - li.textContent = `${edge.suggestedAlias || edge.edgeType} → ${edge.targetNote}${edge.targetHeading ? `#${edge.targetHeading}` : ""}`; - } - this.initialEdges = requiredEdges.map((e) => ({ - edgeType: e.suggestedAlias || e.edgeType, - targetNote: e.targetNote, - targetHeading: e.targetHeading, - })); + this.chosenEdgeTypes = requiredEdges.map((e) => e.suggestedAlias ?? e.edgeType); + requiredEdges.forEach((edge, index) => { + const targetLabel = `${edge.targetNote}${edge.targetHeading ? `#${edge.targetHeading}` : ""}`; + new Setting(contentEl) + .setName(`Kante nach ${targetLabel}`) + .setDesc("Edge-Typ (kanonisch oder Alias)") + .addDropdown((dropdown) => { + for (const opt of edge.allowedTypes) { + dropdown.addOption(opt, opt); + } + dropdown.setValue(this.chosenEdgeTypes[index] ?? edge.edgeType); + dropdown.onChange((value) => { + this.chosenEdgeTypes[index] = value; + }); + }); + }); } // Section body text const bodyDesc = contentEl.createEl("div", { cls: "setting-item-description" }); - bodyDesc.setText("Inhalt der Sektion (optional). Wird unter den Verbindungen eingefügt."); + bodyDesc.setText("Inhalt der Sektion (optional). Der Block mit Verbindungen steht am Ende der Sektion."); const bodyContainer = contentEl.createDiv({ cls: "setting-item" }); const control = bodyContainer.createDiv({ cls: "setting-item-control" }); const textarea = control.createEl("textarea", { @@ -327,7 +600,7 @@ async function showCreateSectionModal( this.close(); }); }).addButton((button) => { - button + button .setButtonText("Sektion erstellen") .setCta() .onClick(() => { @@ -339,6 +612,13 @@ async function showCreateSectionModal( new Notice("Bitte eine Ziel-Note wählen"); return; } + this.initialEdges = requiredEdges.map((e, i) => ({ + edgeType: this.chosenEdgeTypes[i] ?? e.suggestedAlias ?? e.edgeType, + targetNote: e.targetNote, + targetHeading: e.targetHeading, + forwardEdgeType: e.forwardEdgeType, + isInverse: e.isInverse, + })); this.result = { targetFilePath: this.targetFilePath, heading: this.heading.trim(), @@ -388,18 +668,8 @@ function getRequiredEdgesForSlot( chainRoles: ChainRolesConfig | null, vocabulary: Vocabulary, edgeVocabulary: EdgeVocabulary | null -): Array<{ - edgeType: string; - targetNote: string; - targetHeading: string | null; - suggestedAlias?: string; -}> { - const requiredEdges: Array<{ - edgeType: string; - targetNote: string; - targetHeading: string | null; - suggestedAlias?: string; - }> = []; +): RequiredEdgeForSlot[] { + const requiredEdges: RequiredEdgeForSlot[] = []; const normalizedLinks = template.links || []; for (const link of normalizedLinks) { @@ -414,7 +684,17 @@ function getRequiredEdgesForSlot( } const forwardEdgeType = suggestedEdgeTypes[0] || "related_to"; - // Links, die ZU unserem Slot zeigen: In der neuen Sektion Rückwärtskante (Inverse) zur Quell-Note + const buildAllowedTypes = (canonical: string): string[] => { + const list: string[] = [canonical]; + if (edgeVocabulary) { + const entry = edgeVocabulary.byCanonical.get(canonical); + if (entry?.aliases?.length) { + list.push(...entry.aliases); + } + } + return list; + }; + if (link.to === slotId) { const sourceAssignment = match.slotAssignments[link.from]; if (!sourceAssignment) continue; @@ -422,6 +702,7 @@ function getRequiredEdgesForSlot( const canonical = vocabulary.getCanonical(forwardEdgeType); const inverseType = canonical ? vocabulary.getInverse(canonical) : null; const edgeTypeForSection = inverseType ?? forwardEdgeType; + const allowedTypes = inverseType ? buildAllowedTypes(inverseType) : [edgeTypeForSection]; let suggestedAlias: string | undefined; if (edgeVocabulary && edgeTypeForSection) { const entry = edgeVocabulary.byCanonical.get(edgeTypeForSection); @@ -434,14 +715,17 @@ function getRequiredEdgesForSlot( targetNote: sourceAssignment.file.replace(/\.md$/, ""), targetHeading: sourceAssignment.heading ?? null, suggestedAlias, + forwardEdgeType, + isInverse: true, + allowedTypes: allowedTypes.length ? allowedTypes : [edgeTypeForSection], }); continue; } - // Links, die VON unserem Slot ausgehen: In der neuen Sektion Vorwärtskante zur Ziel-Note if (link.from === slotId) { const targetAssignment = match.slotAssignments[link.to]; if (!targetAssignment) continue; + const allowedTypes = buildAllowedTypes(forwardEdgeType); let suggestedAlias: string | undefined; if (edgeVocabulary) { const entry = edgeVocabulary.byCanonical.get(forwardEdgeType); @@ -454,6 +738,9 @@ function getRequiredEdgesForSlot( targetNote: targetAssignment.file.replace(/\.md$/, ""), targetHeading: targetAssignment.heading ?? null, suggestedAlias, + forwardEdgeType, + isInverse: false, + allowedTypes, }); } } diff --git a/src/workbench/edgeInsertHelper.ts b/src/workbench/edgeInsertHelper.ts new file mode 100644 index 0000000..c8a8a93 --- /dev/null +++ b/src/workbench/edgeInsertHelper.ts @@ -0,0 +1,22 @@ +/** + * Pure helper for inserting edge lines into a line array without introducing + * blank lines inside abstract/callout blocks. + */ + +/** + * Removes blank lines at the insert position (within blockEnd) and splices in edgeLines. + * Ensures no empty string or whitespace-only lines remain at the insert spot. + */ +export function removeBlanksAndInsert( + lines: string[], + insertLine: number, + blockEnd: number, + edgeLines: string[] +): void { + let end = blockEnd; + while (insertLine < end && lines[insertLine]?.trim() === "") { + lines.splice(insertLine, 1); + end--; + } + lines.splice(insertLine, 0, ...edgeLines); +}