mindnet_obsidian/src/ui/InterviewWizardModal.ts

2278 lines
77 KiB
TypeScript
Raw Normal View History

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";
2026-01-16 19:25:55 +01:00
import {
type LoopRuntimeState,
createLoopState,
setDraftField,
isDraftDirty,
commitDraft,
startEdit,
deleteItem,
moveItemUp,
moveItemDown,
clearDraft,
2026-01-16 20:10:08 +01:00
setActiveItemStepIndex,
itemNextStep,
itemPrevStep,
resetItemWizard,
2026-01-16 19:25:55 +01:00
} 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 with heading level selector
const inputContainer = fieldContainer.createEl("div", {
cls: "mindnet-field__input",
});
inputContainer.style.width = "100%";
inputContainer.style.display = "flex";
inputContainer.style.gap = "0.5em";
inputContainer.style.alignItems = "center";
// Heading level dropdown (if enabled)
let headingLevel: number | null = null;
const headingLevelKey = `${step.key}_heading_level`;
const storedHeadingLevel = this.state.collectedData.get(headingLevelKey);
if (storedHeadingLevel !== undefined && typeof storedHeadingLevel === "number") {
headingLevel = storedHeadingLevel;
} else if (step.heading_level?.enabled) {
headingLevel = step.heading_level.default || 2;
}
if (step.heading_level?.enabled) {
const headingSelectorContainer = inputContainer.createEl("div");
headingSelectorContainer.style.flexShrink = "0";
const headingLabel = headingSelectorContainer.createEl("label", {
text: "H",
attr: { for: `heading-level-${step.key}` },
});
headingLabel.style.marginRight = "0.25em";
headingLabel.style.fontSize = "0.9em";
headingLabel.style.color = "var(--text-muted)";
const headingSelect = headingSelectorContainer.createEl("select", {
attr: { id: `heading-level-${step.key}` },
});
headingSelect.style.padding = "0.25em 0.5em";
headingSelect.style.border = "1px solid var(--background-modifier-border)";
headingSelect.style.borderRadius = "4px";
headingSelect.style.background = "var(--background-primary)";
headingSelect.style.fontSize = "0.9em";
headingSelect.style.minWidth = "3em";
// Add options H1-H6
for (let level = 1; level <= 6; level++) {
const option = headingSelect.createEl("option", {
text: `H${level}`,
attr: { value: String(level) },
});
if (headingLevel === level) {
option.selected = true;
}
}
headingSelect.onchange = () => {
const selectedLevel = parseInt(headingSelect.value, 10);
this.state.collectedData.set(headingLevelKey, selectedLevel);
};
}
// Text input (takes remaining space)
const textInputContainer = inputContainer.createEl("div");
textInputContainer.style.flex = "1";
textInputContainer.style.minWidth = "0";
const fieldSetting = new Setting(textInputContainer);
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;
}
2026-01-16 19:25:55 +01:00
// 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,
2026-01-16 20:10:08 +01:00
activeItemStepIndex: 0,
2026-01-16 19:25:55 +01:00
};
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,
2026-01-16 19:25:55 +01:00
itemsCount: loopState.items.length,
nestedStepsCount: step.items.length,
2026-01-16 19:25:55 +01:00
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);
}
2026-01-16 19:25:55 +01:00
// Title
containerEl.createEl("h2", {
text: step.label || "Loop",
});
2026-01-16 19:25:55 +01:00
// 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) {
2026-01-16 19:25:55 +01:00
const itemEntries = Object.entries(item as Record<string, unknown>);
if (itemEntries.length > 0) {
2026-01-16 19:25:55 +01:00
const previewText = itemEntries
.map(([key, value]) => {
const nestedStep = step.items.find(s => s.key === key);
const label = nestedStep?.label || key;
2026-01-16 19:25:55 +01:00
const strValue = String(value);
return `${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}`;
})
.join(", ");
2026-01-16 19:25:55 +01:00
preview.createSpan({ text: previewText });
} else {
2026-01-16 19:25:55 +01:00
preview.createSpan({ text: "Empty item" });
}
} else {
2026-01-16 19:25:55 +01:00
preview.createSpan({ text: String(item) });
}
2026-01-16 19:25:55 +01:00
// 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 = () => {
2026-01-16 20:10:08 +01:00
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();
}
2026-01-16 19:25:55 +01:00
};
// 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();
};
}
}
2026-01-16 19:25:55 +01:00
// Right pane: Editor
const rightPane = paneContainer.createEl("div", {
cls: "loop-editor-pane",
});
rightPane.style.width = "60%";
rightPane.style.flex = "1";
2026-01-16 20:10:08 +01:00
// 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) {
2026-01-16 20:10:08 +01:00
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",
});
2026-01-16 20:10:08 +01:00
const draftValue = loopState.draft[activeNestedStep.key];
this.renderLoopNestedStep(activeNestedStep, editorContainer, step.key, draftValue, (fieldId, value) => {
2026-01-16 19:25:55 +01:00
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
const newState = setDraftField(currentState, fieldId, value);
this.state.loopRuntimeStates.set(step.key, newState);
}
2026-01-16 19:25:55 +01:00
});
2026-01-16 20:10:08 +01:00
// 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";
2026-01-16 20:10:08 +01:00
// Item Back button
const itemBackBtn = itemNavLeft.createEl("button", {
text: "← Item Back",
2026-01-16 19:25:55 +01:00
});
2026-01-16 20:10:08 +01:00
itemBackBtn.disabled = activeStepIndex === 0;
itemBackBtn.onclick = () => {
2026-01-16 19:25:55 +01:00
const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
2026-01-16 20:10:08 +01:00
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);
2026-01-16 19:25:55 +01:00
this.state.loopRuntimeStates.set(step.key, newState);
2026-01-16 20:10:08 +01:00
// Update answers
this.state.loopContexts.set(step.key, newState.items);
// If we just created a new item, reset all nested loop states (items AND draft)
2026-01-16 20:10:08 +01:00
if (wasNewItem) {
for (const nestedStep of step.items) {
if (nestedStep.type === "loop") {
const nestedLoopKey = `${step.key}.${nestedStep.key}`;
// Reset nested loop state completely: clear items and draft
const resetState = {
items: [],
draft: {},
editIndex: null,
activeItemStepIndex: 0,
};
this.state.loopRuntimeStates.set(nestedLoopKey, resetState);
// Also clear preview mode for nested loop fields
const nestedLoopPreviewKeys: string[] = [];
for (const nestedNestedStep of (nestedStep as LoopStep).items) {
nestedLoopPreviewKeys.push(`${nestedLoopKey}.${nestedNestedStep.key}`);
2026-01-16 20:10:08 +01:00
}
nestedLoopPreviewKeys.forEach(key => this.previewMode.delete(key));
2026-01-16 20:10:08 +01:00
}
}
}
2026-01-16 19:25:55 +01:00
this.renderStep();
2026-01-16 20:10:08 +01:00
} else {
new Notice("Please enter at least one field");
2026-01-16 19:25:55 +01:00
}
};
2026-01-16 20:10:08 +01:00
// 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)";
}
2026-01-16 19:25:55 +01:00
}
}
}
2026-01-16 19:25:55 +01:00
/**
* 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;
2026-01-16 19:25:55 +01:00
if (nestedStep.type === "capture_text") {
// Field container
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
2026-01-16 19:25:55 +01:00
// Label
if (nestedStep.label) {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: nestedStep.label,
});
}
2026-01-16 19:25:55 +01:00
// Description/Prompt
if (nestedStep.prompt) {
const descEl = fieldContainer.createEl("div", {
cls: "mindnet-field__desc",
text: nestedStep.prompt,
});
}
// Container for editor/preview
2026-01-16 19:25:55 +01:00
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";
previewContainer.style.position = "relative";
// Add "Back to Edit" button in preview container
if (isPreviewMode) {
const backToEditBtn = previewContainer.createEl("button", {
text: "✏️ Zurück zum Bearbeiten",
cls: "mod-cta",
});
backToEditBtn.style.position = "absolute";
backToEditBtn.style.top = "0.5em";
backToEditBtn.style.right = "0.5em";
backToEditBtn.style.zIndex = "10";
backToEditBtn.onclick = () => {
// Get current value from preview (it's already in draft)
const currentValue = existingValue;
// Update draft with current value
onFieldChange(nestedStep.key, currentValue);
// Toggle preview mode off
this.previewMode.set(previewKey, false);
// Re-render to show editor
this.renderStep();
};
}
// Editor container
2026-01-16 19:25:55 +01:00
const textEditorContainer = editorContainer.createEl("div", {
cls: "markdown-editor-wrapper",
});
textEditorContainer.style.display = isPreviewMode ? "none" : "block";
2026-01-16 19:25:55 +01:00
textEditorContainer.style.width = "100%";
2026-01-16 19:25:55 +01:00
const textSetting = new Setting(textEditorContainer);
textSetting.settingEl.style.width = "100%";
textSetting.controlEl.style.width = "100%";
2026-01-16 19:25:55 +01:00
// 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;
2026-01-16 19:25:55 +01:00
textSetting.addTextArea((text) => {
textareaRef = text.inputEl;
2026-01-16 19:25:55 +01:00
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);
}
2026-01-16 19:25:55 +01:00
});
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 (only show in edit mode)
if (!isPreviewMode) {
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);
}
2026-01-16 19:25:55 +01:00
} else if (nestedStep.type === "capture_text_line") {
// Field container
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
2026-01-16 19:25:55 +01:00
// Label
if (nestedStep.label) {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: nestedStep.label,
});
}
2026-01-16 19:25:55 +01:00
// Description/Prompt
if (nestedStep.prompt) {
const descEl = fieldContainer.createEl("div", {
cls: "mindnet-field__desc",
text: nestedStep.prompt,
});
}
// Input container with heading level selector
2026-01-16 19:25:55 +01:00
const inputContainer = fieldContainer.createEl("div", {
cls: "mindnet-field__input",
});
inputContainer.style.width = "100%";
inputContainer.style.display = "flex";
inputContainer.style.gap = "0.5em";
inputContainer.style.alignItems = "center";
// Heading level dropdown (if enabled)
const headingLevelDraftKey = `${nestedStep.key}_heading_level`;
// Get loopState from the loopKey to access draft
const currentLoopState = this.state.loopRuntimeStates.get(loopKey);
let headingLevelDraftValue: number | undefined = undefined;
if (currentLoopState) {
headingLevelDraftValue = currentLoopState.draft[headingLevelDraftKey] as number | undefined;
// If not in draft and we're editing, try to get from the item being edited
if (headingLevelDraftValue === undefined && currentLoopState.editIndex !== null) {
const itemBeingEdited = currentLoopState.items[currentLoopState.editIndex];
if (itemBeingEdited && typeof itemBeingEdited === "object") {
headingLevelDraftValue = (itemBeingEdited as Record<string, unknown>)[headingLevelDraftKey] as number | undefined;
}
}
}
let headingLevel: number | null = null;
if (headingLevelDraftValue !== undefined && typeof headingLevelDraftValue === "number") {
headingLevel = headingLevelDraftValue;
} else if (nestedStep.heading_level?.enabled) {
headingLevel = nestedStep.heading_level.default || 2;
}
if (nestedStep.heading_level?.enabled) {
const headingSelectorContainer = inputContainer.createEl("div");
headingSelectorContainer.style.flexShrink = "0";
const headingLabel = headingSelectorContainer.createEl("label", {
text: "H",
attr: { for: `heading-level-${loopKey}-${nestedStep.key}` },
});
headingLabel.style.marginRight = "0.25em";
headingLabel.style.fontSize = "0.9em";
headingLabel.style.color = "var(--text-muted)";
const headingSelect = headingSelectorContainer.createEl("select", {
attr: { id: `heading-level-${loopKey}-${nestedStep.key}` },
});
headingSelect.style.padding = "0.25em 0.5em";
headingSelect.style.border = "1px solid var(--background-modifier-border)";
headingSelect.style.borderRadius = "4px";
headingSelect.style.background = "var(--background-primary)";
headingSelect.style.fontSize = "0.9em";
headingSelect.style.minWidth = "3em";
// Add options H1-H6
for (let level = 1; level <= 6; level++) {
const option = headingSelect.createEl("option", {
text: `H${level}`,
attr: { value: String(level) },
});
if (headingLevel === level) {
option.selected = true;
}
}
headingSelect.onchange = () => {
const selectedLevel = parseInt(headingSelect.value, 10);
onFieldChange(headingLevelDraftKey, selectedLevel);
};
}
// Text input (takes remaining space)
const textInputContainer = inputContainer.createEl("div");
textInputContainer.style.flex = "1";
textInputContainer.style.minWidth = "0";
const fieldSetting = new Setting(textInputContainer);
2026-01-16 19:25:55 +01:00
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";
}
2026-01-16 19:25:55 +01:00
fieldSetting.addText((text) => {
text.setValue(existingValue);
text.onChange((value) => {
onFieldChange(nestedStep.key, value);
});
text.inputEl.style.width = "100%";
text.inputEl.style.boxSizing = "border-box";
});
2026-01-16 19:25:55 +01:00
} 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,
});
}
2026-01-16 19:25:55 +01:00
// 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";
});
2026-01-16 20:10:08 +01:00
} else if (nestedStep.type === "loop") {
// Nested loop: render as a button to enter fullscreen mode
2026-01-16 20:10:08 +01:00
const nestedLoopItems = Array.isArray(draftValue) ? draftValue : [];
const nestedLoopKey = `${loopKey}.${nestedStep.key}`;
2026-01-16 20:10:08 +01:00
// 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",
2026-01-16 20:10:08 +01:00
});
// Label
2026-01-16 20:10:08 +01:00
if (nestedStep.label) {
const labelEl = fieldContainer.createEl("div", {
2026-01-16 20:10:08 +01:00
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,
2026-01-16 20:10:08 +01:00
});
countEl.style.marginBottom = "0.5em";
2026-01-16 20:10:08 +01:00
// Button to enter nested loop (fullscreen mode)
const enterBtn = fieldContainer.createEl("button", {
text: nestedLoopState.items.length > 0 ? "Bearbeiten" : "Hinzufügen",
cls: "mod-cta",
2026-01-16 20:10:08 +01:00
});
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;
2026-01-16 20:10:08 +01:00
// 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",
2026-01-16 20:10:08 +01:00
});
breadcrumbItem.style.background = "transparent";
breadcrumbItem.style.border = "none";
breadcrumbItem.style.cursor = "pointer";
breadcrumbItem.style.textDecoration = index < path.length - 1 ? "underline" : "none";
2026-01-16 20:10:08 +01:00
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;
2026-01-16 20:10:08 +01:00
}
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);
2026-01-16 20:10:08 +01:00
}
// Add the nested loop label (the one we're currently in)
if (nestedStep && nestedStep.type === "loop") {
contextPath.push(nestedStep.label || nestedStepKey || "");
2026-01-16 20:10:08 +01:00
}
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");
2026-01-16 20:10:08 +01:00
}
}
2026-01-16 20:10:08 +01:00
// 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)";
2026-01-16 20:10:08 +01:00
}
} 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}`,
2026-01-16 20:10:08 +01:00
});
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",
2026-01-16 20:10:08 +01:00
});
itemEl.style.padding = "0.75em";
itemEl.style.marginBottom = "0.5em";
itemEl.style.background = "var(--background-secondary)";
itemEl.style.borderRadius = "4px";
itemEl.style.cursor = "pointer";
2026-01-16 20:10:08 +01:00
// 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",
2026-01-16 20:10:08 +01:00
});
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();
};
2026-01-16 20:10:08 +01:00
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",
});
2026-01-16 20:10:08 +01:00
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);
}
2026-01-16 20:10:08 +01:00
}
}
}
}
);
2026-01-16 20:10:08 +01:00
// Navigation buttons (same as normal loop)
const subwizardNav = rightPane.createEl("div", {
cls: "loop-subwizard-navigation",
2026-01-16 20:10:08 +01:00
});
subwizardNav.style.display = "flex";
subwizardNav.style.gap = "0.5em";
subwizardNav.style.marginTop = "1em";
subwizardNav.style.justifyContent = "space-between";
2026-01-16 20:10:08 +01:00
const itemNavLeft = subwizardNav.createEl("div");
itemNavLeft.style.display = "flex";
itemNavLeft.style.gap = "0.5em";
2026-01-16 20:10:08 +01:00
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);
2026-01-16 20:10:08 +01:00
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);
2026-01-16 20:10:08 +01:00
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
const itemNavRight = subwizardNav.createEl("div");
itemNavRight.style.display = "flex";
itemNavRight.style.gap = "0.5em";
2026-01-16 20:10:08 +01:00
const doneBtn = itemNavRight.createEl("button", {
text: nestedLoopState.editIndex !== null ? "Save Item" : "Done",
2026-01-16 20:10:08 +01:00
cls: "mod-cta",
});
doneBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState && isDraftDirty(currentState.draft)) {
const newState = commitDraft(currentState);
2026-01-16 20:10:08 +01:00
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);
}
}
}
2026-01-16 20:10:08 +01:00
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);
2026-01-16 20:10:08 +01:00
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) });
}
2026-01-16 19:25:55 +01:00
// 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";
2026-01-16 19:25:55 +01:00
// 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);
2026-01-16 19:25:55 +01:00
// 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
);
}
}
2026-01-16 19:25:55 +01:00
// 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();
}
}