mindnet_obsidian/src/ui/InterviewWizardModal.ts

858 lines
26 KiB
TypeScript
Raw Normal View History

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<string, string> = 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<string, unknown>();
// 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<string, unknown> = {};
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<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
);
}
}
// 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();
}
}