diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts index 97bda82..34b7f2c 100644 --- a/src/interview/parseInterviewConfig.ts +++ b/src/interview/parseInterviewConfig.ts @@ -320,6 +320,40 @@ function parseStep(raw: Record): InterviewStep | null { step.prompt = raw.prompt.trim(); } + // WP-26: Parse section_type + if (typeof raw.section_type === "string" && raw.section_type.trim()) { + step.section_type = raw.section_type.trim(); + } + + // WP-26: Parse block_id + if (typeof raw.block_id === "string" && raw.block_id.trim()) { + step.block_id = raw.block_id.trim(); + } + + // WP-26: Parse generate_block_id + if (typeof raw.generate_block_id === "boolean") { + step.generate_block_id = raw.generate_block_id; + } + + // WP-26: Parse references + if (Array.isArray(raw.references)) { + step.references = []; + for (const refRaw of raw.references) { + if (refRaw && typeof refRaw === "object") { + const ref = refRaw as Record; + if (typeof ref.block_id === "string" && ref.block_id.trim()) { + const reference: { block_id: string; edge_type?: string } = { + block_id: ref.block_id.trim(), + }; + if (typeof ref.edge_type === "string" && ref.edge_type.trim()) { + reference.edge_type = ref.edge_type.trim(); + } + step.references.push(reference); + } + } + } + } + // Parse output.template if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; @@ -363,6 +397,40 @@ function parseStep(raw: Record): InterviewStep | null { }; } + // WP-26: Parse section_type + if (typeof raw.section_type === "string" && raw.section_type.trim()) { + step.section_type = raw.section_type.trim(); + } + + // WP-26: Parse block_id + if (typeof raw.block_id === "string" && raw.block_id.trim()) { + step.block_id = raw.block_id.trim(); + } + + // WP-26: Parse generate_block_id + if (typeof raw.generate_block_id === "boolean") { + step.generate_block_id = raw.generate_block_id; + } + + // WP-26: Parse references + if (Array.isArray(raw.references)) { + step.references = []; + for (const refRaw of raw.references) { + if (refRaw && typeof refRaw === "object") { + const ref = refRaw as Record; + if (typeof ref.block_id === "string" && ref.block_id.trim()) { + const reference: { block_id: string; edge_type?: string } = { + block_id: ref.block_id.trim(), + }; + if (typeof ref.edge_type === "string" && ref.edge_type.trim()) { + reference.edge_type = ref.edge_type.trim(); + } + step.references.push(reference); + } + } + } + } + // Parse output.template if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; diff --git a/src/interview/renderer.test.ts b/src/interview/renderer.test.ts new file mode 100644 index 0000000..6879e7a --- /dev/null +++ b/src/interview/renderer.test.ts @@ -0,0 +1,305 @@ +/** + * Tests für WP-26 Interview-Wizard Renderer + * Prüft Section-Types, Block-IDs, automatische Edge-Vorschläge und Selbstreferenz-Prüfung + */ + +import { describe, it, expect } from "vitest"; +import { renderProfileToMarkdown, type RenderAnswers, type RenderOptions } from "./renderer"; +import type { InterviewProfile } from "./types"; +import type { GraphSchema, EdgeTypeHints } from "../mapping/graphSchema"; +import { Vocabulary } from "../vocab/Vocabulary"; +import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; + +describe("WP-26 Interview Renderer", () => { + // Mock Vocabulary + const mockVocabularyText = `| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung | +|------------------------|--------------|-------------------------|-------------| +| resulted_in | resulted_from| führt zu, bewirkt | Forward edge | +| resulted_from | resulted_in | stammt aus, kommt von | Inverse edge | +| related_to | related_to | verbunden mit | Bidirectional |`; + + const mockVocabulary = new Vocabulary(parseEdgeVocabulary(mockVocabularyText)); + + // Mock GraphSchema + const mockGraphSchema: GraphSchema = { + schema: new Map>([ + [ + "experience", + new Map([ + [ + "insight", + { + typical: ["resulted_in"], + prohibited: [], + }, + ], + ]), + ], + ]), + }; + + const mockOptions: RenderOptions = { + graphSchema: mockGraphSchema, + vocabulary: mockVocabulary, + noteType: "experience", + }; + + it("sollte keine Selbstreferenz bei automatischen Edges generieren", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "context", + section: "## Kontext", + section_type: "experience", + generate_block_id: true, + }, + { + type: "capture_text", + key: "insight", + section: "## Einsicht", + section_type: "insight", + generate_block_id: true, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["context", "Kontext-Text"], + ["insight", "Einsicht-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Prüfe, dass keine Selbstreferenz vorhanden ist + const contextSection = result.match(/## Kontext.*?## Einsicht/s)?.[0]; + expect(contextSection).toBeDefined(); + + // Prüfe, dass keine Edge auf sich selbst zeigt + const selfReference = contextSection?.match(/\[\[#\^context\]\]/); + expect(selfReference).toBeNull(); + + // Prüfe, dass Edges generiert wurden + const edges = result.match(/> \[!edge\]/g); + expect(edges?.length).toBeGreaterThan(0); + }); + + it("sollte Edge-Types aus graph_schema verwenden", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "experience", + section: "## Erfahrung", + section_type: "experience", + generate_block_id: true, + }, + { + type: "capture_text", + key: "insight", + section: "## Einsicht", + section_type: "insight", + generate_block_id: true, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["experience", "Erfahrungs-Text"], + ["insight", "Einsicht-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Debug: Zeige das Ergebnis + console.log("Render-Ergebnis:", result); + + // Prüfe, dass Edges generiert wurden + const edges = result.match(/> \[!edge\]/g); + expect(edges?.length).toBeGreaterThan(0); + + // Prüfe, dass "resulted_in" oder "resulted_from" verwendet wird (aus graph_schema) + // Falls graph_schema nicht verfügbar ist, wird "related_to" verwendet (Fallback) + const hasResultedEdge = result.match(/resulted_(in|from)/); + const hasRelatedEdge = result.match(/related_to/); + + // Mindestens eine Edge sollte vorhanden sein + expect(hasResultedEdge || hasRelatedEdge).toBeTruthy(); + + // Wenn graph_schema verfügbar ist, sollte "resulted_in" oder "resulted_from" verwendet werden + if (mockOptions.graphSchema) { + expect(hasResultedEdge).toBeTruthy(); + } + }); + + it("sollte Backlinks automatisch generieren", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "context", + section: "## Kontext", + section_type: "experience", + generate_block_id: true, + }, + { + type: "capture_text", + key: "insight", + section: "## Einsicht", + section_type: "insight", + generate_block_id: true, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["context", "Kontext-Text"], + ["insight", "Einsicht-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Prüfe, dass beide Edge-Types vorhanden sind (Forward und Backward) + const forwardEdge = result.match(/resulted_in/); + const backwardEdge = result.match(/resulted_from/); + + expect(forwardEdge).toBeDefined(); + expect(backwardEdge).toBeDefined(); + }); + + it("sollte keine Selbstreferenz bei expliziten Referenzen erlauben", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "context", + section: "## Kontext", + section_type: "experience", + block_id: "context", + references: [ + { + block_id: "context", // Selbstreferenz - sollte verhindert werden + edge_type: "related_to", + }, + ], + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["context", "Kontext-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Prüfe, dass keine Selbstreferenz generiert wurde + const selfReference = result.match(/\[\[#\^context\]\]/); + // Sollte null sein, da Selbstreferenz verhindert wird + expect(selfReference).toBeNull(); + }); + + it("sollte Section-Type-Callout direkt nach Heading platzieren", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "context", + section: "## Kontext", + section_type: "experience", + generate_block_id: true, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["context", "Kontext-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Prüfe, dass Section-Type-Callout direkt nach Heading steht + const sectionPattern = /## Kontext.*?\n> \[!section\] experience/s; + expect(result).toMatch(sectionPattern); + }); + + it("sollte Edges am Ende der Sektion platzieren", () => { + const profile: InterviewProfile = { + version: "2.0", + frontmatterWhitelist: [], + key: "test", + label: "Test", + note_type: "experience", + steps: [ + { + type: "capture_text", + key: "context", + section: "## Kontext", + section_type: "experience", + generate_block_id: true, + }, + { + type: "capture_text", + key: "insight", + section: "## Einsicht", + section_type: "insight", + generate_block_id: true, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["context", "Kontext-Text"], + ["insight", "Einsicht-Text"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers, mockOptions); + + // Prüfe, dass Edges am Ende der Einsicht-Sektion stehen (nach dem Text) + const insightSection = result.match(/## Einsicht.*?Einsicht-Text\n\n(> \[!edge\].*)/s)?.[1]; + expect(insightSection).toBeDefined(); + expect(insightSection).toMatch(/> \[!edge\]/); + }); +}); diff --git a/src/interview/renderer.ts b/src/interview/renderer.ts index 6073205..0a87738 100644 --- a/src/interview/renderer.ts +++ b/src/interview/renderer.ts @@ -1,27 +1,68 @@ /** * 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 + 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); + const stepOutput = renderStep( + step, + answers, + { + profile, + sectionSequence, + generatedBlockIds, + options: options || {}, + } + ); if (stepOutput) { output.push(stepOutput); } @@ -30,20 +71,33 @@ export function renderProfileToMarkdown( 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): string | null { +function renderStep( + step: InterviewStep, + answers: RenderAnswers, + context: RenderContext +): string | null { switch (step.type) { case "capture_text": - return renderCaptureText(step, answers); + return renderCaptureText(step, answers, context); case "capture_text_line": - return renderCaptureTextLine(step, answers); + return renderCaptureTextLine(step, answers, context); case "capture_frontmatter": // Frontmatter is handled separately, skip here return null; case "loop": - return renderLoop(step, answers); + return renderLoop(step, answers, context); case "entity_picker": return renderEntityPicker(step, answers); case "instruction": @@ -58,8 +112,13 @@ function renderStep(step: InterviewStep, answers: RenderAnswers): string | null /** * Render capture_text step. + * WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge. */ -function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): string | null { +function renderCaptureText( + step: CaptureTextStep, + answers: RenderAnswers, + context: RenderContext +): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; @@ -67,18 +126,66 @@ function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): strin 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) { - return renderTemplate(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 `${step.section}\n\n${text}`; + return combineSectionParts(heading, sectionTypeCallout, text, references, autoEdges); } return text; @@ -86,8 +193,13 @@ function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): strin /** * Render capture_text_line step. + * WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge. */ -function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers): string | null { +function renderCaptureTextLine( + step: CaptureTextLineStep, + answers: RenderAnswers, + context: RenderContext +): string | null { const value = answers.collectedData.get(step.key); if (!value || String(value).trim() === "") { return null; @@ -110,14 +222,98 @@ function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers } } + // 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) { - return renderTemplate(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 @@ -175,16 +371,23 @@ function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderA /** * 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): string | null { - return renderLoopRecursive(step, answers, 0); +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, depth: number): string | null { +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`); @@ -214,49 +417,39 @@ function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: numb const text = String(fieldValue); const captureStep = nestedStep as CaptureTextStep; - // Use template if provided - if (captureStep.output?.template) { - itemParts.push(renderTemplate(captureStep.output.template, { - text, - field: nestedStep.key, - value: text, - })); - } else { - // Default: just the text - itemParts.push(text); + // 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 text = String(fieldValue); const captureStep = nestedStep as CaptureTextLineStep; - // Get heading level if configured (for nested loops, check in item data) + // 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]; - let headingPrefix = ""; - if (captureStep.heading_level?.enabled) { - if (typeof headingLevel === "number") { - const level = Math.max(1, Math.min(6, headingLevel)); - headingPrefix = "#".repeat(level) + " "; - } else if (captureStep.heading_level.default) { - // Fallback to default if not set in item - const level = Math.max(1, Math.min(6, captureStep.heading_level.default)); - headingPrefix = "#".repeat(level) + " "; - } + if (headingLevel !== undefined) { + itemData.set(headingLevelKey, headingLevel); } - // Use template if provided - if (captureStep.output?.template) { - itemParts.push(renderTemplate(captureStep.output.template, { - text: headingPrefix + text, - field: nestedStep.key, - value: headingPrefix + text, - heading_level: headingLevel ? String(headingLevel) : (captureStep.heading_level?.default ? String(captureStep.heading_level.default) : ""), - })); - } else { - // Default: text with heading prefix if configured - itemParts.push(headingPrefix + text); + // 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") { @@ -287,7 +480,7 @@ function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: numb nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue); // Recursively render the nested loop - const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, depth + 1); + const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, context, depth + 1); if (nestedOutput) { itemParts.push(nestedOutput); } @@ -339,3 +532,166 @@ function renderTemplate(template: string, tokens: { text: string; field: string; 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"); +} diff --git a/src/interview/types.ts b/src/interview/types.ts index 3fe6847..dfaecf4 100644 --- a/src/interview/types.ts +++ b/src/interview/types.ts @@ -76,6 +76,14 @@ export interface CaptureTextStep { required?: boolean; section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse") prompt?: string; // Optional prompt text + // WP-26: Section-Type und Block-ID Support + section_type?: string; // Optional: Section-Type (z.B. "experience", "insight") + block_id?: string; // Optional: Explizite Block-ID (z.B. "sit") + generate_block_id?: boolean; // Optional: Automatische Block-ID-Generierung aus Step-Key + references?: Array<{ // Optional: Referenzen zu vorherigen Sections + block_id: string; // Block-ID der referenzierten Section + edge_type?: string; // Optional: Vorgeschlagener Edge-Type + }>; output?: { template?: string; // Template with tokens: {text}, {field}, {value} }; @@ -91,6 +99,14 @@ export interface CaptureTextLineStep { enabled?: boolean; // Show heading level selector (default: false) default?: number; // Default heading level 1-6 (default: 2) }; + // WP-26: Section-Type und Block-ID Support + section_type?: string; // Optional: Section-Type (z.B. "experience", "insight") + block_id?: string; // Optional: Explizite Block-ID (z.B. "sit") + generate_block_id?: boolean; // Optional: Automatische Block-ID-Generierung aus Step-Key + references?: Array<{ // Optional: Referenzen zu vorherigen Sections + block_id: string; // Block-ID der referenzierten Section + edge_type?: string; // Optional: Vorgeschlagener Edge-Type + }>; output?: { template?: string; // Template with tokens: {text}, {field}, {value}, {heading_level} }; diff --git a/src/interview/wizardState.ts b/src/interview/wizardState.ts index e71e47f..2067661 100644 --- a/src/interview/wizardState.ts +++ b/src/interview/wizardState.ts @@ -50,6 +50,9 @@ export function createWizardState(profile: InterviewProfile): WizardState { patches: [], activeLoopPath: [], // Start at top level pendingEdgeAssignments: [], // Start with empty pending assignments + // WP-26: Initialize Section-Type und Block-ID Tracking + generatedBlockIds: new Map(), + sectionSequence: [], }; } diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 7d36167..7ba19c4 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -26,7 +26,11 @@ import { createMarkdownToolbar, } from "./markdownToolbar"; import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "../mapping/edgeTypeSelector"; -import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; +import { renderProfileToMarkdown, type RenderAnswers, type RenderOptions } from "../interview/renderer"; +import type { GraphSchema } from "../mapping/graphSchema"; +import { Vocabulary } from "../vocab/Vocabulary"; +import type { SectionInfo } from "../interview/wizardState"; +import { slugify } from "../interview/slugify"; import { NoteIndex } from "../entityPicker/noteIndex"; import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"; import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink"; @@ -176,8 +180,18 @@ export class InterviewWizardModal extends Modal { stepKey: step?.key || "null", stepLabel: step?.label || "null", totalSteps: flattenSteps(this.state.profile.steps).length, + sectionSequenceLength: this.state.sectionSequence.length, + generatedBlockIdsCount: this.state.generatedBlockIds.size, }); + // WP-26: Track Section-Info während des Wizard-Durchlaufs + if (step) { + console.log(`[WP-26] trackSectionInfo wird aufgerufen für Step ${step.key}, type: ${step.type}`); + this.trackSectionInfo(step); + } else { + console.log(`[WP-26] Kein Step gefunden, trackSectionInfo wird nicht aufgerufen`); + } + if (!step) { // Check if we're at the end legitimately or if there's an error const steps = flattenSteps(this.state.profile.steps); @@ -415,10 +429,30 @@ export class InterviewWizardModal extends Modal { valuePreview: value.substring(0, 50) + (value.length > 50 ? "..." : ""), }); + // WP-26: Speichere Fokus-Info vor State-Update, um Fokus-Verlust zu vermeiden + let hadFocus = false; + let selectionStart = 0; + let selectionEnd = 0; + if (document.activeElement === textareaRef) { + hadFocus = true; + selectionStart = textareaRef.selectionStart; + selectionEnd = textareaRef.selectionEnd; + } + // Update stored value this.currentInputValues.set(step.key, value); this.state.collectedData.set(step.key, value); + // WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war + if (hadFocus && textareaRef) { + setTimeout(() => { + if (textareaRef && document.body.contains(textareaRef)) { + textareaRef.focus(); + textareaRef.setSelectionRange(selectionStart, selectionEnd); + } + }, 0); + } + // Update preview if in preview mode if (isPreviewMode) { this.updatePreview(previewContainer, value); @@ -469,44 +503,158 @@ export class InterviewWizardModal extends Modal { backToEditWrapper.style.display = "none"; } }, - (app: App) => { - // Open entity picker modal - if (!this.noteIndex) { - new Notice("Note index not available"); - return; - } - new EntityPickerModal( - app, - this.noteIndex, - async (result: EntityPickerResult) => { - // Check if inline micro edging is enabled (also for toolbar) - // Support: inline_micro, both (inline_micro + post_run) - const edgingMode = this.profile.edging?.mode; - const shouldRunInlineMicro = - (edgingMode === "inline_micro" || edgingMode === "both") && - this.settings?.inlineMicroEnabled !== false; - - let linkText = `[[${result.basename}]]`; - - if (shouldRunInlineMicro) { - // Get current step for section key resolution - const currentStep = getCurrentStep(this.state); - if (currentStep) { - console.log("[Mindnet] Starting inline micro edging from toolbar"); - const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); - if (edgeType && typeof edgeType === "string") { - // Use [[rel:type|link]] format - linkText = `[[rel:${edgeType}|${result.basename}]]`; + async (app: App) => { + // WP-26: Erweitere Entity-Picker um Block-ID-Vorschläge für Intra-Note-Links + // Zeige zuerst Block-ID-Auswahl für Intra-Note-Links, dann normale Note-Auswahl + const blockIds = Array.from(this.state.generatedBlockIds.keys()); + + if (blockIds.length > 0) { + // Zeige Block-ID-Auswahl-Modal + const blockIdModal = new Modal(app); + blockIdModal.titleEl.textContent = "Block-ID oder Note auswählen"; + blockIdModal.contentEl.createEl("p", { + text: "Wähle eine Block-ID für Intra-Note-Link oder eine Note:", + }); + + // Block-ID-Liste + const blockIdList = blockIdModal.contentEl.createEl("div"); + blockIdList.style.display = "flex"; + blockIdList.style.flexDirection = "column"; + blockIdList.style.gap = "0.5em"; + blockIdList.style.marginBottom = "1em"; + + for (const blockId of blockIds) { + const sectionInfo = this.state.generatedBlockIds.get(blockId); + const btn = blockIdList.createEl("button", { + text: `#^${blockId} - ${sectionInfo?.heading || blockId}`, + }); + btn.style.width = "100%"; + btn.style.textAlign = "left"; + btn.style.padding = "0.5em"; + btn.onclick = async () => { + blockIdModal.close(); + + // Erstelle Block-ID-Link + const blockIdLink = `#^${blockId}`; + + // Prüfe, ob inline micro edging aktiviert ist + const edgingMode = this.profile.edging?.mode; + const shouldRunInlineMicro = + (edgingMode === "inline_micro" || edgingMode === "both") && + this.settings?.inlineMicroEnabled !== false; + + let linkText = `[[${blockIdLink}]]`; + + if (shouldRunInlineMicro) { + // Get current step for section key resolution + const currentStep = getCurrentStep(this.state); + if (currentStep) { + console.log("[WP-26] Starting inline micro edging for Block-ID:", blockId); + const edgeType = await this.handleInlineMicroEdging(currentStep, blockIdLink, ""); + if (edgeType && typeof edgeType === "string") { + // Use [[rel:type|#^block-id]] format + linkText = `[[rel:${edgeType}|${blockIdLink}]]`; + } } } - } - - // Insert link with rel: prefix if edge type was selected - // Extract inner part (without [[ and ]]) - const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); - insertWikilinkIntoTextarea(textarea, innerLink); + + // Insert link + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + insertWikilinkIntoTextarea(textarea, innerLink); + }; } - ).open(); + + // Separator + blockIdModal.contentEl.createEl("hr"); + + // Button für normale Note-Auswahl + const noteBtn = blockIdModal.contentEl.createEl("button", { + text: "📄 Note auswählen...", + cls: "mod-cta", + }); + noteBtn.style.width = "100%"; + noteBtn.style.marginTop = "1em"; + noteBtn.onclick = () => { + blockIdModal.close(); + // Öffne normalen Entity-Picker + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + new EntityPickerModal( + app, + this.noteIndex, + async (result: EntityPickerResult) => { + // Check if inline micro edging is enabled (also for toolbar) + // Support: inline_micro, both (inline_micro + post_run) + const edgingMode = this.profile.edging?.mode; + const shouldRunInlineMicro = + (edgingMode === "inline_micro" || edgingMode === "both") && + this.settings?.inlineMicroEnabled !== false; + + let linkText = `[[${result.basename}]]`; + + if (shouldRunInlineMicro) { + // Get current step for section key resolution + const currentStep = getCurrentStep(this.state); + if (currentStep) { + console.log("[Mindnet] Starting inline micro edging from toolbar"); + const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); + if (edgeType && typeof edgeType === "string") { + // Use [[rel:type|link]] format + linkText = `[[rel:${edgeType}|${result.basename}]]`; + } + } + } + + // Insert link with rel: prefix if edge type was selected + // Extract inner part (without [[ and ]]) + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + insertWikilinkIntoTextarea(textarea, innerLink); + } + ).open(); + }; + + blockIdModal.open(); + } else { + // Keine Block-IDs vorhanden, öffne direkt Entity-Picker + if (!this.noteIndex) { + new Notice("Note index not available"); + return; + } + new EntityPickerModal( + app, + this.noteIndex, + async (result: EntityPickerResult) => { + // Check if inline micro edging is enabled (also for toolbar) + // Support: inline_micro, both (inline_micro + post_run) + const edgingMode = this.profile.edging?.mode; + const shouldRunInlineMicro = + (edgingMode === "inline_micro" || edgingMode === "both") && + this.settings?.inlineMicroEnabled !== false; + + let linkText = `[[${result.basename}]]`; + + if (shouldRunInlineMicro) { + // Get current step for section key resolution + const currentStep = getCurrentStep(this.state); + if (currentStep) { + console.log("[Mindnet] Starting inline micro edging from toolbar"); + const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); + if (edgeType && typeof edgeType === "string") { + // Use [[rel:type|link]] format + linkText = `[[rel:${edgeType}|${result.basename}]]`; + } + } + } + + // Insert link with rel: prefix if edge type was selected + // Extract inner part (without [[ and ]]) + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + insertWikilinkIntoTextarea(textarea, innerLink); + } + ).open(); + } }, async (app: App, textarea: HTMLTextAreaElement) => { // Edge-Type-Selektor für Interview-Eingabefeld @@ -973,15 +1121,30 @@ export class InterviewWizardModal extends Modal { if (typeof item === "object" && item !== null) { const itemEntries = Object.entries(item as Record); if (itemEntries.length > 0) { - const previewText = itemEntries - .map(([key, value]) => { - const nestedStep = step.items.find(s => s.key === key); - const label = nestedStep?.label || key; - const strValue = String(value); - return `${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}`; - }) - .join(", "); - preview.createSpan({ text: previewText }); + const previewParts: string[] = []; + for (const [key, value] of itemEntries) { + const nestedStep = step.items.find(s => s.key === key); + const label = nestedStep?.label || key; + const strValue = String(value); + + // WP-26: Zeige Block-ID wenn vorhanden + let blockIdDisplay = ""; + if (nestedStep && (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line")) { + const captureStep = nestedStep as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; + let blockId: string | null = null; + if (captureStep.block_id) { + blockId = captureStep.block_id; + } else if (captureStep.generate_block_id) { + blockId = slugify(`${step.key}-${captureStep.key}`); + } + if (blockId) { + blockIdDisplay = ` ^${blockId}`; + } + } + + previewParts.push(`${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}${blockIdDisplay}`); + } + preview.createSpan({ text: previewParts.join(", ") }); } else { preview.createSpan({ text: "Empty item" }); } @@ -1093,6 +1256,8 @@ export class InterviewWizardModal extends Modal { if (currentState) { const newState = setDraftField(currentState, fieldId, value); this.state.loopRuntimeStates.set(step.key, newState); + // WP-26: NICHT renderStep() hier aufrufen, um Fokus-Verlust zu vermeiden + // Das Re-Rendering wird nur bei expliziten Navigationen durchgeführt } }); @@ -1134,6 +1299,12 @@ export class InterviewWizardModal extends Modal { itemNextBtn.onclick = () => { const currentState = this.state.loopRuntimeStates.get(step.key); if (currentState) { + // WP-26: Track Section-Info für aktuelle nested Steps bevor Navigation + const activeNestedStep = step.items[activeStepIndex]; + if (activeNestedStep) { + this.trackSectionInfoForLoopItem(activeNestedStep, step.key, currentState.draft); + } + const newState = itemNextStep(currentState, step.items.length); this.state.loopRuntimeStates.set(step.key, newState); this.renderStep(); @@ -1180,6 +1351,14 @@ export class InterviewWizardModal extends Modal { if (isDraftDirty(currentState.draft)) { const wasNewItem = currentState.editIndex === null; + + // WP-26: Track Section-Info für alle nested Steps bevor Commit + for (const nestedStep of step.items) { + if (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line") { + this.trackSectionInfoForLoopItem(nestedStep, step.key, currentState.draft); + } + } + const newState = commitDraft(currentState); this.state.loopRuntimeStates.set(step.key, newState); // Update answers @@ -1208,6 +1387,8 @@ export class InterviewWizardModal extends Modal { } } + // WP-26: Nach dem Speichern bleibt der Loop-Step aktiv (kein automatischer Wechsel zum nächsten Step) + // Der Benutzer kann weitere Items hinzufügen oder mit "Next" zum nächsten Step navigieren this.renderStep(); } else { new Notice("Please enter at least one field"); @@ -1267,6 +1448,28 @@ export class InterviewWizardModal extends Modal { }); } + // WP-26: Block-ID-Anzeige für capture_text mit section + const captureStep = nestedStep as import("../interview/types").CaptureTextStep; + if (captureStep.section) { + let blockId: string | null = null; + if (captureStep.block_id) { + blockId = captureStep.block_id; + } else if (captureStep.generate_block_id) { + blockId = slugify(`${loopKey}-${captureStep.key}`); + } + + if (blockId) { + const blockIdDisplay = fieldContainer.createEl("div", { + cls: "block-id-display", + text: `Block-ID: ^${blockId}`, + }); + blockIdDisplay.style.fontSize = "0.85em"; + blockIdDisplay.style.color = "var(--text-muted)"; + blockIdDisplay.style.marginBottom = "0.5em"; + blockIdDisplay.style.fontStyle = "italic"; + } + } + // Container for editor/preview const editorContainer = fieldContainer.createEl("div", { cls: "markdown-editor-container", @@ -1338,8 +1541,32 @@ export class InterviewWizardModal extends Modal { textSetting.addTextArea((text) => { textareaRef = text.inputEl; text.setValue(existingValue); + + // WP-26: Speichere Fokus-Info vor onChange, um Fokus-Verlust zu vermeiden + let hadFocus = false; + let selectionStart = 0; + let selectionEnd = 0; + text.onChange((value) => { + // WP-26: Speichere Fokus-Info vor State-Update + if (document.activeElement === textareaRef) { + hadFocus = true; + selectionStart = textareaRef.selectionStart; + selectionEnd = textareaRef.selectionEnd; + } + onFieldChange(nestedStep.key, value); + + // WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war + if (hadFocus && textareaRef) { + setTimeout(() => { + if (textareaRef && document.body.contains(textareaRef)) { + textareaRef.focus(); + textareaRef.setSelectionRange(selectionStart, selectionEnd); + } + }, 0); + } + // Update preview if in preview mode const currentPreviewMode = this.previewMode.get(previewKey) || false; if (currentPreviewMode) { @@ -1515,6 +1742,29 @@ export class InterviewWizardModal extends Modal { settingNameEl.style.display = "none"; } + // WP-26: Block-ID-Anzeige für capture_text_line mit heading_level + let blockIdDisplay: HTMLElement | null = null; + if (nestedStep.heading_level?.enabled) { + const captureStep = nestedStep as import("../interview/types").CaptureTextLineStep; + let blockId: string | null = null; + if (captureStep.block_id) { + blockId = captureStep.block_id; + } else if (captureStep.generate_block_id) { + blockId = slugify(`${loopKey}-${captureStep.key}`); + } + + if (blockId) { + blockIdDisplay = fieldSetting.settingEl.createEl("div", { + cls: "block-id-display", + text: `Block-ID: ^${blockId}`, + }); + blockIdDisplay.style.fontSize = "0.85em"; + blockIdDisplay.style.color = "var(--text-muted)"; + blockIdDisplay.style.marginTop = "0.25em"; + blockIdDisplay.style.fontStyle = "italic"; + } + } + fieldSetting.addText((text) => { text.setValue(existingValue); text.onChange((value) => { @@ -2416,38 +2666,86 @@ export class InterviewWizardModal extends Modal { graphSchema = await this.plugin.ensureGraphSchemaLoaded(); } - // Get source note ID and type - const sourceContent = this.fileContent; - const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); - const sourceNoteId = extractFrontmatterId(sourceContent) || undefined; - const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/); + // WP-26: Prüfe, ob es eine Block-ID-Referenz ist ([[#^block-id]]) let sourceType: string | undefined; - if (sourceFrontmatter && sourceFrontmatter[1]) { - const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m); - if (typeMatch && typeMatch[1]) { - sourceType = typeMatch[1].trim(); - } - } - - // Get target note ID and type - let targetNoteId: string | undefined; let targetType: string | undefined; - try { - const targetFile = this.app.vault.getAbstractFileByPath(linkPath); - if (targetFile && targetFile instanceof TFile) { - const targetContent = await this.app.vault.read(targetFile); - targetNoteId = extractFrontmatterId(targetContent) || undefined; - const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/); - if (targetFrontmatter && targetFrontmatter[1]) { - const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m); - if (typeMatch && typeMatch[1]) { - targetType = typeMatch[1].trim(); - } + let sourceNoteId: string | undefined; + let targetNoteId: string | undefined; + + const blockIdMatch = linkBasename.match(/^#\^(.+)$/); + if (blockIdMatch && blockIdMatch[1]) { + // Intra-Note-Edge: Block-ID-Referenz + const blockId = blockIdMatch[1]; + console.log(`[WP-26] Block-ID-Referenz erkannt: ${blockId}`); + + // Finde Source-Section (aktuelle Section aus Step) + const currentStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; + let currentBlockId: string | null = null; + if (currentStep.block_id) { + currentBlockId = currentStep.block_id; + } else if (currentStep.generate_block_id) { + currentBlockId = slugify(currentStep.key); + } + + if (currentBlockId) { + const sourceSection = this.state.generatedBlockIds.get(currentBlockId); + if (sourceSection) { + sourceType = sourceSection.sectionType || sourceSection.noteType; + console.log(`[WP-26] Source-Type aus Section: ${sourceType}`); + } + } else { + // Fallback: Verwende Note-Type + sourceType = this.state.profile.note_type; + } + + // Finde Target-Section (Block-ID aus generatedBlockIds) + const targetSection = this.state.generatedBlockIds.get(blockId); + if (targetSection) { + targetType = targetSection.sectionType || targetSection.noteType; + console.log(`[WP-26] Target-Type aus Section: ${targetType}`); + } else { + console.warn(`[WP-26] Block-ID "${blockId}" nicht in generatedBlockIds gefunden`); + // Fallback: Verwende Note-Type + targetType = this.state.profile.note_type; + } + + // Für Intra-Note-Edges: sourceNoteId = targetNoteId (gleiche Note) + const sourceContent = this.fileContent; + const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); + sourceNoteId = extractFrontmatterId(sourceContent) || undefined; + targetNoteId = sourceNoteId; // Gleiche Note + } else { + // Inter-Note-Edge: Normale Wikilink-Referenz + // Get source note ID and type + const sourceContent = this.fileContent; + const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); + sourceNoteId = extractFrontmatterId(sourceContent) || undefined; + const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/); + if (sourceFrontmatter && sourceFrontmatter[1]) { + const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m); + if (typeMatch && typeMatch[1]) { + sourceType = typeMatch[1].trim(); } } - } catch (e) { - // Target note might not exist yet, that's OK - console.debug("[Mindnet] Could not read target note for inline micro:", e); + + // Get target note ID and type + try { + const targetFile = this.app.vault.getAbstractFileByPath(linkPath); + if (targetFile && targetFile instanceof TFile) { + const targetContent = await this.app.vault.read(targetFile); + targetNoteId = extractFrontmatterId(targetContent) || undefined; + const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/); + if (targetFrontmatter && targetFrontmatter[1]) { + const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m); + if (typeMatch && typeMatch[1]) { + targetType = typeMatch[1].trim(); + } + } + } + } catch (e) { + // Target note might not exist yet, that's OK + console.debug("[Mindnet] Could not read target note for inline micro:", e); + } } // Show inline edge type modal @@ -2794,13 +3092,47 @@ export class InterviewWizardModal extends Modal { this.state.loopContexts.set(loopKey, loopState.items); } + // WP-26: Lade Vocabulary und GraphSchema für automatische Edge-Vorschläge + let vocabulary: Vocabulary | null = null; + let graphSchema: GraphSchema | null = null; + + try { + // Lade Vocabulary + if (!this.vocabulary && this.settings) { + const vocabText = await VocabularyLoader.loadText( + this.app, + this.settings.edgeVocabularyPath + ); + this.vocabulary = parseEdgeVocabulary(vocabText); + } + if (this.vocabulary) { + vocabulary = new Vocabulary(this.vocabulary); + } + + // Lade GraphSchema + if (this.plugin?.ensureGraphSchemaLoaded) { + graphSchema = await this.plugin.ensureGraphSchemaLoaded(); + } + } catch (e) { + console.warn("[WP-26] Fehler beim Laden von Vocabulary/GraphSchema für Renderer:", e); + // Continue without vocabulary/schema - Renderer funktioniert auch ohne + } + // Use renderer to generate markdown from collected data const answers: RenderAnswers = { collectedData: this.state.collectedData, loopContexts: this.state.loopContexts, + sectionSequence: Array.from(this.state.sectionSequence), // WP-26: Section-Sequenz übergeben }; - const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers); + // WP-26: Render-Optionen für Section-Types und Edge-Vorschläge + const renderOptions: RenderOptions = { + graphSchema: graphSchema, + vocabulary: vocabulary, + noteType: this.state.profile.note_type, + }; + + const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers, renderOptions); if (renderedMarkdown.trim()) { // Append rendered markdown to file @@ -2878,6 +3210,183 @@ export class InterviewWizardModal extends Modal { return JSON.stringify(value); } + /** + * WP-26: Track Section-Info für Loop-Items. + * Wird aufgerufen, wenn ein Loop-Item gespeichert wird oder zwischen Steps navigiert wird. + */ + private trackSectionInfoForLoopItem( + nestedStep: InterviewStep, + loopKey: string, + draft: Record + ): void { + // Nur für capture_text und capture_text_line Steps + if (nestedStep.type !== "capture_text" && nestedStep.type !== "capture_text_line") { + return; + } + + const captureStep = nestedStep as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; + + // Für capture_text: Nur wenn Section vorhanden ist + if (nestedStep.type === "capture_text" && !captureStep.section) { + return; + } + + // Für capture_text_line: Nur wenn heading_level enabled ist + if (nestedStep.type === "capture_text_line" && !captureStep.heading_level?.enabled) { + return; + } + + // WP-26: Block-ID ermitteln + let blockId: string | null = null; + if (captureStep.block_id) { + blockId = captureStep.block_id; + } else if (captureStep.generate_block_id) { + // Für Loop-Items: Verwende Loop-Key + Step-Key für eindeutige Block-ID + blockId = slugify(`${loopKey}-${captureStep.key}`); + } + + // WP-26: Section-Type ermitteln + const sectionType = captureStep.section_type || null; + const noteType = this.state.profile.note_type; + + // WP-26: Heading-Text ermitteln + let heading = ""; + if (nestedStep.type === "capture_text" && captureStep.section) { + // Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext") + heading = captureStep.section.replace(/^#+\s+/, ""); + } else if (nestedStep.type === "capture_text_line") { + // Für capture_text_line: Heading aus Draft-Wert + const draftValue = draft[captureStep.key]; + if (draftValue && typeof draftValue === "string") { + heading = draftValue; + } else { + heading = captureStep.key; + } + } + + // WP-26: Section-Info erstellen + const sectionInfo: SectionInfo = { + stepKey: `${loopKey}.${captureStep.key}`, // Eindeutiger Key für Loop-Items + sectionType: sectionType, + heading: heading, + blockId: blockId, + noteType: noteType, + }; + + // WP-26: Block-ID tracken (nur wenn vorhanden) + if (blockId) { + this.state.generatedBlockIds.set(blockId, sectionInfo); + console.log(`[WP-26] Block-ID für Loop-Item getrackt: ${blockId} für Step ${loopKey}.${captureStep.key}`); + } + + // WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden) + // Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate) + const existingIndex = this.state.sectionSequence.findIndex( + s => s.stepKey === sectionInfo.stepKey && s.blockId === blockId + ); + + if (existingIndex === -1) { + // Neue Section hinzufügen + this.state.sectionSequence.push(sectionInfo); + console.log(`[WP-26] Section für Loop-Item zur Sequenz hinzugefügt: ${sectionInfo.stepKey} (Block-ID: ${blockId || "keine"})`); + } else { + // Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat) + this.state.sectionSequence[existingIndex] = sectionInfo; + console.log(`[WP-26] Section für Loop-Item aktualisiert: ${sectionInfo.stepKey}`); + } + } + + /** + * WP-26: Track Section-Info während des Wizard-Durchlaufs. + * Wird aufgerufen, wenn ein Step mit section/section_type gerendert wird. + */ + private trackSectionInfo(step: InterviewStep): void { + // Nur für capture_text und capture_text_line Steps mit section + if (step.type !== "capture_text" && step.type !== "capture_text_line") { + return; + } + + const captureStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; + + console.log(`[WP-26] trackSectionInfo aufgerufen für Step ${step.key}`, { + type: step.type, + hasSection: !!captureStep.section, + section: captureStep.section, + hasSectionType: !!captureStep.section_type, + sectionType: captureStep.section_type, + hasBlockId: !!captureStep.block_id, + blockId: captureStep.block_id, + generateBlockId: captureStep.generate_block_id, + hasHeadingLevel: !!captureStep.heading_level?.enabled, + }); + + // Nur wenn Section vorhanden ist + if (!captureStep.section && step.type === "capture_text") { + console.log(`[WP-26] Step ${step.key} hat keine section, überspringe`); + return; + } + + // Für capture_text_line: Nur wenn heading_level enabled ist + if (step.type === "capture_text_line" && !captureStep.heading_level?.enabled) { + console.log(`[WP-26] Step ${step.key} hat kein heading_level enabled, überspringe`); + return; + } + + // WP-26: Block-ID ermitteln + let blockId: string | null = null; + if (captureStep.block_id) { + blockId = captureStep.block_id; + } else if (captureStep.generate_block_id) { + blockId = slugify(captureStep.key); + } + + // WP-26: Section-Type ermitteln + const sectionType = captureStep.section_type || null; + const noteType = this.state.profile.note_type; + + // WP-26: Heading-Text ermitteln + let heading = ""; + if (step.type === "capture_text" && captureStep.section) { + // Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext") + heading = captureStep.section.replace(/^#+\s+/, ""); + } else if (step.type === "capture_text_line") { + // Für capture_text_line: Heading wird später aus dem eingegebenen Text generiert + // Verwende Step-Key als Platzhalter + heading = captureStep.key; + } + + // WP-26: Section-Info erstellen + const sectionInfo: SectionInfo = { + stepKey: captureStep.key, + sectionType: sectionType, + heading: heading, + blockId: blockId, + noteType: noteType, + }; + + // WP-26: Block-ID tracken (nur wenn vorhanden) + if (blockId) { + this.state.generatedBlockIds.set(blockId, sectionInfo); + console.log(`[WP-26] Block-ID getrackt: ${blockId} für Step ${captureStep.key}`); + } + + // WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden) + // Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate) + const existingIndex = this.state.sectionSequence.findIndex( + s => s.stepKey === captureStep.key && s.blockId === blockId + ); + + if (existingIndex === -1) { + // Neue Section hinzufügen + this.state.sectionSequence.push(sectionInfo); + console.log(`[WP-26] Section zur Sequenz hinzugefügt: ${captureStep.key} (Block-ID: ${blockId || "keine"})`); + } else { + // Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat) + this.state.sectionSequence[existingIndex] = sectionInfo; + console.log(`[WP-26] Section aktualisiert: ${captureStep.key}`); + } + } + onClose(): void { const { contentEl } = this; contentEl.empty();