diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts index 90d3f6d..e7b134d 100644 --- a/src/interview/parseInterviewConfig.ts +++ b/src/interview/parseInterviewConfig.ts @@ -175,6 +175,14 @@ function parseStep(raw: Record): InterviewStep | null { if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } + + // Parse output.join + if (raw.output && typeof raw.output === "object") { + const output = raw.output as Record; + if (typeof output.join === "string") { + step.output = { join: output.join }; + } + } for (const itemRaw of nestedSteps) { if (!itemRaw || typeof itemRaw !== "object") { @@ -246,6 +254,17 @@ function parseStep(raw: Record): InterviewStep | null { if (typeof raw.required === "boolean") { step.required = raw.required; } + if (typeof raw.prompt === "string" && raw.prompt.trim()) { + step.prompt = raw.prompt.trim(); + } + + // Parse output.template + if (raw.output && typeof raw.output === "object") { + const output = raw.output as Record; + if (typeof output.template === "string") { + step.output = { template: output.template }; + } + } return step; } @@ -273,6 +292,46 @@ function parseStep(raw: Record): InterviewStep | null { if (typeof raw.prompt === "string" && raw.prompt.trim()) { step.prompt = raw.prompt.trim(); } + + // Parse output.template + if (raw.output && typeof raw.output === "object") { + const output = raw.output as Record; + if (typeof output.template === "string") { + step.output = { template: output.template }; + } + } + + return step; + } + + if (type === "capture_text_line") { + const key = getKey(); + if (!key) { + return null; + } + + const step: InterviewStep = { + type: "capture_text_line", + key: key, + }; + + if (typeof raw.label === "string" && raw.label.trim()) { + step.label = raw.label.trim(); + } + if (typeof raw.required === "boolean") { + step.required = raw.required; + } + if (typeof raw.prompt === "string" && raw.prompt.trim()) { + step.prompt = raw.prompt.trim(); + } + + // Parse output.template + if (raw.output && typeof raw.output === "object") { + const output = raw.output as Record; + if (typeof output.template === "string") { + step.output = { template: output.template }; + } + } return step; } diff --git a/src/interview/renderer.ts b/src/interview/renderer.ts new file mode 100644 index 0000000..ab427ad --- /dev/null +++ b/src/interview/renderer.ts @@ -0,0 +1,241 @@ +/** + * Markdown renderer for interview profiles. + * Converts collected answers into markdown output based on profile configuration. + */ + +import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep } from "./types"; + +export interface RenderAnswers { + collectedData: Map; + loopContexts: Map; +} + +/** + * Render profile answers to markdown string. + * Deterministic and testable. + */ +export function renderProfileToMarkdown( + profile: InterviewProfile, + answers: RenderAnswers +): string { + const output: string[] = []; + + for (const step of profile.steps) { + const stepOutput = renderStep(step, answers); + if (stepOutput) { + output.push(stepOutput); + } + } + + return output.join("\n\n").trim(); +} + +/** + * Render a single step to markdown. + */ +function renderStep(step: InterviewStep, answers: RenderAnswers): string | null { + switch (step.type) { + case "capture_text": + return renderCaptureText(step, answers); + case "capture_text_line": + return renderCaptureTextLine(step, answers); + case "capture_frontmatter": + // Frontmatter is handled separately, skip here + return null; + case "loop": + return renderLoop(step, answers); + case "instruction": + case "llm_dialog": + case "review": + // These don't produce markdown output + return null; + default: + return null; + } +} + +/** + * Render capture_text step. + */ +function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): string | null { + const value = answers.collectedData.get(step.key); + if (!value || String(value).trim() === "") { + return null; + } + + const text = String(value); + + // Use template if provided + if (step.output?.template) { + return renderTemplate(step.output.template, { + text, + field: step.key, + value: text, + }); + } + + // Default: use section if provided, otherwise just the text + if (step.section) { + return `${step.section}\n\n${text}`; + } + + return text; +} + +/** + * Render capture_text_line step. + */ +function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers): string | null { + const value = answers.collectedData.get(step.key); + if (!value || String(value).trim() === "") { + return null; + } + + const text = String(value); + + // Use template if provided + if (step.output?.template) { + return renderTemplate(step.output.template, { + text, + field: step.key, + value: text, + }); + } + + // Default: just the text + return text; +} + +/** + * 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. + */ +function renderLoop(step: LoopStep, answers: RenderAnswers): string | 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 + 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; + + // 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); + } + } + } else if (nestedStep.type === "capture_text_line") { + if (fieldValue && String(fieldValue).trim()) { + const text = String(fieldValue); + const captureStep = nestedStep as CaptureTextLineStep; + + // 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); + } + } + } 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), + })); + } + } + } + } + + 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 }): string { + return template + .replace(/\{text\}/g, tokens.text) + .replace(/\{field\}/g, tokens.field) + .replace(/\{value\}/g, tokens.value); +} diff --git a/src/interview/types.ts b/src/interview/types.ts index 044b735..85bbc8a 100644 --- a/src/interview/types.ts +++ b/src/interview/types.ts @@ -22,6 +22,7 @@ export type InterviewStep = | LLMDialogStep | CaptureFrontmatterStep | CaptureTextStep + | CaptureTextLineStep | InstructionStep | ReviewStep; @@ -30,6 +31,9 @@ export interface LoopStep { key: string; label?: string; items: InterviewStep[]; + output?: { + join?: string; // String to join items (default: "\n\n") + }; } export interface LLMDialogStep { @@ -50,6 +54,9 @@ export interface CaptureFrontmatterStep { field: string; required?: boolean; prompt?: string; // Optional prompt text + output?: { + template?: string; // Template with tokens: {text}, {field}, {value} + }; } export interface CaptureTextStep { @@ -59,6 +66,20 @@ export interface CaptureTextStep { required?: boolean; section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse") prompt?: string; // Optional prompt text + output?: { + template?: string; // Template with tokens: {text}, {field}, {value} + }; +} + +export interface CaptureTextLineStep { + type: "capture_text_line"; + key: string; + label?: string; + required?: boolean; + prompt?: string; // Optional prompt text + output?: { + template?: string; // Template with tokens: {text}, {field}, {value} + }; } export interface InstructionStep { diff --git a/src/tests/interview/renderer.test.ts b/src/tests/interview/renderer.test.ts new file mode 100644 index 0000000..de98636 --- /dev/null +++ b/src/tests/interview/renderer.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect } from "vitest"; +import { renderProfileToMarkdown, type RenderAnswers } from "../../interview/renderer"; +import type { InterviewProfile } from "../../interview/types"; + +describe("renderer", () => { + describe("renderProfileToMarkdown", () => { + it("should render experience_cluster profile without generic labels", () => { + const profile: InterviewProfile = { + key: "experience_cluster", + label: "Experience Cluster", + note_type: "experience", + steps: [ + { + type: "loop", + key: "experiences", + items: [ + { + type: "capture_text", + key: "experience_text", + label: "Erlebnis", + section: "## 🧩 Erlebnisse", + }, + ], + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map(), + loopContexts: new Map([ + [ + "experiences", + [ + { experience_text: "Erstes wichtiges Erlebnis mit Details" }, + { experience_text: "Zweites Erlebnis mit mehr Kontext" }, + ], + ], + ]), + }; + + const result = renderProfileToMarkdown(profile, answers); + + // Should contain the section header and both experiences + expect(result).toContain("## 🧩 Erlebnisse"); + expect(result).toContain("Erstes wichtiges Erlebnis mit Details"); + expect(result).toContain("Zweites Erlebnis mit mehr Kontext"); + + // Should NOT contain generic labels + expect(result).not.toContain("## Items"); + expect(result).not.toContain("Überschrift 1"); + expect(result).not.toContain("Liste 1"); + expect(result).not.toContain("Erlebnis 1"); + expect(result).not.toContain("Erlebnis 2"); + }); + + it("should render experience_hub minimal case", () => { + const profile: InterviewProfile = { + key: "experience_hub", + label: "Experience Hub", + note_type: "experience", + steps: [ + { + type: "loop", + key: "items", + items: [ + { + type: "capture_text", + key: "item_text", + label: "Erlebnis", + }, + ], + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map(), + loopContexts: new Map([ + [ + "items", + [{ item_text: "Ein einzelnes Erlebnis" }], + ], + ]), + }; + + const result = renderProfileToMarkdown(profile, answers); + + // Should contain the experience text + expect(result).toContain("Ein einzelnes Erlebnis"); + + // Should NOT contain generic labels + expect(result).not.toContain("## Items"); + expect(result).not.toContain("Überschrift"); + expect(result).not.toContain("Liste"); + }); + + it("should use output.template for capture_text when provided", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "capture_text", + key: "custom_text", + label: "Custom Text", + output: { + template: "### {field}\n\n{text}", + }, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([["custom_text", "Mein Textinhalt"]]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).toContain("### custom_text"); + expect(result).toContain("Mein Textinhalt"); + }); + + it("should use output.join for loop when provided", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "loop", + key: "items", + items: [ + { + type: "capture_text", + key: "text", + label: "Text", + }, + ], + output: { + join: "\n- ", + }, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map(), + loopContexts: new Map([ + [ + "items", + [ + { text: "Item 1" }, + { text: "Item 2" }, + ], + ], + ]), + }; + + const result = renderProfileToMarkdown(profile, answers); + + // Should use custom join separator + expect(result).toContain("Item 1\n- Item 2"); + }); + + it("should skip empty values", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "capture_text", + key: "empty_text", + label: "Empty Text", + }, + { + type: "capture_text", + key: "filled_text", + label: "Filled Text", + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([ + ["empty_text", ""], + ["filled_text", "Content here"], + ]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).not.toContain("empty_text"); + expect(result).toContain("Content here"); + }); + + it("should handle section header for capture_text", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "capture_text", + key: "sectioned_text", + label: "Sectioned Text", + section: "## My Section", + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([["sectioned_text", "Content under section"]]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).toContain("## My Section"); + expect(result).toContain("Content under section"); + }); + + it("should render multiple loop items correctly", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "loop", + key: "multi_items", + items: [ + { + type: "capture_text", + key: "text", + label: "Text", + }, + ], + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map(), + loopContexts: new Map([ + [ + "multi_items", + [ + { text: "First item" }, + { text: "Second item" }, + { text: "Third item" }, + ], + ], + ]), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).toContain("First item"); + expect(result).toContain("Second item"); + expect(result).toContain("Third item"); + // Should not have item numbers or generic labels + expect(result).not.toMatch(/Item \d+/); + expect(result).not.toContain("## Items"); + }); + + it("should render capture_text_line step", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "capture_text_line", + key: "single_line", + label: "Single Line", + prompt: "Enter a single line of text", + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([["single_line", "This is a single line"]]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).toContain("This is a single line"); + }); + + it("should use output.template for capture_text_line when provided", () => { + const profile: InterviewProfile = { + key: "test_profile", + label: "Test Profile", + note_type: "test", + steps: [ + { + type: "capture_text_line", + key: "tagged_line", + label: "Tagged Line", + output: { + template: "- {text}", + }, + }, + ], + }; + + const answers: RenderAnswers = { + collectedData: new Map([["tagged_line", "My tag"]]), + loopContexts: new Map(), + }; + + const result = renderProfileToMarkdown(profile, answers); + + expect(result).toBe("- My tag"); + }); + }); +}); diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index f62774b..d465469 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -25,6 +25,7 @@ import { extractFrontmatterId } from "../parser/parseFrontmatter"; import { createMarkdownToolbar, } from "./markdownToolbar"; +import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; export interface WizardResult { applied: boolean; @@ -189,6 +190,9 @@ export class InterviewWizardModal extends Modal { case "capture_text": this.renderCaptureTextStep(step, stepContentEl); break; + case "capture_text_line": + this.renderCaptureTextLineStep(step, stepContentEl); + break; case "capture_frontmatter": this.renderCaptureFrontmatterStep(step, stepContentEl); break; @@ -372,6 +376,76 @@ export class InterviewWizardModal extends Modal { } } + renderCaptureTextLineStep(step: InterviewStep, containerEl: HTMLElement): void { + if (step.type !== "capture_text_line") return; + + const existingValue = + (this.state.collectedData.get(step.key) as string) || ""; + + console.log("Render capture_text_line step", { + stepKey: step.key, + stepLabel: step.label, + existingValue: existingValue, + }); + + // Field container with vertical layout + const fieldContainer = containerEl.createEl("div", { + cls: "mindnet-field", + }); + + // Label + if (step.label) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: step.label, + }); + } + + // Description/Prompt + if (step.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: step.prompt, + }); + } + + // Input container + const inputContainer = fieldContainer.createEl("div", { + cls: "mindnet-field__input", + }); + inputContainer.style.width = "100%"; + + const fieldSetting = new Setting(inputContainer); + fieldSetting.settingEl.style.width = "100%"; + fieldSetting.controlEl.style.width = "100%"; + + // Hide the default label from Setting component + const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; + if (settingNameEl) { + settingNameEl.style.display = "none"; + } + + fieldSetting.addText((text) => { + text.setValue(existingValue); + // Store initial value + this.currentInputValues.set(step.key, existingValue); + + text.onChange((value) => { + console.log("Text line field changed", { + stepKey: step.key, + value: value, + }); + + // Update stored value + this.currentInputValues.set(step.key, value); + this.state.collectedData.set(step.key, value); + }); + text.inputEl.style.width = "100%"; + text.inputEl.style.boxSizing = "border-box"; + text.inputEl.focus(); + }); + } + /** * Update preview container with rendered markdown. */ @@ -643,6 +717,58 @@ export class InterviewWizardModal extends Modal { textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); } }, 10); + } else if (nestedStep.type === "capture_text_line") { + const existingValue = (itemData.get(nestedStep.key) as string) || ""; + const inputKey = `${itemDataKey}_${nestedStep.key}`; + + // Field container with vertical layout + const fieldContainer = itemFormContainer.createEl("div", { + cls: "mindnet-field", + }); + + // Label + if (nestedStep.label) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: nestedStep.label, + }); + } + + // Description/Prompt + if (nestedStep.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: nestedStep.prompt, + }); + } + + // Input container + const inputContainer = fieldContainer.createEl("div", { + cls: "mindnet-field__input", + }); + inputContainer.style.width = "100%"; + + const fieldSetting = new Setting(inputContainer); + fieldSetting.settingEl.style.width = "100%"; + fieldSetting.controlEl.style.width = "100%"; + + // Hide the default label from Setting component + const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; + if (settingNameEl) { + settingNameEl.style.display = "none"; + } + + fieldSetting.addText((text) => { + text.setValue(existingValue); + this.currentInputValues.set(inputKey, existingValue); + + text.onChange((value) => { + this.currentInputValues.set(inputKey, value); + itemData.set(nestedStep.key, value); + }); + text.inputEl.style.width = "100%"; + text.inputEl.style.boxSizing = "border-box"; + }); } else if (nestedStep.type === "capture_frontmatter") { const existingValue = (itemData.get(nestedStep.key) as string) || ""; const inputKey = `${itemDataKey}_${nestedStep.key}`; @@ -1038,59 +1164,22 @@ export class InterviewWizardModal extends Modal { } } - // Apply loop data to content - for (const [loopKey, items] of this.state.loopContexts.entries()) { - if (items.length > 0) { - // Find the loop step to get section info - const loopStep = this.state.profile.steps.find(s => s.type === "loop" && s.key === loopKey); - if (loopStep && loopStep.type === "loop") { - // Get section from first nested step if available (check for section property in YAML) - const firstNestedStep = loopStep.items[0]; - let sectionHeader = "## Items"; - - // Check if nested step has section property - if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) { - sectionHeader = firstNestedStep.section; - } - - // Build content for loop items - const loopContent: string[] = []; - loopContent.push(sectionHeader); - loopContent.push(""); - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item && typeof item === "object") { - // Render each field from the item - for (const [fieldKey, fieldValue] of Object.entries(item)) { - if (fieldValue && String(fieldValue).trim()) { - // Find nested step to get label - const nestedStep = loopStep.items.find(s => s.key === fieldKey); - const label = nestedStep?.label || fieldKey; - - // For multiple items, add item number - if (items.length > 1) { - loopContent.push(`#### ${label} ${i + 1}`); - } - loopContent.push(String(fieldValue)); - loopContent.push(""); - } - } - } - } - - // Append loop content to file - const loopContentStr = loopContent.join("\n"); - updatedContent = updatedContent.trimEnd() + "\n\n" + loopContentStr; - - console.log("Apply loop content", { - loopKey: loopKey, - itemsCount: items.length, - contentLength: loopContentStr.length, - sectionHeader: sectionHeader, - }); - } - } + // Use renderer to generate markdown from collected data + const answers: RenderAnswers = { + collectedData: this.state.collectedData, + loopContexts: this.state.loopContexts, + }; + + const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers); + + if (renderedMarkdown.trim()) { + // Append rendered markdown to file + updatedContent = updatedContent.trimEnd() + "\n\n" + renderedMarkdown; + + console.log("Apply rendered markdown", { + contentLength: renderedMarkdown.length, + preview: renderedMarkdown.substring(0, 200) + "...", + }); } // Write updated content