MVP - Basis finalisiert. Standard Toolbar, Workflow
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

This commit is contained in:
Lars 2026-01-16 18:26:01 +01:00
parent 587ef3010a
commit 8ba098c780
5 changed files with 785 additions and 53 deletions

View File

@ -175,6 +175,14 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
if (typeof raw.label === "string" && raw.label.trim()) { if (typeof raw.label === "string" && raw.label.trim()) {
step.label = raw.label.trim(); step.label = raw.label.trim();
} }
// Parse output.join
if (raw.output && typeof raw.output === "object") {
const output = raw.output as Record<string, unknown>;
if (typeof output.join === "string") {
step.output = { join: output.join };
}
}
for (const itemRaw of nestedSteps) { for (const itemRaw of nestedSteps) {
if (!itemRaw || typeof itemRaw !== "object") { if (!itemRaw || typeof itemRaw !== "object") {
@ -246,6 +254,17 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
if (typeof raw.required === "boolean") { if (typeof raw.required === "boolean") {
step.required = raw.required; 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<string, unknown>;
if (typeof output.template === "string") {
step.output = { template: output.template };
}
}
return step; return step;
} }
@ -273,6 +292,46 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
if (typeof raw.prompt === "string" && raw.prompt.trim()) { if (typeof raw.prompt === "string" && raw.prompt.trim()) {
step.prompt = raw.prompt.trim(); step.prompt = raw.prompt.trim();
} }
// Parse output.template
if (raw.output && typeof raw.output === "object") {
const output = raw.output as Record<string, unknown>;
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<string, unknown>;
if (typeof output.template === "string") {
step.output = { template: output.template };
}
}
return step; return step;
} }

241
src/interview/renderer.ts Normal file
View File

@ -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<string, unknown>;
loopContexts: Map<string, unknown[]>;
}
/**
* 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<string, unknown>)[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);
}

View File

@ -22,6 +22,7 @@ export type InterviewStep =
| LLMDialogStep | LLMDialogStep
| CaptureFrontmatterStep | CaptureFrontmatterStep
| CaptureTextStep | CaptureTextStep
| CaptureTextLineStep
| InstructionStep | InstructionStep
| ReviewStep; | ReviewStep;
@ -30,6 +31,9 @@ export interface LoopStep {
key: string; key: string;
label?: string; label?: string;
items: InterviewStep[]; items: InterviewStep[];
output?: {
join?: string; // String to join items (default: "\n\n")
};
} }
export interface LLMDialogStep { export interface LLMDialogStep {
@ -50,6 +54,9 @@ export interface CaptureFrontmatterStep {
field: string; field: string;
required?: boolean; required?: boolean;
prompt?: string; // Optional prompt text prompt?: string; // Optional prompt text
output?: {
template?: string; // Template with tokens: {text}, {field}, {value}
};
} }
export interface CaptureTextStep { export interface CaptureTextStep {
@ -59,6 +66,20 @@ export interface CaptureTextStep {
required?: boolean; required?: boolean;
section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse") section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse")
prompt?: string; // Optional prompt text 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 { export interface InstructionStep {

View File

@ -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");
});
});
});

View File

@ -25,6 +25,7 @@ import { extractFrontmatterId } from "../parser/parseFrontmatter";
import { import {
createMarkdownToolbar, createMarkdownToolbar,
} from "./markdownToolbar"; } from "./markdownToolbar";
import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer";
export interface WizardResult { export interface WizardResult {
applied: boolean; applied: boolean;
@ -189,6 +190,9 @@ export class InterviewWizardModal extends Modal {
case "capture_text": case "capture_text":
this.renderCaptureTextStep(step, stepContentEl); this.renderCaptureTextStep(step, stepContentEl);
break; break;
case "capture_text_line":
this.renderCaptureTextLineStep(step, stepContentEl);
break;
case "capture_frontmatter": case "capture_frontmatter":
this.renderCaptureFrontmatterStep(step, stepContentEl); this.renderCaptureFrontmatterStep(step, stepContentEl);
break; 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. * Update preview container with rendered markdown.
*/ */
@ -643,6 +717,58 @@ export class InterviewWizardModal extends Modal {
textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild);
} }
}, 10); }, 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") { } else if (nestedStep.type === "capture_frontmatter") {
const existingValue = (itemData.get(nestedStep.key) as string) || ""; const existingValue = (itemData.get(nestedStep.key) as string) || "";
const inputKey = `${itemDataKey}_${nestedStep.key}`; const inputKey = `${itemDataKey}_${nestedStep.key}`;
@ -1038,59 +1164,22 @@ export class InterviewWizardModal extends Modal {
} }
} }
// Apply loop data to content // Use renderer to generate markdown from collected data
for (const [loopKey, items] of this.state.loopContexts.entries()) { const answers: RenderAnswers = {
if (items.length > 0) { collectedData: this.state.collectedData,
// Find the loop step to get section info loopContexts: this.state.loopContexts,
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 renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers);
const firstNestedStep = loopStep.items[0];
let sectionHeader = "## Items"; if (renderedMarkdown.trim()) {
// Append rendered markdown to file
// Check if nested step has section property updatedContent = updatedContent.trimEnd() + "\n\n" + renderedMarkdown;
if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) {
sectionHeader = firstNestedStep.section; console.log("Apply rendered markdown", {
} contentLength: renderedMarkdown.length,
preview: renderedMarkdown.substring(0, 200) + "...",
// 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,
});
}
}
} }
// Write updated content // Write updated content