/** * Markdown renderer for interview profiles. * Converts collected answers into markdown output based on profile configuration. * WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge. */ import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep, EntityPickerStep } from "./types"; import type { SectionInfo } from "./wizardState"; import type { GraphSchema } from "../mapping/graphSchema"; import type { Vocabulary } from "../vocab/Vocabulary"; import { slugify } from "./slugify"; import { getHints } from "../mapping/graphSchema"; export interface RenderAnswers { collectedData: Map; loopContexts: Map; // WP-26: Optional - Section-Sequenz für automatische Edge-Vorschläge sectionSequence?: SectionInfo[]; } // WP-26: Render-Optionen für Section-Types und Edge-Vorschläge export interface RenderOptions { graphSchema?: GraphSchema | null; vocabulary?: Vocabulary | null; noteType?: string; // Fallback für effective_type sectionEdgeTypes?: Map>; // fromBlockId -> (toBlockId -> edgeType) für manuell geänderte Edge-Types } /** * Render profile answers to markdown string. * Deterministic and testable. * WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge. */ export function renderProfileToMarkdown( profile: InterviewProfile, answers: RenderAnswers, options?: RenderOptions ): string { // WP-26: Verwende übergebene Section-Sequenz oder erstelle neue // Die Section-Sequenz wird während des interaktiven Wizard-Durchlaufs getrackt const sectionSequence: SectionInfo[] = answers.sectionSequence ? [...answers.sectionSequence] : []; const generatedBlockIds = new Map(); console.log(`[WP-26] renderProfileToMarkdown:`, { sectionSequenceLength: sectionSequence.length, sectionSequence: sectionSequence.map(s => ({ stepKey: s.stepKey, blockId: s.blockId, sectionType: s.sectionType, })), }); // WP-26: Wenn Section-Sequenz übergeben wurde, fülle generatedBlockIds aus der Sequenz if (answers.sectionSequence && answers.sectionSequence.length > 0) { for (const sectionInfo of answers.sectionSequence) { if (sectionInfo.blockId) { generatedBlockIds.set(sectionInfo.blockId, sectionInfo); } } } const context: RenderContext = { profile, sectionSequence, generatedBlockIds, options: options || {}, }; // WP-26: Erster Durchlauf: Rendere alle Steps und sammle gerenderte Sections const renderedSections: Array<{ sectionInfo: SectionInfo; content: string }> = []; const output: string[] = []; for (const step of profile.steps) { // WP-26: Debug-Log für Steps mit WP-26 Feldern if (step.type === "capture_text" || step.type === "capture_text_line") { const captureStep = step as CaptureTextStep | CaptureTextLineStep; console.log(`[WP-26] Renderer: Step ${step.key} hat WP-26 Felder:`, { section_type: captureStep.section_type, block_id: captureStep.block_id, generate_block_id: captureStep.generate_block_id, references: captureStep.references, }); } const stepOutput = renderStep(step, answers, context); if (stepOutput) { // WP-26: Prüfe, ob diese Step eine Section mit Block-ID erzeugt hat if (step.type === "capture_text" || step.type === "capture_text_line") { const captureStep = step as CaptureTextStep | CaptureTextLineStep; let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { blockId = slugify(captureStep.key); } if (blockId && generatedBlockIds.has(blockId)) { const sectionInfo = generatedBlockIds.get(blockId)!; renderedSections.push({ sectionInfo, content: stepOutput }); } } output.push(stepOutput); } } // WP-26: Zweiter Durchlauf: Füge Backward-Edges zu den entsprechenden Sections hinzu // Backward-Edges werden in der Ziel-Sektion (vorherige Section) eingefügt // Wenn Section A -> Section B (Forward-Edge), dann wird Backward-Edge (B -> A) in Section A eingefügt // Spezifikation: Zu jedem Forward-Link wird automatisch ein Rückwärts-Edge in der Ziel-Section generiert const updatedOutput: string[] = []; const backwardEdgesMap = new Map(); // blockId der prevSection -> backwardEdges // Sammle Backward-Edges für ALLE vorherigen Sections // Für jede aktuelle Section generiere Backward-Edges zu allen vorherigen Sections for (const rendered of renderedSections) { const currentIndex = context.sectionSequence.findIndex( s => s.blockId === rendered.sectionInfo.blockId && s.stepKey === rendered.sectionInfo.stepKey ); if (currentIndex > 0 && rendered.sectionInfo.blockId) { // ALLE vorherigen Sections (nicht nur die direkt vorherige) const prevSections = context.sectionSequence.slice(0, currentIndex); // Für jede vorherige Section generiere eine Backward-Edge for (let prevIdx = 0; prevIdx < prevSections.length; prevIdx++) { const prevSection = prevSections[prevIdx]; if (!prevSection || !prevSection.blockId) { continue; } const backwardEdge = renderSingleBackwardEdge( rendered.sectionInfo, prevSection, currentIndex, prevIdx, context ); if (backwardEdge) { const existing = backwardEdgesMap.get(prevSection.blockId) || ""; const combined = existing ? `${existing}\n${backwardEdge}` : backwardEdge; backwardEdgesMap.set(prevSection.blockId, combined); } } } } // Füge Backward-Edges zu den entsprechenden Sections hinzu for (let i = 0; i < output.length; i++) { const currentOutput = output[i]; if (!currentOutput) { updatedOutput.push(""); continue; } // Prüfe, ob diese Output eine Section mit Block-ID ist let sectionInfo: SectionInfo | null = null; for (const rendered of renderedSections) { if (rendered.content === currentOutput) { sectionInfo = rendered.sectionInfo; break; } } if (sectionInfo && sectionInfo.blockId) { const backwardEdges = backwardEdgesMap.get(sectionInfo.blockId); if (backwardEdges) { // Füge Backward-Edges zum abstract wrapper hinzu const updatedContent = addEdgesToAbstractWrapper(currentOutput, backwardEdges); updatedOutput.push(updatedContent); } else { updatedOutput.push(currentOutput); } } else { updatedOutput.push(currentOutput); } } return updatedOutput.join("\n\n").trim(); } // WP-26: Render-Kontext für Section-Types und Block-IDs interface RenderContext { profile: InterviewProfile; sectionSequence: SectionInfo[]; generatedBlockIds: Map; options: RenderOptions; } /** * Render a single step to markdown. * WP-26: Erweitert um RenderContext für Section-Types und Block-IDs. */ function renderStep( step: InterviewStep, answers: RenderAnswers, context: RenderContext ): string | null { switch (step.type) { case "capture_text": return renderCaptureText(step, answers, context); case "capture_text_line": return renderCaptureTextLine(step, answers, context); case "capture_frontmatter": // Frontmatter is handled separately, skip here return null; case "loop": return renderLoop(step, answers, context); case "entity_picker": return renderEntityPicker(step, answers); case "instruction": case "llm_dialog": case "review": // These don't produce markdown output return null; default: return null; } } /** * Render capture_text step. * WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge. */ function renderCaptureText( step: CaptureTextStep, answers: RenderAnswers, context: RenderContext ): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; } const text = String(value); console.log(`[WP-26] renderCaptureText für Step ${step.key}:`, { hasBlockId: !!step.block_id, blockId: step.block_id, hasGenerateBlockId: !!step.generate_block_id, generateBlockId: step.generate_block_id, hasSectionType: !!step.section_type, sectionType: step.section_type, hasSection: !!step.section, section: step.section, }); // WP-26: Block-ID-Generierung let blockId: string | null = null; if (step.block_id) { blockId = step.block_id; } else if (step.generate_block_id) { blockId = slugify(step.key); } console.log(`[WP-26] Generierte Block-ID für Step ${step.key}:`, blockId); // WP-26: Heading mit Block-ID erweitern let heading = step.section || ""; if (heading && blockId) { // Füge Block-ID zu Heading hinzu: "## Heading" -> "## Heading ^block-id" heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`); } // WP-26: Section-Type-Callout generieren (direkt nach Heading) const sectionTypeCallout = step.section_type ? `> [!section] ${step.section_type}` : ""; // WP-26: Section-Info für Tracking const sectionInfo: SectionInfo = { stepKey: step.key, sectionType: step.section_type || null, heading: heading || step.key, blockId: blockId, noteType: context.options.noteType || context.profile.note_type, }; // WP-26: Block-ID tracken if (blockId) { context.generatedBlockIds.set(blockId, sectionInfo); // WP-26: Auch Basis-Block-ID tracken (für Referenzen ohne Index bei Loop-Items) // Z.B. "action_heading-1" wird auch als "action_heading" getrackt const baseBlockId = blockId.replace(/-\d+$/, ""); if (baseBlockId !== blockId && !context.generatedBlockIds.has(baseBlockId)) { context.generatedBlockIds.set(baseBlockId, sectionInfo); } } // WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden) if (step.section) { context.sectionSequence.push(sectionInfo); } // WP-26: Referenzen generieren (Forward-Edges zu anderen Sections) const references = renderReferences(step.references || [], context, sectionInfo); // WP-26: Automatische Forward-Edges generieren (prevSection -> currentSection) const forwardEdges = renderForwardEdges(sectionInfo, context); // WP-26: Automatische Backward-Edges generieren (currentSection -> nachfolgende Sections) // Diese werden später in einem zweiten Durchlauf hinzugefügt const backwardEdges = ""; // Wird später hinzugefügt // Use template if provided if (step.output?.template) { const templateText = renderTemplate(step.output.template, { text, field: step.key, value: text, }); // WP-26: Template mit Heading, Section-Type und Edges kombinieren return combineSectionParts(heading, sectionTypeCallout, templateText, references, forwardEdges, backwardEdges); } // Default: use section if provided, otherwise just the text if (step.section) { return combineSectionParts(heading, sectionTypeCallout, text, references, forwardEdges, backwardEdges); } return text; } /** * Render capture_text_line step. * WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge. */ function renderCaptureTextLine( step: CaptureTextLineStep, answers: RenderAnswers, context: RenderContext ): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; } const text = String(value); // Get heading level if configured const headingLevelKey = `${step.key}_heading_level`; const headingLevel = answers.collectedData.get(headingLevelKey); let headingPrefix = ""; if (step.heading_level?.enabled) { if (typeof headingLevel === "number") { const level = Math.max(1, Math.min(6, headingLevel)); headingPrefix = "#".repeat(level) + " "; } else if (step.heading_level.default) { // Fallback to default if not set const level = Math.max(1, Math.min(6, step.heading_level.default)); headingPrefix = "#".repeat(level) + " "; } } // WP-26: Block-ID-Generierung // Für Loop-Items kann die Block-ID bereits nummeriert sein (z.B. "action_heading-1") let blockId: string | null = null; if (step.block_id) { blockId = step.block_id; } else if (step.generate_block_id) { blockId = slugify(step.key); } // WP-26: Heading mit Block-ID erweitern (wenn heading_prefix vorhanden) let heading = ""; if (headingPrefix) { heading = headingPrefix + text; if (blockId) { // Füge Block-ID zu Heading hinzu heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`); } } // WP-26: Section-Type-Callout generieren (direkt nach Heading) const sectionTypeCallout = step.section_type ? `> [!section] ${step.section_type}` : ""; // WP-26: Section-Info für Tracking (nur wenn Heading vorhanden) let sectionInfo: SectionInfo | null = null; if (headingPrefix && blockId) { sectionInfo = { stepKey: step.key, sectionType: step.section_type || null, heading: heading || text, blockId: blockId, noteType: context.options.noteType || context.profile.note_type, }; // WP-26: Block-ID tracken (auch wenn nummeriert für Loop-Items) context.generatedBlockIds.set(blockId, sectionInfo); // WP-26: Auch Basis-Block-ID tracken (für Referenzen ohne Index) // Z.B. "action_heading-1" wird auch als "action_heading" getrackt const baseBlockId = blockId.replace(/-\d+$/, ""); if (baseBlockId !== blockId && !context.generatedBlockIds.has(baseBlockId)) { context.generatedBlockIds.set(baseBlockId, sectionInfo); } // WP-26: Section-Sequenz aktualisieren context.sectionSequence.push(sectionInfo); } // WP-26: Referenzen generieren (Forward-Edges zu anderen Sections) const references = sectionInfo ? renderReferences(step.references || [], context, sectionInfo) : ""; // WP-26: Automatische Forward-Edges generieren (prevSection -> currentSection) const forwardEdges = sectionInfo ? renderForwardEdges(sectionInfo, context) : ""; // Use template if provided if (step.output?.template) { const templateText = renderTemplate(step.output.template, { text: headingPrefix + text, field: step.key, value: headingPrefix + text, heading_level: headingLevel ? String(headingLevel) : "", }); // WP-26: Template mit Heading, Section-Type und Edges kombinieren (wenn Heading vorhanden) if (headingPrefix) { // Backward-Edges werden später im zweiten Durchlauf hinzugefügt return combineSectionParts(heading, sectionTypeCallout, "", references, forwardEdges, ""); } return templateText; } // WP-26: Wenn Heading vorhanden, mit Section-Type und Edges kombinieren if (headingPrefix) { // Backward-Edges werden später im zweiten Durchlauf hinzugefügt return combineSectionParts(heading, sectionTypeCallout, "", references, forwardEdges, ""); } // Default: text with heading prefix if configured return headingPrefix + text; } /** * Render entity_picker step. */ function renderEntityPicker(step: EntityPickerStep, answers: RenderAnswers): string | null { const basename = answers.collectedData.get(step.key); if (!basename || typeof basename !== "string" || !basename.trim()) { return null; } // Check if there's a label field let label: string | undefined; if (step.labelField) { const labelValue = answers.collectedData.get(step.labelField); if (labelValue && typeof labelValue === "string" && labelValue.trim()) { label = labelValue.trim(); } } // Render wikilink if (label) { return `[[${basename}|${label}]]`; } else { return `[[${basename}]]`; } } /** * Render capture_frontmatter step. * Note: This is typically handled separately, but included for completeness. */ function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderAnswers): string | null { const value = answers.collectedData.get(step.key); if (value === undefined || value === null || String(value).trim() === "") { return null; } // Use template if provided if (step.output?.template) { return renderTemplate(step.output.template, { text: String(value), field: step.field, value: String(value), }); } // Default: no markdown output (frontmatter is handled separately) return null; } /** * Render loop step (recursive, supports arbitrary nesting depth). * WP-26: Erweitert um RenderContext für Section-Types und Block-IDs. */ function renderLoop(step: LoopStep, answers: RenderAnswers, context: RenderContext): string | null { return renderLoopRecursive(step, answers, context, 0); } /** * Recursive helper to render loop items with nested steps. * Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size). * WP-26: Erweitert um RenderContext für Section-Types und Block-IDs. */ function renderLoopRecursive( step: LoopStep, answers: RenderAnswers, context: RenderContext, depth: number ): string | null { // Safety check: prevent infinite recursion (practical limit: 100 levels) if (depth > 100) { console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`); return null; } const items = answers.loopContexts.get(step.key); if (!items || items.length === 0) { return null; } const itemOutputs: string[] = []; // WP-26: Tracke Item-Index für eindeutige Block-IDs let itemIndex = 0; for (const item of items) { if (!item || typeof item !== "object") { continue; } itemIndex++; const itemParts: string[] = []; // Render each nested step for this item (recursively) for (const nestedStep of step.items) { const fieldValue = (item as Record)[nestedStep.key]; if (nestedStep.type === "capture_text") { if (fieldValue && String(fieldValue).trim()) { const text = String(fieldValue); const captureStep = nestedStep as CaptureTextStep; // WP-26: Erstelle temporären Step mit nummerierter Block-ID für Loop-Items const loopStepWithBlockId: CaptureTextStep = { ...captureStep, block_id: captureStep.generate_block_id ? `${slugify(captureStep.key)}-${itemIndex}` : captureStep.block_id, }; // WP-26: Verwende erweiterte renderCaptureText Funktion const rendered = renderCaptureText(loopStepWithBlockId, { collectedData: new Map([[nestedStep.key, fieldValue]]), loopContexts: answers.loopContexts, }, context); if (rendered) { itemParts.push(rendered); } } } else if (nestedStep.type === "capture_text_line") { if (fieldValue && String(fieldValue).trim()) { const captureStep = nestedStep as CaptureTextLineStep; // WP-26: Erstelle temporäres collectedData mit allen Item-Daten const itemData = new Map(); itemData.set(nestedStep.key, fieldValue); // Füge heading_level hinzu falls vorhanden const headingLevelKey = `${nestedStep.key}_heading_level`; const headingLevel = (item as Record)[headingLevelKey]; if (headingLevel !== undefined) { itemData.set(headingLevelKey, headingLevel); } // WP-26: Erstelle temporären Step mit nummerierter Block-ID für Loop-Items const loopStepWithBlockId: CaptureTextLineStep = { ...captureStep, block_id: captureStep.generate_block_id ? `${slugify(captureStep.key)}-${itemIndex}` : captureStep.block_id, }; // WP-26: Verwende erweiterte renderCaptureTextLine Funktion const rendered = renderCaptureTextLine(loopStepWithBlockId, { collectedData: itemData, loopContexts: answers.loopContexts, }, context); if (rendered) { itemParts.push(rendered); } } } else if (nestedStep.type === "capture_frontmatter") { if (fieldValue && String(fieldValue).trim()) { const captureStep = nestedStep as CaptureFrontmatterStep; // Use template if provided if (captureStep.output?.template) { itemParts.push(renderTemplate(captureStep.output.template, { text: String(fieldValue), field: captureStep.field, value: String(fieldValue), })); } } } else if (nestedStep.type === "loop") { // Recursively handle nested loop: fieldValue should be an array of items if (Array.isArray(fieldValue) && fieldValue.length > 0) { const nestedLoopStep = nestedStep as LoopStep; // Create a temporary answers object for the nested loop // The nested loop items are stored in fieldValue, so we need to create // a temporary loopContexts map for the nested loop const nestedAnswers: RenderAnswers = { collectedData: answers.collectedData, loopContexts: new Map(answers.loopContexts), }; nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue); // Recursively render the nested loop const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, context, depth + 1); if (nestedOutput) { itemParts.push(nestedOutput); } } } } if (itemParts.length > 0) { itemOutputs.push(itemParts.join("\n\n")); } } if (itemOutputs.length === 0) { return null; } // Check if first nested step has a section header (for backwards compatibility) const firstNestedStep = step.items[0]; let sectionHeader: string | null = null; if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) { sectionHeader = firstNestedStep.section; } // Join items with configured separator or default newline const joinStr = step.output?.join || "\n\n"; const loopContent = itemOutputs.join(joinStr); // Prepend section header if available if (sectionHeader) { return `${sectionHeader}\n\n${loopContent}`; } return loopContent; } /** * Render template string with token replacement. * Tokens: {text}, {field}, {value} */ function renderTemplate(template: string, tokens: { text: string; field: string; value: string; heading_level?: string }): string { let result = template .replace(/\{text\}/g, tokens.text) .replace(/\{field\}/g, tokens.field) .replace(/\{value\}/g, tokens.value); if (tokens.heading_level !== undefined) { result = result.replace(/\{heading_level\}/g, tokens.heading_level); } return result; } // WP-26: Helper-Funktionen für Section-Types und Block-IDs /** * Kombiniert Heading, Section-Type-Callout, Content und Edges zu einer Section. * Formatierungsregel: Section-Type direkt nach Heading, Edges im abstract wrapper am Ende. */ function combineSectionParts( heading: string, sectionTypeCallout: string, content: string, references: string, autoEdges: string, backwardEdges: string = "" ): string { const parts: string[] = []; if (heading) { parts.push(heading); } if (sectionTypeCallout) { parts.push(sectionTypeCallout); } if (content) { parts.push(content); } // WP-26: Alle Edges im abstract wrapper am Ende der Sektion const allEdges = [references, autoEdges, backwardEdges].filter(e => e.trim()); if (allEdges.length > 0) { const abstractWrapper = buildAbstractWrapper(allEdges.join("\n")); if (abstractWrapper) { parts.push(abstractWrapper); } } return parts.join("\n\n"); } /** * Erstellt einen abstract wrapper für Edges. * Format: > [!abstract]- 🕸️ Semantic Mapping\n>> [!edge] type\n>> [[link]] */ function buildAbstractWrapper(edgesContent: string): string | null { if (!edgesContent.trim()) { return null; } // Parse edges aus dem Content const edgeLines = edgesContent.split("\n").filter(line => line.trim()); const edgeGroups = new Map(); let currentEdgeType: string | null = null; for (const line of edgeLines) { // Prüfe auf Edge-Type: > [!edge] type const edgeMatch = line.match(/^>\s*\[!edge\]\s+(.+)$/); if (edgeMatch && edgeMatch[1]) { currentEdgeType = edgeMatch[1].trim(); if (!edgeGroups.has(currentEdgeType)) { edgeGroups.set(currentEdgeType, []); } } else { // Prüfe auf Link: > [[#^block-id]] const linkMatch = line.match(/^>\s*(\[\[#\^.+?\]\])$/); if (linkMatch && linkMatch[1] && currentEdgeType) { const links = edgeGroups.get(currentEdgeType) || []; links.push(linkMatch[1]); edgeGroups.set(currentEdgeType, links); } } } if (edgeGroups.size === 0) { return null; } // Build abstract wrapper const wrapperLines: string[] = []; wrapperLines.push(`> [!abstract]- 🕸️ Semantic Mapping`); // Sort edge types alphabetically const sortedEdgeTypes = Array.from(edgeGroups.keys()).sort(); for (let i = 0; i < sortedEdgeTypes.length; i++) { const edgeType = sortedEdgeTypes[i]; if (!edgeType) continue; const links = edgeGroups.get(edgeType) || []; if (links.length === 0) { continue; } // Add separator between groups (except before first) if (i > 0) { wrapperLines.push(">"); } // Edge header wrapperLines.push(`>> [!edge] ${edgeType}`); // Links (sorted for determinism) const sortedLinks = [...links].sort(); for (const link of sortedLinks) { wrapperLines.push(`>> ${link}`); } } // Add block-id marker wrapperLines.push(`> ^map-${Date.now()}`); return wrapperLines.join("\n"); } /** * Generiert Edge-Callouts für explizite Referenzen. * Format: > [!edge] \n> [[#^block-id]] * * WP-26: Unterstützt auch Basis-Block-IDs für Loop-Items (z.B. "action_heading" wird zu "action_heading-1", "action_heading-2" aufgelöst) */ function renderReferences( references: Array<{ block_id: string; edge_type?: string }>, context: RenderContext, currentSection: SectionInfo ): string { if (!references || references.length === 0) { return ""; } const edgeCallouts: string[] = []; for (const ref of references) { // WP-26: Auflösung der Block-ID (für Loop-Items mit Basis-Block-ID) let resolvedBlockId = ref.block_id; // Prüfe, ob Block-ID direkt existiert if (!context.generatedBlockIds.has(ref.block_id)) { // Versuche, Block-ID mit Index aufzulösen (für Loop-Items) // Suche nach Block-IDs, die mit der Basis-Block-ID beginnen let foundBlockId: string | null = null; for (const [blockId] of context.generatedBlockIds.entries()) { // Prüfe, ob Block-ID mit Basis-Block-ID beginnt (z.B. "action_heading-1", "action_heading-2") if (blockId.startsWith(`${ref.block_id}-`)) { // Verwende die neueste gefundene Block-ID (höchster Index) if (!foundBlockId || blockId > foundBlockId) { foundBlockId = blockId; } } } if (foundBlockId) { resolvedBlockId = foundBlockId; } else { console.warn(`[WP-26] Block-ID "${ref.block_id}" nicht gefunden für Referenz`); continue; } } // WP-26: Prüfe auf Selbstreferenz if (resolvedBlockId === currentSection.blockId) { console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${resolvedBlockId}" zeigt auf sich selbst`); continue; } const edgeType = ref.edge_type || "related_to"; edgeCallouts.push(`> [!edge] ${edgeType}`); edgeCallouts.push(`> [[#^${resolvedBlockId}]]`); } return edgeCallouts.join("\n"); } /** * Ermittelt den effektiven Section-Type basierend auf Heading-Level. * Wenn eine Section keinen expliziten Type hat, wird der Type der vorherigen Section * auf dem gleichen oder höheren Level verwendet, sonst Note-Type. */ function getEffectiveSectionType( section: SectionInfo, index: number, sectionSequence: SectionInfo[] ): string { // Wenn expliziter Section-Type vorhanden, verwende diesen if (section.sectionType) { return section.sectionType; } // Extrahiere Heading-Level aus der Überschrift const headingMatch = section.heading.match(/^(#{1,6})\s+/); const currentLevel = headingMatch ? headingMatch[1]?.length || 0 : 0; // Suche rückwärts nach der letzten Section mit explizitem Type auf gleichem oder höherem Level for (let i = index - 1; i >= 0; i--) { const prevSection = sectionSequence[i]; if (!prevSection) continue; const prevHeadingMatch = prevSection.heading.match(/^(#{1,6})\s+/); const prevLevel = prevHeadingMatch ? prevHeadingMatch[1]?.length || 0 : 0; // Wenn vorherige Section auf gleichem oder höherem Level einen expliziten Type hat if (prevLevel <= currentLevel && prevSection.sectionType) { return prevSection.sectionType; } // Wenn wir auf ein höheres Level stoßen, stoppe die Suche if (prevLevel < currentLevel) { break; } } // Fallback: Note-Type return section.noteType; } /** * Generiert automatische Forward-Edges zwischen Sections. * Forward-Edge: prevSection -> currentSection (in aktueller Section) * Generiert Edges zu ALLEN vorherigen Sections (nicht nur zur direkt vorherigen). * Verwendet graph_schema.md für typische Edge-Types. * * Spezifikation: Alle Sections haben Edges zu allen anderen Sections. * Edge-Types werden aus graph_schema.md abgeleitet. */ function renderForwardEdges( currentSection: SectionInfo, context: RenderContext ): string { // Nur wenn aktuelle Section eine Block-ID hat if (!currentSection.blockId) { return ""; } // Finde Index der aktuellen Section in der Sequenz const currentIndex = context.sectionSequence.findIndex( s => s.blockId === currentSection.blockId && s.stepKey === currentSection.stepKey ); if (currentIndex === -1 || currentIndex === 0) { return ""; // Keine vorherigen Sections } // ALLE vorherigen Sections (nicht nur die direkt vorherige) const prevSections = context.sectionSequence.slice(0, currentIndex); const edgeCallouts: string[] = []; const { graphSchema } = context.options; for (let prevIdx = 0; prevIdx < prevSections.length; prevIdx++) { const prevSection = prevSections[prevIdx]; // Nur wenn vorherige Section auch eine Block-ID hat if (!prevSection || !prevSection.blockId) { continue; } // WP-26: Prüfe auf Selbstreferenz if (prevSection.blockId === currentSection.blockId) { console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${prevSection.blockId}" zeigt auf sich selbst`); continue; } // WP-26: Auflösung der Block-ID für Loop-Items // Prüfe zuerst, ob die Block-ID direkt existiert let resolvedPrevBlockId = prevSection.blockId; if (!context.generatedBlockIds.has(prevSection.blockId)) { // Versuche, Block-ID mit Index aufzulösen (für Loop-Items) // Suche nach Block-IDs, die mit der Basis-Block-ID beginnen let foundBlockId: string | null = null; for (const [blockId] of context.generatedBlockIds.entries()) { if (blockId.startsWith(`${prevSection.blockId}-`)) { // Verwende die neueste gefundene Block-ID (höchster Index) if (!foundBlockId || blockId > foundBlockId) { foundBlockId = blockId; } } } if (foundBlockId) { resolvedPrevBlockId = foundBlockId; } } // WP-26: Prüfe zuerst, ob ein manuell geänderter Edge-Type vorhanden ist let forwardEdgeType: string | null = null; if (context.options.sectionEdgeTypes) { const fromBlockId = prevSection.blockId; const toBlockId = currentSection.blockId; if (fromBlockId && toBlockId) { const fromMap = context.options.sectionEdgeTypes.get(fromBlockId); if (fromMap) { forwardEdgeType = fromMap.get(toBlockId) || null; if (forwardEdgeType) { console.log(`[WP-26] Verwende manuell geänderten Edge-Type: ${fromBlockId} -> ${toBlockId} = ${forwardEdgeType}`); } } } } // Falls kein manuell geänderter Edge-Type vorhanden, verwende graph_schema if (!forwardEdgeType) { // WP-26: Ermittle effective_types mit Heading-Level-basierter Fallback-Logik const prevType = getEffectiveSectionType(prevSection, prevIdx, context.sectionSequence); const currentType = getEffectiveSectionType(currentSection, currentIndex, context.sectionSequence); // Lookup in graph_schema.md: prevType -> currentType if (graphSchema && prevType && currentType) { const hints = getHints(graphSchema, prevType, currentType); if (hints.typical.length > 0) { forwardEdgeType = hints.typical[0] || null; } else { console.debug(`[WP-26] Keine typischen Edges gefunden für ${prevType} -> ${currentType}, verwende Fallback`); } } // Fallback: related_to für experience -> experience, references für andere if (!forwardEdgeType) { if (prevType === currentType && prevType === "experience") { forwardEdgeType = "related_to"; } else { forwardEdgeType = "references"; } } } // WP-26: Forward-Edge generieren (prevSection -> currentSection) // Diese Edge wird in der aktuellen Section eingefügt edgeCallouts.push(`> [!edge] ${forwardEdgeType}`); edgeCallouts.push(`> [[#^${resolvedPrevBlockId}]]`); } return edgeCallouts.join("\n"); } /** * Generiert eine einzelne Backward-Edge zwischen zwei Sections. * Backward-Edge: currentSection -> prevSection (wird in prevSection eingefügt) * * Wenn Section A -> Section B (Forward-Edge), dann wird Backward-Edge (B -> A) in Section A eingefügt. */ function renderSingleBackwardEdge( currentSection: SectionInfo, prevSection: SectionInfo, currentIndex: number, prevIndex: number, context: RenderContext ): string { // Nur wenn beide Sections Block-IDs haben if (!currentSection.blockId || !prevSection.blockId) { return ""; } // WP-26: Prüfe auf Selbstreferenz if (prevSection.blockId === currentSection.blockId) { return ""; } const { graphSchema, vocabulary } = context.options; // WP-26: Prüfe zuerst, ob ein manuell geänderter Edge-Type vorhanden ist let forwardEdgeType: string | null = null; if (context.options.sectionEdgeTypes) { const fromBlockId = prevSection.blockId; const toBlockId = currentSection.blockId; if (fromBlockId && toBlockId) { const fromMap = context.options.sectionEdgeTypes.get(fromBlockId); if (fromMap) { forwardEdgeType = fromMap.get(toBlockId) || null; if (forwardEdgeType) { console.log(`[WP-26] Verwende manuell geänderten Edge-Type für Backward-Edge: ${fromBlockId} -> ${toBlockId} = ${forwardEdgeType}`); } } } } // Falls kein manuell geänderter Edge-Type vorhanden, verwende graph_schema if (!forwardEdgeType) { // WP-26: Ermittle effective_types mit Heading-Level-basierter Fallback-Logik const prevType = getEffectiveSectionType(prevSection, prevIndex, context.sectionSequence); const currentType = getEffectiveSectionType(currentSection, currentIndex, context.sectionSequence); // Lookup in graph_schema.md: prevType -> currentType (Forward-Edge-Type) if (graphSchema && prevType && currentType) { const hints = getHints(graphSchema, prevType, currentType); if (hints.typical.length > 0) { forwardEdgeType = hints.typical[0] || null; } } // Fallback: related_to if (!forwardEdgeType) { forwardEdgeType = "related_to"; } } // WP-26: Backward-Edge generieren (currentSection -> prevSection) // Das ist die inverse Edge zu der Forward-Edge (prevSection -> currentSection) let backwardEdgeType: string = forwardEdgeType; if (vocabulary) { const inverse = vocabulary.getInverse(forwardEdgeType); if (inverse) { backwardEdgeType = inverse; } } // WP-26: Backward-Edge (currentSection -> prevSection) wird in prevSection eingefügt // Diese Edge zeigt von currentSection zurück zu prevSection return `> [!edge] ${backwardEdgeType}\n> [[#^${currentSection.blockId}]]`; } /** * Fügt Edges zu einem bestehenden abstract wrapper hinzu oder erstellt einen neuen. */ function addEdgesToAbstractWrapper(content: string, newEdges: string): string { if (!newEdges.trim()) { return content; } // Prüfe, ob bereits ein abstract wrapper vorhanden ist const abstractWrapperMatch = content.match(/(> \[!abstract\][^\n]*\n(?:>>?[^\n]*\n)*> \^map-\d+)/); if (abstractWrapperMatch && abstractWrapperMatch[1]) { // Füge neue Edges zum bestehenden wrapper hinzu const existingWrapper = abstractWrapperMatch[1]; const updatedWrapper = mergeEdgesIntoWrapper(existingWrapper, newEdges); if (abstractWrapperMatch[0]) { return content.replace(abstractWrapperMatch[0], updatedWrapper); } } { // Erstelle neuen abstract wrapper const abstractWrapper = buildAbstractWrapper(newEdges); if (abstractWrapper) { return content.trimEnd() + "\n\n" + abstractWrapper; } return content; } } /** * Merged neue Edges in einen bestehenden abstract wrapper. */ function mergeEdgesIntoWrapper(existingWrapper: string, newEdges: string): string { // Parse bestehenden wrapper const lines = existingWrapper.split("\n"); const wrapperHeader = lines[0] || "> [!abstract]- 🕸️ Semantic Mapping"; const mapMarker = lines[lines.length - 1] || `> ^map-${Date.now()}`; // Parse bestehende Edges const existingGroups = new Map(); let currentEdgeType: string | null = null; for (let i = 1; i < lines.length - 1; i++) { const line = lines[i]; if (!line) continue; const edgeMatch = line.match(/^>>\s*\[!edge\]\s+(.+)$/); if (edgeMatch && edgeMatch[1]) { currentEdgeType = edgeMatch[1].trim(); if (!existingGroups.has(currentEdgeType)) { existingGroups.set(currentEdgeType, []); } } else { const linkMatch = line.match(/^>>\s*(\[\[#\^.+?\]\])$/); if (linkMatch && linkMatch[1] && currentEdgeType) { const links = existingGroups.get(currentEdgeType) || []; links.push(linkMatch[1]); existingGroups.set(currentEdgeType, links); } } } // Parse neue Edges const newEdgeLines = newEdges.split("\n").filter(line => line.trim()); for (const line of newEdgeLines) { const edgeMatch = line.match(/^>\s*\[!edge\]\s+(.+)$/); if (edgeMatch && edgeMatch[1]) { currentEdgeType = edgeMatch[1].trim(); if (!existingGroups.has(currentEdgeType)) { existingGroups.set(currentEdgeType, []); } } else { const linkMatch = line.match(/^>\s*(\[\[#\^.+?\]\])$/); if (linkMatch && linkMatch[1] && currentEdgeType) { const links = existingGroups.get(currentEdgeType) || []; // Prüfe auf Duplikate if (!links.includes(linkMatch[1])) { links.push(linkMatch[1]); } existingGroups.set(currentEdgeType, links); } } } // Build merged wrapper const wrapperLines: string[] = []; wrapperLines.push(wrapperHeader); const sortedEdgeTypes = Array.from(existingGroups.keys()).sort(); for (let i = 0; i < sortedEdgeTypes.length; i++) { const edgeType = sortedEdgeTypes[i]; if (!edgeType) continue; const links = existingGroups.get(edgeType) || []; if (links.length === 0) { continue; } if (i > 0) { wrapperLines.push(">"); } wrapperLines.push(`>> [!edge] ${edgeType}`); const sortedLinks = [...links].sort(); for (const link of sortedLinks) { wrapperLines.push(`>> ${link}`); } } wrapperLines.push(mapMarker); return wrapperLines.join("\n"); }