/** * 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 } /** * 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 { const output: 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(); // 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); } } } // WP-26: Track Section-Sequenz während des Renderns (für Steps, die noch nicht getrackt wurden) for (const step of profile.steps) { const stepOutput = renderStep( step, answers, { profile, sectionSequence, generatedBlockIds, options: options || {}, } ); if (stepOutput) { output.push(stepOutput); } } return output.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); // 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); } // 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: Section-Sequenz aktualisieren (nur wenn Section vorhanden) if (step.section) { context.sectionSequence.push(sectionInfo); } // WP-26: Referenzen generieren (am Ende der Sektion) const references = renderReferences(step.references || [], context, sectionInfo); // WP-26: Automatische Edge-Vorschläge generieren (am Ende der Sektion) const autoEdges = renderAutomaticEdges(sectionInfo, context); // 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, autoEdges); } // Default: use section if provided, otherwise just the text if (step.section) { return combineSectionParts(heading, sectionTypeCallout, text, references, autoEdges); } 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 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) if (headingPrefix) { const sectionInfo: 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 if (blockId) { context.generatedBlockIds.set(blockId, sectionInfo); } // WP-26: Section-Sequenz aktualisieren context.sectionSequence.push(sectionInfo); } // WP-26: Section-Info für Tracking (nur wenn Heading vorhanden) let sectionInfo: SectionInfo | null = null; if (headingPrefix) { 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 if (blockId) { context.generatedBlockIds.set(blockId, sectionInfo); } // WP-26: Section-Sequenz aktualisieren context.sectionSequence.push(sectionInfo); } // WP-26: Referenzen generieren (am Ende der Sektion) const references = sectionInfo ? renderReferences(step.references || [], context, sectionInfo) : ""; // WP-26: Automatische Edge-Vorschläge generieren (nur wenn Heading vorhanden) const autoEdges = sectionInfo ? renderAutomaticEdges(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) { return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges); } return templateText; } // WP-26: Wenn Heading vorhanden, mit Section-Type und Edges kombinieren if (headingPrefix) { return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges); } // 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[] = []; for (const item of items) { if (!item || typeof item !== "object") { continue; } 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: Verwende erweiterte renderCaptureText Funktion const rendered = renderCaptureText(captureStep, { 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: Verwende erweiterte renderCaptureTextLine Funktion const rendered = renderCaptureTextLine(captureStep, { 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 am Ende. */ function combineSectionParts( heading: string, sectionTypeCallout: string, content: string, references: string, autoEdges: string ): string { const parts: string[] = []; if (heading) { parts.push(heading); } if (sectionTypeCallout) { parts.push(sectionTypeCallout); } if (content) { parts.push(content); } // WP-26: Edges am Ende der Sektion (Referenzen + automatische Edges) const edges = [references, autoEdges].filter(e => e.trim()).join("\n"); if (edges) { parts.push(edges); } return parts.join("\n\n"); } /** * Generiert Edge-Callouts für explizite Referenzen. * Format: > [!edge] \n> [[#^block-id]] */ 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) { // Prüfe, ob Block-ID existiert if (!context.generatedBlockIds.has(ref.block_id)) { console.warn(`[WP-26] Block-ID "${ref.block_id}" nicht gefunden für Referenz`); continue; } // WP-26: Prüfe auf Selbstreferenz if (ref.block_id === currentSection.blockId) { console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${ref.block_id}" zeigt auf sich selbst`); continue; } const edgeType = ref.edge_type || "related_to"; edgeCallouts.push(`> [!edge] ${edgeType}`); edgeCallouts.push(`> [[#^${ref.block_id}]]`); } return edgeCallouts.join("\n"); } /** * Generiert automatische Edge-Vorschläge zwischen Sections. * Verwendet graph_schema.md für typische Edge-Types und edge_vocabulary.md für inverse Edges. * * Formatierungsregel: Edges werden am Ende der aktuellen Section eingefügt. * Forward-Edge: prevSection -> currentSection (in aktueller Section) * Rückwärts-Edge: currentSection -> prevSection (in aktueller Section, inverser Edge-Type) */ function renderAutomaticEdges( currentSection: SectionInfo, context: RenderContext ): string { // Nur wenn aktuelle Section eine Block-ID hat if (!currentSection.blockId) { return ""; } // Nur wenn es vorherige Sections gibt if (context.sectionSequence.length <= 1) { return ""; } // Aktuelle Section ist die letzte in der Sequenz // Wir generieren Edges von allen vorherigen Sections zur aktuellen Section const prevSections = context.sectionSequence.slice(0, -1); const edgeCallouts: string[] = []; const { graphSchema, vocabulary } = context.options; for (const prevSection of prevSections) { // Nur wenn vorherige Section auch eine Block-ID hat if (!prevSection.blockId) { continue; } // WP-26: Prüfe auf Selbstreferenz (sollte nicht vorkommen, aber sicherheitshalber prüfen) if (prevSection.blockId === currentSection.blockId) { console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${prevSection.blockId}" zeigt auf sich selbst`); continue; } // Ermittle effective_types const prevType = prevSection.sectionType || prevSection.noteType; const currentType = currentSection.sectionType || currentSection.noteType; // Lookup in graph_schema.md: prevType -> currentType let forwardEdgeType: string | null = null; if (graphSchema && prevType && currentType) { const hints = getHints(graphSchema, prevType, currentType); if (hints.typical.length > 0) { forwardEdgeType = hints.typical[0]; // Erster typischer Edge-Type aus graph_schema } else { // Debug: Log wenn keine typischen Edges gefunden wurden console.debug(`[WP-26] Keine typischen Edges gefunden für ${prevType} -> ${currentType}`); } } // Fallback: related_to if (!forwardEdgeType) { forwardEdgeType = "related_to"; } // WP-26: Automatische Edge-Vorschläge // Wir generieren beide Richtungen: // 1. Forward-Edge: prevSection -> currentSection (mit originalem Edge-Type) // 2. Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type) // // Beide Edges werden in der aktuellen Section eingefügt und zeigen zur prevSection // Der Backlink wird automatisch mitgesetzt // Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type) let inverseEdgeType: string | null = null; if (vocabulary) { inverseEdgeType = vocabulary.getInverse(forwardEdgeType); } // Generiere Rückwärts-Edge (currentSection -> prevSection) if (inverseEdgeType) { edgeCallouts.push(`> [!edge] ${inverseEdgeType}`); edgeCallouts.push(`> [[#^${prevSection.blockId}]]`); } // Generiere Forward-Edge als Backlink (prevSection -> currentSection) // Diese Edge beschreibt die Beziehung von prevSection zu currentSection edgeCallouts.push(`> [!edge] ${forwardEdgeType}`); edgeCallouts.push(`> [[#^${prevSection.blockId}]]`); } return edgeCallouts.join("\n"); }