mindnet_obsidian/src/ui/InterviewWizardModal.ts
Lars c3357a784b
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Enhance nested loop functionality in Interview Wizard Modal
- Added support for tracking active loop paths, enabling nested loop navigation.
- Implemented breadcrumb navigation to display loop hierarchy and allow users to navigate back to parent loops.
- Updated rendering logic for nested loops to support fullscreen mode and improved item management with edit, delete, and navigation options.
- Enhanced user experience with context headers showing parent loop item details and improved item editing capabilities.
2026-01-16 20:53:02 +01:00

2116 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
App,
Component,
MarkdownRenderer,
Modal,
Notice,
Setting,
TFile,
TextAreaComponent,
TextComponent,
} from "obsidian";
import type { InterviewProfile, InterviewStep, LoopStep } from "../interview/types";
import {
type WizardState,
createWizardState,
getCurrentStep,
getNextStepIndex,
getPreviousStepIndex,
canGoNext,
canGoBack,
type Patch,
flattenSteps,
} from "../interview/wizardState";
import { extractFrontmatterId } from "../parser/parseFrontmatter";
import {
createMarkdownToolbar,
} from "./markdownToolbar";
import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer";
import {
type LoopRuntimeState,
createLoopState,
setDraftField,
isDraftDirty,
commitDraft,
startEdit,
deleteItem,
moveItemUp,
moveItemDown,
clearDraft,
setActiveItemStepIndex,
itemNextStep,
itemPrevStep,
resetItemWizard,
} from "../interview/loopState";
export interface WizardResult {
applied: boolean;
patches: Patch[];
}
export class InterviewWizardModal extends Modal {
state: WizardState;
file: TFile;
fileContent: string;
onSubmit: (result: WizardResult) => void;
onSaveAndExit: (result: WizardResult) => void;
profileKey: string;
// Store current input values to save on navigation
private currentInputValues: Map<string, string> = new Map();
// Store preview mode state per step key
private previewMode: Map<string, boolean> = new Map();
constructor(
app: App,
profile: InterviewProfile,
file: TFile,
fileContent: string,
onSubmit: (result: WizardResult) => void,
onSaveAndExit: (result: WizardResult) => void
) {
super(app);
// Validate profile
if (!profile) {
new Notice(`Interview profile not found`);
throw new Error("Profile is required");
}
this.profileKey = profile.key;
// Log profile info
const stepKinds = profile.steps?.map(s => s.type) || [];
console.log("Wizard profile", {
key: profile.key,
stepCount: profile.steps?.length,
kinds: stepKinds,
});
// Validate steps - only throw if profile.steps is actually empty
if (!profile.steps || profile.steps.length === 0) {
new Notice(`Interview has no steps for profile: ${profile.key}`);
throw new Error("Profile has no steps");
}
// Check flattened steps after creation (will be logged in createWizardState)
// If flattened is empty but profile.steps is not, that's a flattenSteps bug
this.state = createWizardState(profile);
// Validate flattened steps after creation
const flat = flattenSteps(profile.steps);
if (flat.length === 0 && profile.steps.length > 0) {
console.error("Flatten produced 0 steps but profile has steps", {
profileKey: profile.key,
originalStepCount: profile.steps.length,
originalKinds: stepKinds,
});
new Notice(`Flatten produced 0 steps (check flattenSteps) for profile: ${profile.key}`);
throw new Error("FlattenSteps produced empty result");
}
this.file = file;
this.fileContent = fileContent;
this.onSubmit = onSubmit;
this.onSaveAndExit = onSaveAndExit;
}
onOpen(): void {
const fileName = this.file.basename || this.file.name.replace(/\.md$/, "");
// Add CSS class for styling
this.modalEl.addClass("mindnet-wizard-modal");
console.log("=== WIZARD START ===", {
profileKey: this.profileKey,
file: this.file.path,
fileName: fileName,
stepCount: this.state.profile.steps?.length || 0,
});
this.renderStep();
}
renderStep(): void {
const { contentEl } = this;
contentEl.empty();
// Apply flex layout structure
contentEl.addClass("modal-content");
const step = getCurrentStep(this.state);
console.log("Render step", {
stepIndex: this.state.currentStepIndex,
stepType: step?.type || "null",
stepKey: step?.key || "null",
stepLabel: step?.label || "null",
totalSteps: flattenSteps(this.state.profile.steps).length,
});
if (!step) {
// Check if we're at the end legitimately or if there's an error
const steps = flattenSteps(this.state.profile.steps);
if (steps.length === 0) {
new Notice(`Interview has no steps for profile: ${this.profileKey}`);
this.close();
return;
}
if (this.state.currentStepIndex >= steps.length) {
contentEl.createEl("p", { text: "Interview completed" });
return;
}
// Unexpected: step is null but we should have one
console.error("Unexpected: step is null", {
currentStepIndex: this.state.currentStepIndex,
stepCount: steps.length,
profileKey: this.profileKey,
});
new Notice(`Error: Could not load step ${this.state.currentStepIndex + 1}`);
this.close();
return;
}
// Create body container for scrollable content
const bodyEl = contentEl.createEl("div", {
cls: "modal-content-body",
});
// Check if ID exists
const hasId = this.checkIdExists();
if (!hasId) {
const warningEl = bodyEl.createEl("div", {
cls: "interview-warning",
});
warningEl.createEl("p", {
text: "⚠️ Note missing frontmatter ID",
});
new Setting(warningEl).addButton((button) => {
button.setButtonText("Generate ID").onClick(() => {
this.generateId();
});
});
}
// Create step content container
const stepContentEl = bodyEl.createEl("div", {
cls: "step-content",
});
// Render step based on type
switch (step.type) {
case "instruction":
this.renderInstructionStep(step, stepContentEl);
break;
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;
case "loop":
this.renderLoopStep(step, stepContentEl);
break;
case "llm_dialog":
this.renderLLMDialogStep(step, stepContentEl);
break;
case "review":
this.renderReviewStep(step, stepContentEl);
break;
}
// Navigation buttons in sticky footer
const footerEl = contentEl.createEl("div", {
cls: "modal-content-footer",
});
this.renderNavigation(footerEl);
}
checkIdExists(): boolean {
const id = extractFrontmatterId(this.fileContent);
return id !== null && id.trim() !== "";
}
generateId(): void {
const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const frontmatterMatch = this.fileContent.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch && frontmatterMatch[1]) {
// Add id to existing frontmatter
const frontmatter = frontmatterMatch[1];
if (!frontmatter.includes("id:")) {
const newFrontmatter = `${frontmatter}\nid: ${id}`;
this.fileContent = this.fileContent.replace(
/^---\n([\s\S]*?)\n---/,
`---\n${newFrontmatter}\n---`
);
this.state.patches.push({
type: "frontmatter",
field: "id",
value: id,
});
new Notice("ID generated");
this.renderStep();
}
}
}
renderInstructionStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "instruction") return;
containerEl.createEl("h2", {
text: step.label || "Instruction",
});
containerEl.createEl("div", {
text: step.content,
cls: "interview-instruction-content",
});
}
renderCaptureTextStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "capture_text") return;
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
const isPreviewMode = this.previewMode.get(step.key) || false;
console.log("Render capture_text step", {
stepKey: step.key,
stepLabel: step.label,
existingValue: existingValue,
valueLength: existingValue.length,
isPreviewMode: isPreviewMode,
});
// 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,
});
}
// Container for editor/preview
const editorContainer = fieldContainer.createEl("div", {
cls: "markdown-editor-container",
});
editorContainer.style.width = "100%";
editorContainer.style.position = "relative";
// Preview container (hidden by default)
const previewContainer = editorContainer.createEl("div", {
cls: "markdown-preview-container",
});
previewContainer.style.display = isPreviewMode ? "block" : "none";
previewContainer.style.width = "100%";
previewContainer.style.minHeight = "240px";
previewContainer.style.padding = "1em";
previewContainer.style.border = "1px solid var(--background-modifier-border)";
previewContainer.style.borderRadius = "4px";
previewContainer.style.background = "var(--background-primary)";
previewContainer.style.overflowY = "auto";
// Editor container
const textEditorContainer = editorContainer.createEl("div", {
cls: "markdown-editor-wrapper",
});
textEditorContainer.style.display = isPreviewMode ? "none" : "block";
textEditorContainer.style.width = "100%";
// Create textarea first
const textSetting = new Setting(textEditorContainer);
textSetting.settingEl.style.width = "100%";
textSetting.controlEl.style.width = "100%";
let textareaRef: HTMLTextAreaElement | null = null;
textSetting.addTextArea((text) => {
textareaRef = text.inputEl;
text.setValue(existingValue);
// Store initial value
this.currentInputValues.set(step.key, existingValue);
text.onChange((value) => {
console.log("Text field changed", {
stepKey: step.key,
valueLength: value.length,
valuePreview: value.substring(0, 50) + (value.length > 50 ? "..." : ""),
});
// Update stored value
this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value);
// Update preview if in preview mode
if (isPreviewMode) {
this.updatePreview(previewContainer, value);
}
});
text.inputEl.rows = 10;
text.inputEl.style.width = "100%";
text.inputEl.style.minHeight = "240px";
text.inputEl.style.boxSizing = "border-box";
text.inputEl.focus();
});
// Create toolbar after textarea is created
// Use setTimeout to ensure textarea is in DOM
setTimeout(() => {
const textarea = textEditorContainer.querySelector("textarea");
if (textarea) {
const toolbar = createMarkdownToolbar(
textarea,
() => {
const newPreviewMode = !this.previewMode.get(step.key);
this.previewMode.set(step.key, newPreviewMode);
this.renderStep();
}
);
textEditorContainer.insertBefore(toolbar, textEditorContainer.firstChild);
}
}, 10);
// Render preview if in preview mode
if (isPreviewMode && existingValue) {
this.updatePreview(previewContainer, existingValue);
}
}
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.
*/
private async updatePreview(
container: HTMLElement,
markdown: string
): Promise<void> {
container.empty();
if (!markdown.trim()) {
container.createEl("p", {
text: "(empty)",
cls: "text-muted",
});
return;
}
// Use Obsidian's MarkdownRenderer
// Create a component for the renderer
const component = new Component();
// Register it with the modal (Modal extends Component)
(this as any).addChild(component);
await MarkdownRenderer.render(
this.app,
markdown,
container,
this.file.path,
component
);
}
renderCaptureFrontmatterStep(
step: InterviewStep,
containerEl: HTMLElement
): void {
if (step.type !== "capture_frontmatter") return;
if (!step.field) return;
// Prefill with filename if field is "title" and no existing value
const fileName = this.file.basename || this.file.name.replace(/\.md$/, "");
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
// Use filename as default for title field if no existing value
const defaultValue = step.field === "title" && !existingValue
? fileName
: existingValue;
console.log("Render capture_frontmatter step", {
stepKey: step.key,
field: step.field,
existingValue: existingValue,
fileName: fileName,
defaultValue: defaultValue,
});
// Field container with vertical layout
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
// Label
const labelText = step.label || step.field;
if (labelText) {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: labelText,
});
}
// 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 settingNameEl2 = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null;
if (settingNameEl2) {
settingNameEl2.style.display = "none";
}
fieldSetting.addText((text) => {
text.setValue(defaultValue);
// Store initial value
this.currentInputValues.set(step.key, defaultValue);
text.onChange((value) => {
console.log("Frontmatter field changed", {
stepKey: step.key,
field: step.field,
value: value,
});
// Update stored value
this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value);
// Update or add patch
const existingPatchIndex = this.state.patches.findIndex(
p => p.type === "frontmatter" && p.field === step.field
);
const patch = {
type: "frontmatter" as const,
field: step.field!,
value: value,
};
if (existingPatchIndex >= 0) {
this.state.patches[existingPatchIndex] = patch;
} else {
this.state.patches.push(patch);
}
});
text.inputEl.style.width = "100%";
text.inputEl.style.boxSizing = "border-box";
text.inputEl.focus();
});
}
renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "loop") return;
// Check if we're in a nested loop (fullscreen mode)
const currentLoopKey = this.state.activeLoopPath.length > 0
? this.state.activeLoopPath[this.state.activeLoopPath.length - 1]
: null;
// If we're in a nested loop, find which nested step it is
if (currentLoopKey && currentLoopKey.startsWith(step.key + ".")) {
// We're in a nested loop of this step - render it in fullscreen mode
this.renderNestedLoopFullscreen(step, containerEl, currentLoopKey);
return;
}
// Otherwise, render normal loop (or top-level loop)
if (this.state.activeLoopPath.length > 0 && this.state.activeLoopPath[0] !== step.key) {
// We're in a different loop's nested loop, don't render this one
return;
}
// Initialize or get loop runtime state
let loopState = this.state.loopRuntimeStates.get(step.key);
if (!loopState) {
// Initialize from legacy loopContexts if available
const legacyItems = this.state.loopContexts.get(step.key) || [];
loopState = {
items: legacyItems,
draft: {},
editIndex: null,
activeItemStepIndex: 0,
};
this.state.loopRuntimeStates.set(step.key, loopState);
}
const commitMode = step.ui?.commit || "explicit_add";
console.log("Render loop step", {
stepKey: step.key,
stepLabel: step.label,
itemsCount: loopState.items.length,
nestedStepsCount: step.items.length,
editIndex: loopState.editIndex,
commitMode: commitMode,
activeLoopPath: this.state.activeLoopPath,
});
// Breadcrumb navigation if in nested context
if (this.state.activeLoopPath.length > 0) {
this.renderBreadcrumb(containerEl, step);
}
// Title
containerEl.createEl("h2", {
text: step.label || "Loop",
});
// Show editing indicator
if (loopState.editIndex !== null) {
const indicator = containerEl.createEl("div", {
cls: "loop-editing-indicator",
text: `✏️ Editing item ${loopState.editIndex + 1}`,
});
indicator.style.padding = "0.5em";
indicator.style.background = "var(--background-modifier-border-hover)";
indicator.style.borderRadius = "4px";
indicator.style.marginBottom = "1em";
}
// 2-pane container
const paneContainer = containerEl.createEl("div", {
cls: "loop-pane-container",
});
paneContainer.style.display = "flex";
paneContainer.style.gap = "1em";
paneContainer.style.width = "100%";
paneContainer.style.minHeight = "400px";
// Left pane: Items list
const leftPane = paneContainer.createEl("div", {
cls: "loop-items-pane",
});
leftPane.style.width = "40%";
leftPane.style.minWidth = "250px";
leftPane.style.borderRight = "1px solid var(--background-modifier-border)";
leftPane.style.paddingRight = "1em";
leftPane.style.overflowY = "auto";
leftPane.style.maxHeight = "600px";
leftPane.createEl("h3", {
text: `Items (${loopState.items.length})`,
});
// Items list
const itemsList = leftPane.createEl("div", {
cls: "loop-items-list",
});
if (loopState.items.length === 0) {
itemsList.createEl("p", {
text: "No items yet. Use the editor on the right to add items.",
cls: "interview-note",
});
} else {
for (let i = 0; i < loopState.items.length; i++) {
const item = loopState.items[i];
const itemEl = itemsList.createEl("div", {
cls: `loop-item-entry ${loopState.editIndex === i ? "is-editing" : ""}`,
});
itemEl.style.padding = "0.5em";
itemEl.style.marginBottom = "0.5em";
itemEl.style.border = "1px solid var(--background-modifier-border)";
itemEl.style.borderRadius = "4px";
if (loopState.editIndex === i) {
itemEl.style.background = "var(--background-modifier-border-hover)";
}
// Item preview
const preview = itemEl.createEl("div", {
cls: "loop-item-preview",
});
preview.style.marginBottom = "0.5em";
if (typeof item === "object" && item !== null) {
const itemEntries = Object.entries(item as Record<string, unknown>);
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 });
} else {
preview.createSpan({ text: "Empty item" });
}
} else {
preview.createSpan({ text: String(item) });
}
// Actions
const actions = itemEl.createEl("div", {
cls: "loop-item-actions",
});
actions.style.display = "flex";
actions.style.gap = "0.25em";
actions.style.flexWrap = "wrap";
// Edit button
const editBtn = actions.createEl("button", {
text: "Edit",
cls: "mod-cta",
});
editBtn.style.fontSize = "0.85em";
editBtn.style.padding = "0.25em 0.5em";
editBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
let newState = startEdit(currentState, i);
newState = resetItemWizard(newState); // Reset to first step
this.state.loopRuntimeStates.set(step.key, newState);
this.renderStep();
}
};
// Delete button
const deleteBtn = actions.createEl("button", {
text: "Delete",
});
deleteBtn.style.fontSize = "0.85em";
deleteBtn.style.padding = "0.25em 0.5em";
deleteBtn.onclick = () => {
if (confirm(`Delete item ${i + 1}?`)) {
const newState = deleteItem(loopState!, i);
this.state.loopRuntimeStates.set(step.key, newState);
// Update answers
this.state.loopContexts.set(step.key, newState.items);
this.renderStep();
}
};
// Move Up button
const moveUpBtn = actions.createEl("button", {
text: "↑",
});
moveUpBtn.style.fontSize = "0.85em";
moveUpBtn.style.padding = "0.25em 0.5em";
moveUpBtn.disabled = i === 0;
moveUpBtn.onclick = () => {
const newState = moveItemUp(loopState!, i);
this.state.loopRuntimeStates.set(step.key, newState);
this.state.loopContexts.set(step.key, newState.items);
this.renderStep();
};
// Move Down button
const moveDownBtn = actions.createEl("button", {
text: "↓",
});
moveDownBtn.style.fontSize = "0.85em";
moveDownBtn.style.padding = "0.25em 0.5em";
moveDownBtn.disabled = i === loopState.items.length - 1;
moveDownBtn.onclick = () => {
const newState = moveItemDown(loopState!, i);
this.state.loopRuntimeStates.set(step.key, newState);
this.state.loopContexts.set(step.key, newState.items);
this.renderStep();
};
}
}
// Right pane: Editor
const rightPane = paneContainer.createEl("div", {
cls: "loop-editor-pane",
});
rightPane.style.width = "60%";
rightPane.style.flex = "1";
// Subwizard header
const itemTitle = rightPane.createEl("h3");
const itemTitleText = loopState.editIndex !== null
? `Item: ${loopState.editIndex + 1} (editing)`
: "Item: New";
const stepCounter = step.items.length > 0
? ` - Step ${loopState.activeItemStepIndex + 1}/${step.items.length}`
: "";
itemTitle.textContent = itemTitleText + stepCounter;
// Render only the active nested step (subwizard)
if (step.items.length > 0) {
const activeStepIndex = Math.min(loopState.activeItemStepIndex, step.items.length - 1);
const activeNestedStep = step.items[activeStepIndex];
if (activeNestedStep) {
const editorContainer = rightPane.createEl("div", {
cls: "loop-item-editor",
});
const draftValue = loopState.draft[activeNestedStep.key];
this.renderLoopNestedStep(activeNestedStep, editorContainer, step.key, draftValue, (fieldId, value) => {
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
const newState = setDraftField(currentState, fieldId, value);
this.state.loopRuntimeStates.set(step.key, newState);
}
});
// Subwizard navigation buttons
const subwizardNav = rightPane.createEl("div", {
cls: "loop-subwizard-navigation",
});
subwizardNav.style.display = "flex";
subwizardNav.style.gap = "0.5em";
subwizardNav.style.marginTop = "1em";
subwizardNav.style.justifyContent = "space-between";
// Left side: Item Back/Next
const itemNavLeft = subwizardNav.createEl("div", {
cls: "loop-item-nav-left",
});
itemNavLeft.style.display = "flex";
itemNavLeft.style.gap = "0.5em";
// Item Back button
const itemBackBtn = itemNavLeft.createEl("button", {
text: "← Item Back",
});
itemBackBtn.disabled = activeStepIndex === 0;
itemBackBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
const newState = itemPrevStep(currentState);
this.state.loopRuntimeStates.set(step.key, newState);
this.renderStep();
}
};
// Item Next button
const itemNextBtn = itemNavLeft.createEl("button", {
text: "Item Next →",
});
itemNextBtn.disabled = activeStepIndex >= step.items.length - 1;
itemNextBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
const newState = itemNextStep(currentState, step.items.length);
this.state.loopRuntimeStates.set(step.key, newState);
this.renderStep();
}
};
// Right side: Done/Save Item
const itemNavRight = subwizardNav.createEl("div", {
cls: "loop-item-nav-right",
});
itemNavRight.style.display = "flex";
itemNavRight.style.gap = "0.5em";
// Check for required fields
const missingRequired: string[] = [];
for (const nestedStep of step.items) {
if (
(nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line" || nestedStep.type === "capture_frontmatter") &&
nestedStep.required
) {
const value = loopState.draft[nestedStep.key];
if (!value || (typeof value === "string" && value.trim() === "")) {
missingRequired.push(nestedStep.label || nestedStep.key);
}
}
}
// Done/Save Item button
const doneBtn = itemNavRight.createEl("button", {
text: loopState.editIndex !== null ? "Save Item" : "Done",
cls: "mod-cta",
});
doneBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(step.key);
if (!currentState) return;
// Check required fields
if (missingRequired.length > 0) {
const msg = `Required fields missing: ${missingRequired.join(", ")}. Save anyway?`;
if (!confirm(msg)) {
return;
}
}
if (isDraftDirty(currentState.draft)) {
const wasNewItem = currentState.editIndex === null;
const newState = commitDraft(currentState);
this.state.loopRuntimeStates.set(step.key, newState);
// Update answers
this.state.loopContexts.set(step.key, newState.items);
// If we just created a new item, reset all nested loop states
if (wasNewItem) {
for (const nestedStep of step.items) {
if (nestedStep.type === "loop") {
const nestedLoopKey = `${step.key}.${nestedStep.key}`;
const nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (nestedLoopState) {
const resetState = clearDraft(nestedLoopState);
this.state.loopRuntimeStates.set(nestedLoopKey, resetState);
}
}
}
}
this.renderStep();
} else {
new Notice("Please enter at least one field");
}
};
// Show warning if required fields missing
if (missingRequired.length > 0) {
const warning = rightPane.createEl("div", {
cls: "loop-required-warning",
text: `⚠️ Required fields missing: ${missingRequired.join(", ")}`,
});
warning.style.padding = "0.5em";
warning.style.background = "var(--background-modifier-error)";
warning.style.borderRadius = "4px";
warning.style.marginTop = "0.5em";
warning.style.color = "var(--text-error)";
}
}
}
}
/**
* Render a nested step within a loop editor.
*/
private renderLoopNestedStep(
nestedStep: InterviewStep,
containerEl: HTMLElement,
loopKey: string,
draftValue: unknown,
onFieldChange: (fieldId: string, value: unknown) => void
): void {
const existingValue = draftValue !== undefined ? String(draftValue) : "";
// Use unique key for preview mode tracking in nested loops
const previewKey = `${loopKey}.${nestedStep.key}`;
const isPreviewMode = this.previewMode.get(previewKey) || false;
if (nestedStep.type === "capture_text") {
// Field container
const fieldContainer = containerEl.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,
});
}
// Container for editor/preview
const editorContainer = fieldContainer.createEl("div", {
cls: "markdown-editor-container",
});
editorContainer.style.width = "100%";
editorContainer.style.position = "relative";
// Preview container (hidden by default)
const previewContainer = editorContainer.createEl("div", {
cls: "markdown-preview-container",
});
previewContainer.style.display = isPreviewMode ? "block" : "none";
previewContainer.style.width = "100%";
previewContainer.style.minHeight = "240px";
previewContainer.style.padding = "1em";
previewContainer.style.border = "1px solid var(--background-modifier-border)";
previewContainer.style.borderRadius = "4px";
previewContainer.style.background = "var(--background-primary)";
previewContainer.style.overflowY = "auto";
// Editor container
const textEditorContainer = editorContainer.createEl("div", {
cls: "markdown-editor-wrapper",
});
textEditorContainer.style.display = isPreviewMode ? "none" : "block";
textEditorContainer.style.width = "100%";
const textSetting = new Setting(textEditorContainer);
textSetting.settingEl.style.width = "100%";
textSetting.controlEl.style.width = "100%";
// Hide the default label
const settingNameEl = textSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null;
if (settingNameEl) {
settingNameEl.style.display = "none";
}
let textareaRef: HTMLTextAreaElement | null = null;
textSetting.addTextArea((text) => {
textareaRef = text.inputEl;
text.setValue(existingValue);
text.onChange((value) => {
onFieldChange(nestedStep.key, value);
// Update preview if in preview mode
const currentPreviewMode = this.previewMode.get(previewKey) || false;
if (currentPreviewMode) {
this.updatePreview(previewContainer, value);
}
});
text.inputEl.rows = 8;
text.inputEl.style.width = "100%";
text.inputEl.style.minHeight = "150px";
text.inputEl.style.boxSizing = "border-box";
});
// Add toolbar with preview toggle
setTimeout(() => {
const textarea = textEditorContainer.querySelector("textarea");
if (textarea) {
const itemToolbar = createMarkdownToolbar(
textarea,
() => {
// Get current value from textarea before toggling
const currentValue = textarea.value;
// Update draft with current value
onFieldChange(nestedStep.key, currentValue);
// Toggle preview mode
const newPreviewMode = !this.previewMode.get(previewKey);
this.previewMode.set(previewKey, newPreviewMode);
// Re-render to show/hide preview
this.renderStep();
}
);
textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild);
}
}, 10);
// Render preview if in preview mode
if (isPreviewMode && existingValue) {
this.updatePreview(previewContainer, existingValue);
}
} else if (nestedStep.type === "capture_text_line") {
// Field container
const fieldContainer = containerEl.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
const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null;
if (settingNameEl) {
settingNameEl.style.display = "none";
}
fieldSetting.addText((text) => {
text.setValue(existingValue);
text.onChange((value) => {
onFieldChange(nestedStep.key, value);
});
text.inputEl.style.width = "100%";
text.inputEl.style.boxSizing = "border-box";
});
} else if (nestedStep.type === "capture_frontmatter") {
// Field container
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
// Label
const labelText = nestedStep.label || nestedStep.field || nestedStep.key;
if (labelText) {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: labelText,
});
}
// 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
const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null;
if (settingNameEl) {
settingNameEl.style.display = "none";
}
fieldSetting.addText((text) => {
text.setValue(existingValue);
text.onChange((value) => {
onFieldChange(nestedStep.key, value);
});
text.inputEl.style.width = "100%";
text.inputEl.style.boxSizing = "border-box";
});
} else if (nestedStep.type === "loop") {
// Nested loop: render as a button to enter fullscreen mode
const nestedLoopItems = Array.isArray(draftValue) ? draftValue : [];
const nestedLoopKey = `${loopKey}.${nestedStep.key}`;
// Get or create nested loop state
let nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (!nestedLoopState) {
nestedLoopState = {
items: nestedLoopItems,
draft: {},
editIndex: null,
activeItemStepIndex: 0,
};
this.state.loopRuntimeStates.set(nestedLoopKey, nestedLoopState);
}
// Field container
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
// Label
if (nestedStep.label) {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: nestedStep.label,
});
}
// Show item count
const countText = nestedLoopState.items.length > 0
? `${nestedLoopState.items.length} ${nestedLoopState.items.length === 1 ? "Eintrag" : "Einträge"}`
: "Keine Einträge";
const countEl = fieldContainer.createEl("div", {
cls: "mindnet-field__desc",
text: countText,
});
countEl.style.marginBottom = "0.5em";
// Button to enter nested loop (fullscreen mode)
const enterBtn = fieldContainer.createEl("button", {
text: nestedLoopState.items.length > 0 ? "Bearbeiten" : "Hinzufügen",
cls: "mod-cta",
});
enterBtn.style.width = "100%";
enterBtn.onclick = () => {
// Enter nested loop: add to activeLoopPath
this.state.activeLoopPath.push(nestedLoopKey);
this.renderStep();
};
}
}
/**
* Render breadcrumb navigation showing loop hierarchy.
*/
private renderBreadcrumb(containerEl: HTMLElement, currentStep: InterviewStep): void {
const breadcrumbContainer = containerEl.createEl("div", {
cls: "loop-breadcrumb",
});
breadcrumbContainer.style.display = "flex";
breadcrumbContainer.style.alignItems = "center";
breadcrumbContainer.style.gap = "0.5em";
breadcrumbContainer.style.marginBottom = "1em";
breadcrumbContainer.style.padding = "0.5em";
breadcrumbContainer.style.background = "var(--background-secondary)";
breadcrumbContainer.style.borderRadius = "4px";
// Build breadcrumb path
const path: Array<{ key: string; label: string }> = [];
// Find all parent loops
for (let i = 0; i < this.state.activeLoopPath.length; i++) {
const loopKey = this.state.activeLoopPath[i];
if (!loopKey) continue;
// Extract step key from loop key (e.g., "items.item_list" -> "item_list")
const parts = loopKey.split(".");
const stepKey = parts[parts.length - 1];
if (!stepKey) continue;
// Find the step in the profile
const step = this.findStepByKey(stepKey);
if (step && step.type === "loop") {
path.push({ key: loopKey, label: step.label || stepKey });
}
}
// Render breadcrumb
path.forEach((item, index) => {
if (index > 0) {
breadcrumbContainer.createEl("span", { text: "" });
}
const breadcrumbItem = breadcrumbContainer.createEl("button", {
text: item.label,
cls: "breadcrumb-item",
});
breadcrumbItem.style.background = "transparent";
breadcrumbItem.style.border = "none";
breadcrumbItem.style.cursor = "pointer";
breadcrumbItem.style.textDecoration = index < path.length - 1 ? "underline" : "none";
if (index < path.length - 1) {
breadcrumbItem.onclick = () => {
// Navigate to this level
this.state.activeLoopPath = this.state.activeLoopPath.slice(0, index + 1);
this.renderStep();
};
}
});
// Back button to parent level
if (this.state.activeLoopPath.length > 0) {
const backBtn = breadcrumbContainer.createEl("button", {
text: "← Zurück",
cls: "mod-cta",
});
backBtn.style.marginLeft = "auto";
backBtn.onclick = () => {
this.state.activeLoopPath.pop();
this.renderStep();
};
}
}
/**
* Find a step by its key in the profile (recursive search).
*/
private findStepByKey(key: string): InterviewStep | null {
const searchInSteps = (steps: InterviewStep[]): InterviewStep | null => {
for (const step of steps) {
if (step.key === key) {
return step;
}
if (step.type === "loop") {
const found = searchInSteps(step.items);
if (found) return found;
}
}
return null;
};
return searchInSteps(this.state.profile.steps);
}
/**
* Render a nested loop in fullscreen mode (uses full width).
*/
private renderNestedLoopFullscreen(
parentStep: InterviewStep,
containerEl: HTMLElement,
nestedLoopKey: string
): void {
// Extract the nested step from parent step
const nestedStepKey = nestedLoopKey.split(".").pop();
if (!nestedStepKey) return;
// parentStep must be a LoopStep to have items
if (parentStep.type !== "loop") return;
const loopStep = parentStep as LoopStep;
const nestedStep = loopStep.items.find((s: InterviewStep) => s.key === nestedStepKey);
if (!nestedStep || nestedStep.type !== "loop") return;
// Get nested loop state
let nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (!nestedLoopState) {
nestedLoopState = {
items: [],
draft: {},
editIndex: null,
activeItemStepIndex: 0,
};
this.state.loopRuntimeStates.set(nestedLoopKey, nestedLoopState);
}
// Breadcrumb
this.renderBreadcrumb(containerEl, nestedStep);
// Context header: Show parent loop item context
const contextHeader = containerEl.createEl("div", {
cls: "nested-loop-context",
});
contextHeader.style.padding = "1em";
contextHeader.style.background = "var(--background-secondary)";
contextHeader.style.borderRadius = "6px";
contextHeader.style.marginBottom = "1.5em";
contextHeader.style.border = "1px solid var(--background-modifier-border)";
// Find parent loop context
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
const parentLoopState = this.state.loopRuntimeStates.get(parentLoopKey);
if (parentLoopState) {
// Find the top-level loop step
const topLevelLoopKey = nestedLoopKey.split(".")[0];
if (!topLevelLoopKey) {
// Fallback if no top-level key found
const contextTitle = contextHeader.createEl("div", {
cls: "context-title",
text: "📍 Kontext: " + (parentStep.label || "Parent Loop"),
});
contextTitle.style.fontWeight = "bold";
contextTitle.style.marginBottom = "0.5em";
contextTitle.style.fontSize = "0.9em";
contextTitle.style.color = "var(--text-muted)";
} else {
const topLevelLoopState = this.state.loopRuntimeStates.get(topLevelLoopKey);
const topLevelStep = this.findStepByKey(topLevelLoopKey);
// Build context path: top-level loop > nested loop (current)
const contextPath: string[] = [];
if (topLevelStep && topLevelStep.type === "loop") {
contextPath.push(topLevelStep.label || topLevelLoopKey);
}
// Add the nested loop label (the one we're currently in)
if (nestedStep && nestedStep.type === "loop") {
contextPath.push(nestedStep.label || nestedStepKey || "");
}
const contextTitle = contextHeader.createEl("div", {
cls: "context-title",
});
contextTitle.style.fontWeight = "bold";
contextTitle.style.marginBottom = "0.5em";
contextTitle.style.fontSize = "0.9em";
contextTitle.style.color = "var(--text-muted)";
if (contextPath.length > 0) {
contextTitle.textContent = "📍 Kontext: " + contextPath.join(" ");
} else {
contextTitle.textContent = "📍 Kontext: " + (parentStep.label || "Parent Loop");
}
}
// Show which parent item we're editing
if (parentLoopState.editIndex !== null) {
const parentItem = parentLoopState.items[parentLoopState.editIndex];
if (parentItem && typeof parentItem === "object") {
const parentItemObj = parentItem as Record<string, unknown>;
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
// Show parent item fields (excluding the nested loop field itself)
const parentFields: string[] = [];
for (const [key, value] of Object.entries(parentItemObj)) {
if (nestedStepKey && key !== nestedStepKey && value && typeof value === "string" && value.trim() !== "") {
const step = loopStep.items.find(s => s.key === key);
const label = step?.label || key;
parentFields.push(`${label}: ${value.trim().substring(0, 60)}${value.trim().length > 60 ? "..." : ""}`);
}
}
if (parentFields.length > 0) {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} - ${parentFields.join(" | ")}`;
} else {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} (bearbeiten)`;
}
} else {
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
text: `Item ${parentLoopState.editIndex + 1} (bearbeiten)`,
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
}
} else {
// New item in parent loop
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
text: "Neues Item (wird erstellt)",
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-muted)";
contextInfo.style.fontStyle = "italic";
}
}
}
// Title
containerEl.createEl("h2", {
text: nestedStep.label || "Verschachtelter Loop",
});
// Show editing indicator
if (nestedLoopState.editIndex !== null) {
const indicator = containerEl.createEl("div", {
cls: "loop-editing-indicator",
text: `✏️ Editing item ${nestedLoopState.editIndex + 1}`,
});
indicator.style.padding = "0.5em";
indicator.style.background = "var(--background-modifier-border-hover)";
indicator.style.borderRadius = "4px";
indicator.style.marginBottom = "1em";
}
// Full-width 2-pane container
const paneContainer = containerEl.createEl("div", {
cls: "loop-pane-container",
});
paneContainer.style.display = "flex";
paneContainer.style.gap = "1em";
paneContainer.style.width = "100%";
// Left pane: Items list (30% for fullscreen mode)
const leftPane = paneContainer.createEl("div", {
cls: "loop-items-pane",
});
leftPane.style.width = "30%";
leftPane.style.borderRight = "1px solid var(--background-modifier-border)";
leftPane.style.paddingRight = "1em";
leftPane.style.maxHeight = "70vh";
leftPane.style.overflowY = "auto";
const itemsTitle = leftPane.createEl("h3", {
text: "Einträge",
});
itemsTitle.style.marginBottom = "0.5em";
// Render items list (same as normal loop)
nestedLoopState.items.forEach((item, i) => {
const itemEl = leftPane.createEl("div", {
cls: "loop-item",
});
itemEl.style.padding = "0.75em";
itemEl.style.marginBottom = "0.5em";
itemEl.style.background = "var(--background-secondary)";
itemEl.style.borderRadius = "4px";
itemEl.style.cursor = "pointer";
// Extract first non-empty field value for display
let itemText = `Item ${i + 1}`;
if (typeof item === "object" && item !== null) {
const itemObj = item as Record<string, unknown>;
for (const [key, value] of Object.entries(itemObj)) {
if (value && typeof value === "string" && value.trim() !== "") {
itemText = value.trim();
break;
}
}
} else if (item) {
itemText = String(item);
}
itemEl.textContent = itemText.length > 50 ? itemText.substring(0, 50) + "..." : itemText;
// Action buttons (same as normal loop)
const buttonContainer = itemEl.createEl("div", {
cls: "loop-item-actions",
});
buttonContainer.style.display = "flex";
buttonContainer.style.gap = "0.25em";
buttonContainer.style.marginTop = "0.5em";
buttonContainer.style.justifyContent = "flex-end";
const editBtn = buttonContainer.createEl("button", { text: "✏️ Edit" });
editBtn.onclick = () => {
let newState = startEdit(nestedLoopState!, i);
newState = resetItemWizard(newState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const moveUpBtn = buttonContainer.createEl("button", { text: "↑" });
moveUpBtn.disabled = i === 0;
moveUpBtn.onclick = () => {
const newState = moveItemUp(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const moveDownBtn = buttonContainer.createEl("button", { text: "↓" });
moveDownBtn.disabled = i >= nestedLoopState.items.length - 1;
moveDownBtn.onclick = () => {
const newState = moveItemDown(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const deleteBtn = buttonContainer.createEl("button", { text: "🗑️" });
deleteBtn.onclick = () => {
const newState = deleteItem(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
});
// Right pane: Editor (70% for fullscreen mode)
const rightPane = paneContainer.createEl("div", {
cls: "loop-editor-pane",
});
rightPane.style.width = "70%";
rightPane.style.flex = "1";
// Subwizard header
const itemTitle = rightPane.createEl("h3");
const itemTitleText = nestedLoopState.editIndex !== null
? `Item: ${nestedLoopState.editIndex + 1} (editing)`
: "Item: New";
const stepCounter = nestedStep.items.length > 0
? ` - Step ${nestedLoopState.activeItemStepIndex + 1}/${nestedStep.items.length}`
: "";
itemTitle.textContent = itemTitleText + stepCounter;
// Render active nested step
if (nestedStep.items.length > 0) {
const activeStepIndex = Math.min(nestedLoopState.activeItemStepIndex, nestedStep.items.length - 1);
const activeNestedStep = nestedStep.items[activeStepIndex];
if (activeNestedStep) {
const editorContainer = rightPane.createEl("div", {
cls: "loop-item-editor",
});
const draftValue = nestedLoopState.draft[activeNestedStep.key];
this.renderLoopNestedStep(
activeNestedStep,
editorContainer,
nestedLoopKey,
draftValue,
(fieldId, value) => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = setDraftField(currentState, fieldId, value);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
if (parentLoopKey) {
const parentState = this.state.loopRuntimeStates.get(parentLoopKey);
if (parentState && nestedStepKey) {
const updatedParentDraft = {
...parentState.draft,
[nestedStepKey]: newState.items,
};
const updatedParentState = setDraftField(parentState, nestedStepKey, newState.items);
this.state.loopRuntimeStates.set(parentLoopKey, updatedParentState);
}
}
}
}
}
);
// Navigation buttons (same as normal loop)
const subwizardNav = rightPane.createEl("div", {
cls: "loop-subwizard-navigation",
});
subwizardNav.style.display = "flex";
subwizardNav.style.gap = "0.5em";
subwizardNav.style.marginTop = "1em";
subwizardNav.style.justifyContent = "space-between";
const itemNavLeft = subwizardNav.createEl("div");
itemNavLeft.style.display = "flex";
itemNavLeft.style.gap = "0.5em";
const itemBackBtn = itemNavLeft.createEl("button", { text: "← Item Back" });
itemBackBtn.disabled = activeStepIndex === 0;
itemBackBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = itemPrevStep(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
const itemNextBtn = itemNavLeft.createEl("button", { text: "Item Next →" });
itemNextBtn.disabled = activeStepIndex >= nestedStep.items.length - 1;
itemNextBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = itemNextStep(currentState, nestedStep.items.length);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
const itemNavRight = subwizardNav.createEl("div");
itemNavRight.style.display = "flex";
itemNavRight.style.gap = "0.5em";
const doneBtn = itemNavRight.createEl("button", {
text: nestedLoopState.editIndex !== null ? "Save Item" : "Done",
cls: "mod-cta",
});
doneBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState && isDraftDirty(currentState.draft)) {
const newState = commitDraft(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0 && nestedStepKey) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
if (parentLoopKey) {
const parentState = this.state.loopRuntimeStates.get(parentLoopKey);
if (parentState) {
const updatedParentState = setDraftField(parentState, nestedStepKey, newState.items);
this.state.loopRuntimeStates.set(parentLoopKey, updatedParentState);
}
}
}
this.renderStep();
}
};
if (nestedLoopState.editIndex !== null || isDraftDirty(nestedLoopState.draft)) {
const clearBtn = itemNavRight.createEl("button", { text: "Clear" });
clearBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = clearDraft(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
}
}
}
}
renderLLMDialogStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "llm_dialog") return;
// 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: `Prompt: ${step.prompt}`,
});
}
const existingValue =
(this.state.collectedData.get(step.key) as string) || "";
// Editor container for LLM response
const llmEditorContainer = fieldContainer.createEl("div", {
cls: "markdown-editor-wrapper",
});
llmEditorContainer.style.width = "100%";
const llmSetting = new Setting(llmEditorContainer);
llmSetting.settingEl.style.width = "100%";
llmSetting.controlEl.style.width = "100%";
// Hide the default label from Setting component
const settingNameEl = llmSetting.settingEl.querySelector(".setting-item-name") as HTMLElement;
if (settingNameEl) {
settingNameEl.style.display = "none";
}
let llmTextareaRef: HTMLTextAreaElement | null = null;
llmSetting.addTextArea((text) => {
llmTextareaRef = text.inputEl;
text.setValue(existingValue);
this.currentInputValues.set(step.key, existingValue);
text.onChange((value) => {
this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value);
});
text.inputEl.rows = 10;
text.inputEl.style.width = "100%";
text.inputEl.style.minHeight = "240px";
text.inputEl.style.boxSizing = "border-box";
});
// Add toolbar for LLM response
setTimeout(() => {
const textarea = llmEditorContainer.querySelector("textarea");
if (textarea) {
const llmToolbar = createMarkdownToolbar(textarea);
llmEditorContainer.insertBefore(llmToolbar, llmEditorContainer.firstChild);
}
}, 10);
containerEl.createEl("p", {
text: "Note: LLM dialog requires manual input in this version",
cls: "interview-note",
});
}
renderReviewStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "review") return;
containerEl.createEl("h2", {
text: step.label || "Review",
});
containerEl.createEl("p", {
text: "Review collected data and patches:",
});
// Show collected data
const dataList = containerEl.createEl("ul");
for (const [key, value] of this.state.collectedData.entries()) {
const li = dataList.createEl("li");
li.createEl("strong", { text: `${key}: ` });
li.createSpan({ text: String(value) });
}
// Show loop items (from runtime states)
for (const [loopKey, loopState] of this.state.loopRuntimeStates.entries()) {
const loopLi = dataList.createEl("li");
loopLi.createEl("strong", { text: `${loopKey} (${loopState.items.length} items): ` });
loopLi.createSpan({ text: `${loopState.items.length} committed items` });
}
// Show patches
const patchesList = containerEl.createEl("ul");
for (const patch of this.state.patches) {
const li = patchesList.createEl("li");
if (patch.type === "frontmatter") {
li.createSpan({
text: `Frontmatter ${patch.field}: ${String(patch.value)}`,
});
} else {
li.createSpan({ text: `Content patch` });
}
}
}
renderNavigation(containerEl: HTMLElement): void {
// Navigation buttons in a flex row
const navContainer = containerEl.createEl("div", {
cls: "interview-navigation",
});
navContainer.style.display = "flex";
navContainer.style.gap = "0.5em";
navContainer.style.justifyContent = "flex-end";
navContainer.style.flexWrap = "wrap";
// Back button
new Setting(navContainer)
.addButton((button) => {
button.setButtonText("Back").setDisabled(!canGoBack(this.state));
button.onClick(() => {
this.goBack();
});
})
.addButton((button) => {
const step = getCurrentStep(this.state);
const isReview = step?.type === "review";
const isLoop = step?.type === "loop";
// For loop steps, check if we have items (from runtime state or legacy context)
let loopItems: unknown[] = [];
if (isLoop) {
const loopState = this.state.loopRuntimeStates.get(step.key);
loopItems = loopState ? loopState.items : (this.state.loopContexts.get(step.key) || []);
}
const canProceedLoop = !isLoop || loopItems.length > 0;
button
.setButtonText(isReview ? "Apply & Finish" : "Next")
.setCta()
.setDisabled((!canGoNext(this.state) && !isReview) || !canProceedLoop);
button.onClick(() => {
if (isReview) {
console.log("=== FINISH WIZARD (Apply & Finish) ===");
// Save current step data before finishing
const currentStep = getCurrentStep(this.state);
if (currentStep) {
this.saveCurrentStepData(currentStep);
}
this.applyPatches();
this.onSubmit({ applied: true, patches: this.state.patches });
this.close();
} else {
this.goNext();
}
});
})
.addButton((button) => {
button.setButtonText("Skip").onClick(() => {
this.goNext();
});
})
.addButton((button) => {
button.setButtonText("Save & Exit").onClick(() => {
console.log("=== SAVE & EXIT ===");
this.applyPatches();
this.onSaveAndExit({
applied: true,
patches: this.state.patches,
});
this.close();
});
});
}
goNext(): void {
const currentStep = getCurrentStep(this.state);
// Handle loop commit mode
if (currentStep && currentStep.type === "loop") {
const loopState = this.state.loopRuntimeStates.get(currentStep.key);
const commitMode = currentStep.ui?.commit || "explicit_add";
// In on_next mode, auto-commit dirty draft
if (commitMode === "on_next" && loopState && isDraftDirty(loopState.draft)) {
const newState = commitDraft(loopState);
this.state.loopRuntimeStates.set(currentStep.key, newState);
// Update answers
this.state.loopContexts.set(currentStep.key, newState.items);
console.log("Auto-committed draft on Next", {
stepKey: currentStep.key,
itemsCount: newState.items.length,
});
}
}
// Save current step data before navigating
if (currentStep) {
this.saveCurrentStepData(currentStep);
}
const nextIndex = getNextStepIndex(this.state);
console.log("Navigate: Next", {
fromIndex: this.state.currentStepIndex,
toIndex: nextIndex,
currentStepKey: currentStep?.key,
currentStepType: currentStep?.type,
});
if (nextIndex !== null) {
this.state.stepHistory.push(this.state.currentStepIndex);
this.state.currentStepIndex = nextIndex;
this.renderStep();
} else {
console.log("Cannot go next: already at last step");
}
}
/**
* Save data from current step before navigating away.
*/
private saveCurrentStepData(step: InterviewStep): void {
const currentValue = this.currentInputValues.get(step.key);
if (currentValue !== undefined) {
console.log("Save current step data before navigation", {
stepKey: step.key,
stepType: step.type,
value: typeof currentValue === "string"
? (currentValue.length > 50 ? currentValue.substring(0, 50) + "..." : currentValue)
: currentValue,
});
this.state.collectedData.set(step.key, currentValue);
// For frontmatter steps, also update patch
if (step.type === "capture_frontmatter" && step.field) {
const existingPatchIndex = this.state.patches.findIndex(
p => p.type === "frontmatter" && p.field === step.field
);
const patch = {
type: "frontmatter" as const,
field: step.field,
value: currentValue,
};
if (existingPatchIndex >= 0) {
this.state.patches[existingPatchIndex] = patch;
} else {
this.state.patches.push(patch);
}
}
}
}
goBack(): void {
const prevIndex = getPreviousStepIndex(this.state);
if (prevIndex !== null) {
this.state.currentStepIndex = prevIndex;
this.state.stepHistory.pop();
this.renderStep();
}
}
async applyPatches(): Promise<void> {
console.log("=== APPLY PATCHES ===", {
patchCount: this.state.patches.length,
patches: this.state.patches.map(p => ({
type: p.type,
field: p.field,
value: typeof p.value === "string"
? (p.value.length > 50 ? p.value.substring(0, 50) + "..." : p.value)
: p.value,
})),
collectedDataKeys: Array.from(this.state.collectedData.keys()),
loopContexts: Array.from(this.state.loopContexts.entries()).map(([key, items]) => ({
loopKey: key,
itemsCount: items.length,
})),
});
let updatedContent = this.fileContent;
// Apply frontmatter patches
for (const patch of this.state.patches) {
if (patch.type === "frontmatter" && patch.field) {
console.log("Apply frontmatter patch", {
field: patch.field,
value: patch.value,
});
updatedContent = this.applyFrontmatterPatch(
updatedContent,
patch.field,
patch.value
);
}
}
// Sync loopRuntimeStates to loopContexts for renderer
for (const [loopKey, loopState] of this.state.loopRuntimeStates.entries()) {
this.state.loopContexts.set(loopKey, loopState.items);
}
// 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
console.log("Write file", {
file: this.file.path,
contentLength: updatedContent.length,
contentPreview: updatedContent.substring(0, 200) + "...",
});
await this.app.vault.modify(this.file, updatedContent);
this.fileContent = updatedContent;
console.log("=== PATCHES APPLIED ===");
new Notice("Changes applied");
}
applyFrontmatterPatch(
content: string,
field: string,
value: unknown
): string {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch || !frontmatterMatch[1]) {
return content;
}
const frontmatter = frontmatterMatch[1];
const fieldRegex = new RegExp(`^${field}\\s*:.*$`, "m");
let updatedFrontmatter: string;
if (fieldRegex.test(frontmatter)) {
// Update existing field
updatedFrontmatter = frontmatter.replace(
fieldRegex,
`${field}: ${this.formatYamlValue(value)}`
);
} else {
// Add new field
updatedFrontmatter = `${frontmatter}\n${field}: ${this.formatYamlValue(value)}`;
}
return content.replace(
/^---\n([\s\S]*?)\n---/,
`---\n${updatedFrontmatter}\n---`
);
}
formatYamlValue(value: unknown): string {
if (typeof value === "string") {
if (
value.includes(":") ||
value.includes('"') ||
value.includes("\n") ||
value.trim() !== value
) {
return `"${value.replace(/"/g, '\\"')}"`;
}
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value === null || value === undefined) {
return "null";
}
return JSON.stringify(value);
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}