MVP - Basis finalisiert. Standard Toolbar, Workflow
This commit is contained in:
parent
587ef3010a
commit
8ba098c780
|
|
@ -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
241
src/interview/renderer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
322
src/tests/interview/renderer.test.ts
Normal file
322
src/tests/interview/renderer.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user