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 = new Map(); // Store preview mode state per step key private previewMode: Map = 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 { 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); 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; 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; 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 { 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(); } }