import { App, Modal, Notice, Setting, TFile, TextAreaComponent, TextComponent, } from "obsidian"; import type { InterviewProfile, InterviewStep } from "../interview/types"; import { type WizardState, createWizardState, getCurrentStep, getNextStepIndex, getPreviousStepIndex, canGoNext, canGoBack, type Patch, flattenSteps, } from "../interview/wizardState"; import { extractFrontmatterId } from "../parser/parseFrontmatter"; 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(); 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$/, ""); 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(); 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; } // Check if ID exists const hasId = this.checkIdExists(); if (!hasId) { const warningEl = contentEl.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(); }); }); } // Render step based on type switch (step.type) { case "instruction": this.renderInstructionStep(step, contentEl); break; case "capture_text": this.renderCaptureTextStep(step, contentEl); break; case "capture_frontmatter": this.renderCaptureFrontmatterStep(step, contentEl); break; case "loop": this.renderLoopStep(step, contentEl); break; case "llm_dialog": this.renderLLMDialogStep(step, contentEl); break; case "review": this.renderReviewStep(step, contentEl); break; } // Navigation buttons this.renderNavigation(contentEl); } 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) || ""; console.log("Render capture_text step", { stepKey: step.key, stepLabel: step.label, existingValue: existingValue, valueLength: existingValue.length, }); containerEl.createEl("h2", { text: step.label || "Enter Text", }); new Setting(containerEl).addTextArea((text) => { 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); }); text.inputEl.rows = 10; text.inputEl.focus(); }); } 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, }); containerEl.createEl("h2", { text: step.label || `Enter ${step.field}`, }); new Setting(containerEl) .setName(step.field) .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.focus(); }); } renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void { if (step.type !== "loop") return; const items = this.state.loopContexts.get(step.key) || []; const currentItemIndex = items.length; // Next item index console.log("Render loop step", { stepKey: step.key, stepLabel: step.label, itemsCount: items.length, nestedStepsCount: step.items.length, nestedStepTypes: step.items.map(s => s.type), currentItemIndex: currentItemIndex, }); containerEl.createEl("h2", { text: step.label || "Loop", }); // Show existing items if (items.length > 0) { const itemsList = containerEl.createEl("div", { cls: "loop-items-list" }); itemsList.createEl("h3", { text: `Gesammelte Items (${items.length}):` }); const ul = itemsList.createEl("ul"); for (let i = 0; i < items.length; i++) { const item = items[i]; const li = ul.createEl("li"); li.createSpan({ text: `Item ${i + 1}: ` }); if (typeof item === "object" && item !== null) { // Show readable format instead of JSON const itemEntries = Object.entries(item); if (itemEntries.length > 0) { const itemText = itemEntries .map(([key, value]) => { const nestedStep = step.items.find(s => s.key === key); const label = nestedStep?.label || key; return `${label}: ${String(value).substring(0, 50)}${String(value).length > 50 ? "..." : ""}`; }) .join(", "); li.createSpan({ text: itemText }); } else { li.createSpan({ text: "Empty" }); } } else { li.createSpan({ text: String(item) }); } } itemsList.createEl("p", { text: "Sie können weitere Items hinzufügen oder mit 'Next' fortfahren.", cls: "interview-note", }); } // Render nested steps for current item if (step.items.length > 0) { containerEl.createEl("h3", { text: items.length === 0 ? "First Item" : `Item ${items.length + 1}`, }); // Create a temporary state for the current loop item const itemDataKey = `${step.key}_item_${currentItemIndex}`; const itemData = new Map(); // Render each nested step for (const nestedStep of step.items) { if (nestedStep.type === "capture_text") { const existingValue = (itemData.get(nestedStep.key) as string) || ""; const inputKey = `${itemDataKey}_${nestedStep.key}`; containerEl.createEl("h4", { text: nestedStep.label || nestedStep.key, }); // Show prompt if available if (nestedStep.prompt) { containerEl.createEl("p", { text: nestedStep.prompt, cls: "interview-prompt", }); } new Setting(containerEl).addTextArea((text) => { text.setValue(existingValue); this.currentInputValues.set(inputKey, existingValue); text.onChange((value) => { this.currentInputValues.set(inputKey, value); itemData.set(nestedStep.key, value); }); text.inputEl.rows = 10; text.inputEl.style.width = "100%"; text.inputEl.style.minHeight = "150px"; }); } else if (nestedStep.type === "capture_frontmatter") { const existingValue = (itemData.get(nestedStep.key) as string) || ""; const inputKey = `${itemDataKey}_${nestedStep.key}`; containerEl.createEl("h4", { text: nestedStep.label || nestedStep.field || nestedStep.key, }); new Setting(containerEl) .setName(nestedStep.field || nestedStep.key) .addText((text) => { text.setValue(existingValue); this.currentInputValues.set(inputKey, existingValue); text.onChange((value) => { this.currentInputValues.set(inputKey, value); itemData.set(nestedStep.key, value); }); }); } } // Add item button new Setting(containerEl).addButton((button) => { button .setButtonText("Add Item") .setCta() .onClick(() => { // Collect all item data const itemDataObj: Record = {}; let hasData = false; for (const nestedStep of step.items) { const inputKey = `${itemDataKey}_${nestedStep.key}`; const value = this.currentInputValues.get(inputKey); if (value !== undefined && String(value).trim()) { itemDataObj[nestedStep.key] = value; hasData = true; } } if (!hasData) { new Notice("Please enter at least one field before adding an item"); return; } // Add to loop context items.push(itemDataObj); this.state.loopContexts.set(step.key, items); // Clear input values for this item for (const nestedStep of step.items) { const inputKey = `${itemDataKey}_${nestedStep.key}`; this.currentInputValues.delete(inputKey); } console.log("Loop item added", { stepKey: step.key, itemIndex: items.length - 1, itemData: itemDataObj, totalItems: items.length, }); new Notice(`Item ${items.length} added. You can add more or click Next to continue.`); // Re-render to show new item and reset form this.renderStep(); }); }); // Show hint if no items yet if (items.length === 0) { containerEl.createEl("p", { text: "⚠️ Please add at least one item before continuing", cls: "interview-warning", }); } } } renderLLMDialogStep(step: InterviewStep, containerEl: HTMLElement): void { if (step.type !== "llm_dialog") return; containerEl.createEl("h2", { text: step.label || "LLM Dialog", }); containerEl.createEl("p", { text: `Prompt: ${step.prompt}`, }); const existingValue = (this.state.collectedData.get(step.key) as string) || ""; new Setting(containerEl) .setName("Response") .addTextArea((text) => { text.setValue(existingValue).onChange((value) => { this.state.collectedData.set(step.key, value); }); text.inputEl.rows = 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 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 { const navContainer = containerEl.createEl("div", { cls: "interview-navigation", }); // 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, disable Next until at least one item is added const loopItems = isLoop ? (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); // 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 ); } } // Apply loop data to content for (const [loopKey, items] of this.state.loopContexts.entries()) { if (items.length > 0) { // Find the loop step to get section info const loopStep = this.state.profile.steps.find(s => s.type === "loop" && s.key === loopKey); if (loopStep && loopStep.type === "loop") { // Get section from first nested step if available (check for section property in YAML) const firstNestedStep = loopStep.items[0]; let sectionHeader = "## Items"; // Check if nested step has section property if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) { sectionHeader = firstNestedStep.section; } // Build content for loop items const loopContent: string[] = []; loopContent.push(sectionHeader); loopContent.push(""); for (let i = 0; i < items.length; i++) { const item = items[i]; if (item && typeof item === "object") { // Render each field from the item for (const [fieldKey, fieldValue] of Object.entries(item)) { if (fieldValue && String(fieldValue).trim()) { // Find nested step to get label const nestedStep = loopStep.items.find(s => s.key === fieldKey); const label = nestedStep?.label || fieldKey; // For multiple items, add item number if (items.length > 1) { loopContent.push(`#### ${label} ${i + 1}`); } loopContent.push(String(fieldValue)); loopContent.push(""); } } } } // Append loop content to file const loopContentStr = loopContent.join("\n"); updatedContent = updatedContent.trimEnd() + "\n\n" + loopContentStr; console.log("Apply loop content", { loopKey: loopKey, itemsCount: items.length, contentLength: loopContentStr.length, sectionHeader: sectionHeader, }); } } } // Write updated content 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(); } }