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 { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "../mapping/edgeTypeSelector"; import { renderProfileToMarkdown, type RenderAnswers, type RenderOptions } from "../interview/renderer"; import type { GraphSchema } from "../mapping/graphSchema"; import { Vocabulary } from "../vocab/Vocabulary"; import type { SectionInfo } from "../interview/wizardState"; import { slugify } from "../interview/slugify"; import { NoteIndex } from "../entityPicker/noteIndex"; import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"; import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink"; import { buildSemanticMappings, type BuildResult } from "../mapping/semanticMappingBuilder"; import type { MindnetSettings } from "../settings"; import { InlineEdgeTypeModal, type InlineEdgeTypeResult } from "./InlineEdgeTypeModal"; import { SectionEdgesOverviewModal, type SectionEdgesOverviewResult } from "./SectionEdgesOverviewModal"; import { LinkTargetPickerModal } from "./LinkTargetPickerModal"; import { getSectionKeyForWizardContext } from "../interview/sectionKeyResolver"; import type { PendingEdgeAssignment } from "../interview/wizardState"; import { VocabularyLoader } from "../vocab/VocabularyLoader"; import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; import type { EdgeVocabulary } from "../vocab/types"; 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; profile: InterviewProfile; profileKey: string; settings?: MindnetSettings; // Optional settings for post-run edging plugin?: { ensureGraphSchemaLoaded?: () => Promise }; // Optional plugin instance // 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(); // Note index for entity picker (shared instance) private noteIndex: NoteIndex | null = null; // Vocabulary and schema for inline micro suggester private vocabulary: EdgeVocabulary | null = null; constructor( app: App, profile: InterviewProfile, file: TFile, fileContent: string, onSubmit: (result: WizardResult) => void, onSaveAndExit: (result: WizardResult) => void, settings?: MindnetSettings, plugin?: { ensureGraphSchemaLoaded?: () => Promise }, initialPendingEdgeAssignments?: PendingEdgeAssignment[] ) { super(app); // Validate profile if (!profile) { new Notice(`Interview profile not found`); throw new Error("Profile is required"); } this.profile = profile; this.profileKey = profile.key; this.settings = settings; this.plugin = plugin; // Log profile info const stepKinds = profile.steps?.map(s => s.type) || []; console.log("Wizard profile", { key: profile.key, stepCount: profile.steps?.length, kinds: stepKinds, edgingMode: profile.edging?.mode || "none", edging: profile.edging, // Full edging object for debugging profileKeys: Object.keys(profile), // All keys in profile initialPendingEdgeAssignments: initialPendingEdgeAssignments?.length || 0, }); // 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); // Inject initial pending edge assignments if provided if (initialPendingEdgeAssignments && initialPendingEdgeAssignments.length > 0) { this.state.pendingEdgeAssignments = [...initialPendingEdgeAssignments]; console.log("[InterviewWizardModal] Injected initial pending edge assignments:", initialPendingEdgeAssignments.length); } // Initialize note index for entity picker this.noteIndex = new NoteIndex(this.app); // 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, sectionSequenceLength: this.state.sectionSequence.length, generatedBlockIdsCount: this.state.generatedBlockIds.size, }); // WP-26: Track Section-Info während des Wizard-Durchlaufs if (step) { // Debug: Zeige alle Step-Felder if (step.type === "capture_text" || step.type === "capture_text_line") { const isCaptureText = step.type === "capture_text"; const captureTextStep = isCaptureText ? step as import("../interview/types").CaptureTextStep : null; const captureTextLineStep = !isCaptureText ? step as import("../interview/types").CaptureTextLineStep : null; console.log(`[WP-26] Step-Details für ${step.key}:`, { type: step.type, section: isCaptureText ? captureTextStep?.section : undefined, section_type: isCaptureText ? captureTextStep?.section_type : captureTextLineStep?.section_type, block_id: isCaptureText ? captureTextStep?.block_id : captureTextLineStep?.block_id, generate_block_id: isCaptureText ? captureTextStep?.generate_block_id : captureTextLineStep?.generate_block_id, references: isCaptureText ? captureTextStep?.references : captureTextLineStep?.references, heading_level: !isCaptureText ? captureTextLineStep?.heading_level : undefined, }); } console.log(`[WP-26] trackSectionInfo wird aufgerufen für Step ${step.key}, type: ${step.type}`); this.trackSectionInfo(step); } else { console.log(`[WP-26] Kein Step gefunden, trackSectionInfo wird nicht aufgerufen`); } 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 "entity_picker": this.renderEntityPickerStep(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"; previewContainer.style.position = "relative"; // Add "Back to Edit" button wrapper (outside preview content, so it doesn't get cleared) const backToEditWrapper = editorContainer.createEl("div", { cls: "preview-back-button-wrapper", }); backToEditWrapper.style.display = isPreviewMode ? "block" : "none"; backToEditWrapper.style.position = "absolute"; backToEditWrapper.style.top = "0.5em"; backToEditWrapper.style.right = "0.5em"; backToEditWrapper.style.zIndex = "20"; const backToEditBtn = backToEditWrapper.createEl("button", { text: "✏️ Zurück zum Bearbeiten", cls: "mod-cta", }); backToEditBtn.onclick = () => { // Get current value from stored input values const currentValue = this.currentInputValues.get(step.key) || existingValue; // Update stored value this.currentInputValues.set(step.key, String(currentValue)); this.state.collectedData.set(step.key, currentValue); // Toggle preview mode off this.previewMode.set(step.key, false); // Re-render to show editor this.renderStep(); }; // 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 ? "..." : ""), }); // WP-26: Speichere Fokus-Info vor State-Update, um Fokus-Verlust zu vermeiden let hadFocus = false; let selectionStart = 0; let selectionEnd = 0; if (textareaRef && document.activeElement === textareaRef) { hadFocus = true; selectionStart = textareaRef.selectionStart; selectionEnd = textareaRef.selectionEnd; } // Update stored value this.currentInputValues.set(step.key, value); this.state.collectedData.set(step.key, value); // WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war if (hadFocus && textareaRef) { setTimeout(() => { if (textareaRef && document.body.contains(textareaRef)) { textareaRef.focus(); textareaRef.setSelectionRange(selectionStart, selectionEnd); } }, 0); } // 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, async () => { // Get current value from textarea before toggling let currentValue = textarea.value; // If textarea is empty, try to get from stored values if (!currentValue || currentValue.trim() === "") { currentValue = this.currentInputValues.get(step.key) || existingValue || ""; } // Update stored value this.currentInputValues.set(step.key, currentValue); this.state.collectedData.set(step.key, currentValue); // Toggle preview mode const newPreviewMode = !this.previewMode.get(step.key); this.previewMode.set(step.key, newPreviewMode); // If switching to preview mode, render preview immediately if (newPreviewMode) { // Update preview container visibility previewContainer.style.display = "block"; textEditorContainer.style.display = "none"; backToEditWrapper.style.display = "block"; // Render preview content (use existingValue as fallback) const valueToRender = currentValue || existingValue || ""; await this.updatePreview(previewContainer, valueToRender); } else { // Switching back to edit mode previewContainer.style.display = "none"; textEditorContainer.style.display = "block"; backToEditWrapper.style.display = "none"; } }, async (app: App) => { // WP-26: Erweitere Entity-Picker um Block-ID-Vorschläge für Intra-Note-Links // Zeige zuerst Block-ID-Auswahl für Intra-Note-Links, dann normale Note-Auswahl const blockIds = Array.from(this.state.generatedBlockIds.keys()); if (blockIds.length > 0) { // Zeige Block-ID-Auswahl-Modal const blockIdModal = new Modal(app); blockIdModal.titleEl.textContent = "Block-ID oder Note auswählen"; blockIdModal.contentEl.createEl("p", { text: "Wähle eine Block-ID für Intra-Note-Link oder eine Note:", }); // Block-ID-Liste const blockIdList = blockIdModal.contentEl.createEl("div"); blockIdList.style.display = "flex"; blockIdList.style.flexDirection = "column"; blockIdList.style.gap = "0.5em"; blockIdList.style.marginBottom = "1em"; for (const blockId of blockIds) { const sectionInfo = this.state.generatedBlockIds.get(blockId); const btn = blockIdList.createEl("button", { text: `#^${blockId} - ${sectionInfo?.heading || blockId}`, }); btn.style.width = "100%"; btn.style.textAlign = "left"; btn.style.padding = "0.5em"; btn.onclick = async () => { blockIdModal.close(); // Erstelle Block-ID-Link const blockIdLink = `#^${blockId}`; // Prüfe, ob inline micro edging aktiviert ist const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; let linkText = `[[${blockIdLink}]]`; if (shouldRunInlineMicro) { // Get current step for section key resolution const currentStep = getCurrentStep(this.state); if (currentStep) { console.log("[WP-26] Starting inline micro edging for Block-ID:", blockId); const edgeType = await this.handleInlineMicroEdging(currentStep, blockIdLink, ""); if (edgeType && typeof edgeType === "string") { // Use [[rel:type|#^block-id]] format linkText = `[[rel:${edgeType}|${blockIdLink}]]`; } } } // Insert link const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); }; } // Separator blockIdModal.contentEl.createEl("hr"); // Button für normale Note-Auswahl const noteBtn = blockIdModal.contentEl.createEl("button", { text: "📄 Note auswählen...", cls: "mod-cta", }); noteBtn.style.width = "100%"; noteBtn.style.marginTop = "1em"; noteBtn.onclick = () => { blockIdModal.close(); // Öffne normalen Entity-Picker if (!this.noteIndex) { new Notice("Note index not available"); return; } new EntityPickerModal( app, this.noteIndex, async (result: EntityPickerResult) => { const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; const currentStep = getCurrentStep(this.state); let linkTarget = result.basename; if (shouldRunInlineMicro && currentStep) { const file = this.app.vault.getAbstractFileByPath(result.path); if (file && file instanceof TFile) { const picker = new LinkTargetPickerModal( this.app, file, result.basename, this.file?.path ); const pick = await picker.show(); if (pick) linkTarget = pick.linkTarget; } } let linkText = `[[${linkTarget}]]`; if (shouldRunInlineMicro && currentStep) { const edgeType = await this.handleInlineMicroEdging(currentStep, linkTarget, result.path); if (edgeType && typeof edgeType === "string") { linkText = `[[rel:${edgeType}|${linkTarget}]]`; } } const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); }; blockIdModal.open(); } else { if (!this.noteIndex) { new Notice("Note index not available"); return; } new EntityPickerModal( app, this.noteIndex, async (result: EntityPickerResult) => { const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; const currentStep = getCurrentStep(this.state); let linkTarget = result.basename; if (shouldRunInlineMicro && currentStep) { const file = this.app.vault.getAbstractFileByPath(result.path); if (file && file instanceof TFile) { const picker = new LinkTargetPickerModal( this.app, file, result.basename, this.file?.path ); const pick = await picker.show(); if (pick) linkTarget = pick.linkTarget; } } let linkText = `[[${linkTarget}]]`; if (shouldRunInlineMicro && currentStep) { const edgeType = await this.handleInlineMicroEdging(currentStep, linkTarget, result.path); if (edgeType && typeof edgeType === "string") { linkText = `[[rel:${edgeType}|${linkTarget}]]`; } } const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); } }, async (app: App, textarea: HTMLTextAreaElement) => { // Edge-Type-Selektor für Interview-Eingabefeld await this.handleEdgeTypeSelectorForTextarea(app, textarea, step, textEditorContainer); } ); textEditorContainer.insertBefore(toolbar, textEditorContainer.firstChild); } }, 10); // Render preview if in preview mode if (isPreviewMode && existingValue) { this.updatePreview(previewContainer, existingValue).then(() => { // After preview is rendered, ensure back button is visible backToEditWrapper.style.display = "block"; }); } } renderCaptureTextLineStep(step: InterviewStep, containerEl: HTMLElement): void { if (step.type !== "capture_text_line") return; const existingValue = (this.state.collectedData.get(step.key) as string) || ""; console.log("Render capture_text_line step", { stepKey: step.key, stepLabel: step.label, existingValue: existingValue, }); // Field container with vertical layout const fieldContainer = containerEl.createEl("div", { cls: "mindnet-field", }); // Label if (step.label) { const labelEl = fieldContainer.createEl("div", { cls: "mindnet-field__label", text: step.label, }); } // Description/Prompt if (step.prompt) { const descEl = fieldContainer.createEl("div", { cls: "mindnet-field__desc", text: step.prompt, }); } // Input container with heading level selector const inputContainer = fieldContainer.createEl("div", { cls: "mindnet-field__input", }); inputContainer.style.width = "100%"; inputContainer.style.display = "flex"; inputContainer.style.gap = "0.5em"; inputContainer.style.alignItems = "center"; // Heading level dropdown (if enabled) let headingLevel: number | null = null; const headingLevelKey = `${step.key}_heading_level`; const storedHeadingLevel = this.state.collectedData.get(headingLevelKey); if (storedHeadingLevel !== undefined && typeof storedHeadingLevel === "number") { headingLevel = storedHeadingLevel; } else if (step.heading_level?.enabled) { headingLevel = step.heading_level.default || 2; } if (step.heading_level?.enabled) { const headingSelectorContainer = inputContainer.createEl("div"); headingSelectorContainer.style.flexShrink = "0"; const headingLabel = headingSelectorContainer.createEl("label", { text: "H", attr: { for: `heading-level-${step.key}` }, }); headingLabel.style.marginRight = "0.25em"; headingLabel.style.fontSize = "0.9em"; headingLabel.style.color = "var(--text-muted)"; const headingSelect = headingSelectorContainer.createEl("select", { attr: { id: `heading-level-${step.key}` }, }); headingSelect.style.padding = "0.25em 0.5em"; headingSelect.style.border = "1px solid var(--background-modifier-border)"; headingSelect.style.borderRadius = "4px"; headingSelect.style.background = "var(--background-primary)"; headingSelect.style.fontSize = "0.9em"; headingSelect.style.minWidth = "3em"; // Add options H1-H6 for (let level = 1; level <= 6; level++) { const option = headingSelect.createEl("option", { text: `H${level}`, attr: { value: String(level) }, }); if (headingLevel === level) { option.selected = true; } } headingSelect.onchange = () => { const selectedLevel = parseInt(headingSelect.value, 10); this.state.collectedData.set(headingLevelKey, selectedLevel); }; } // Text input (takes remaining space) const textInputContainer = inputContainer.createEl("div"); textInputContainer.style.flex = "1"; textInputContainer.style.minWidth = "0"; const fieldSetting = new Setting(textInputContainer); fieldSetting.settingEl.style.width = "100%"; fieldSetting.controlEl.style.width = "100%"; // Hide the default label from Setting component const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; if (settingNameEl) { settingNameEl.style.display = "none"; } fieldSetting.addText((text) => { text.setValue(existingValue); // Store initial value this.currentInputValues.set(step.key, existingValue); text.onChange((value) => { console.log("Text line field changed", { stepKey: step.key, value: value, }); // Update stored value this.currentInputValues.set(step.key, value); this.state.collectedData.set(step.key, value); }); text.inputEl.style.width = "100%"; text.inputEl.style.boxSizing = "border-box"; text.inputEl.focus(); }); } /** * Update preview container with rendered markdown. */ private async updatePreview( container: HTMLElement, markdown: string ): Promise { console.log("updatePreview called", { markdownLength: markdown?.length || 0, markdownPreview: markdown?.substring(0, 100) || "(empty)", containerExists: !!container, containerId: container.id || "no-id", containerClasses: container.className, }); // Clear container but keep structure const existingChildren = Array.from(container.children); for (const child of existingChildren) { child.remove(); } if (!markdown || !markdown.trim()) { const emptyEl = container.createEl("p", { text: "(empty)", cls: "text-muted", }); console.log("Preview: showing empty message"); return; } // Use Obsidian's MarkdownRenderer // Create a component for the renderer const component = new Component(); try { console.log("Calling MarkdownRenderer.render", { markdownLength: markdown.length, filePath: this.file.path, containerTag: container.tagName, }); // IMPORTANT: Component must be loaded before rendering // This ensures proper lifecycle management component.load(); await MarkdownRenderer.render( this.app, markdown, container, this.file.path, component ); console.log("Preview rendered successfully", { containerChildren: container.children.length, containerHTML: container.innerHTML.substring(0, 200), }); // If container is still empty after rendering, try alternative approach if (container.children.length === 0) { console.warn("Container is empty after MarkdownRenderer.render, trying alternative"); // Fallback: create a simple div with the markdown as HTML (basic rendering) const fallbackDiv = container.createEl("div", { cls: "markdown-preview-fallback", }); // Simple markdown to HTML conversion (very basic) const html = markdown .replace(/\n\n/g, "

") .replace(/\n/g, "
") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1") .replace(/`(.+?)`/g, "$1"); fallbackDiv.innerHTML = `

${html}

`; console.log("Fallback preview rendered"); } // Clean up component when done (optional, but good practice) // Component will be cleaned up when modal closes } catch (error) { console.error("Error rendering preview", error); const errorEl = container.createEl("p", { text: `Error rendering preview: ${String(error)}`, cls: "text-error", }); // Also show the raw markdown for debugging const rawEl = container.createEl("pre", { text: markdown, cls: "text-muted", }); rawEl.style.fontSize = "0.8em"; rawEl.style.overflow = "auto"; } } 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"; } const applyFrontmatterPatch = (value: string | number) => { this.currentInputValues.set(step.key, String(value)); this.state.collectedData.set(step.key, value); const existingPatchIndex = this.state.patches.findIndex( (p) => p.type === "frontmatter" && p.field === step.field ); const patch = { type: "frontmatter" as const, field: step.field!, value, }; if (existingPatchIndex >= 0) { this.state.patches[existingPatchIndex] = patch; } else { this.state.patches.push(patch); } }; // Select/dropdown when input.kind === "select" and options are defined if (step.input?.kind === "select" && Array.isArray(step.input.options) && step.input.options.length > 0) { const options = step.input.options; const optionValues = options.map((o) => String(o.value)); const initialVal = defaultValue !== "" && optionValues.includes(String(defaultValue)) ? String(defaultValue) : String(options[0]?.value ?? ""); fieldSetting.addDropdown((dropdown) => { for (const opt of options) { dropdown.addOption(String(opt.value), opt.label); } dropdown.setValue(initialVal); applyFrontmatterPatch( options.find((o) => String(o.value) === initialVal)?.value ?? options[0]?.value ?? initialVal ); dropdown.onChange((value) => { const opt = options.find((o) => String(o.value) === value); const valueToStore = opt?.value ?? value; applyFrontmatterPatch(typeof valueToStore === "number" ? valueToStore : valueToStore); }); dropdown.selectEl.style.width = "100%"; }); } else { fieldSetting.addText((text) => { text.setValue(defaultValue); this.currentInputValues.set(step.key, defaultValue); text.onChange((value) => { this.currentInputValues.set(step.key, value); applyFrontmatterPatch(value); }); 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 previewParts: string[] = []; for (const [key, value] of itemEntries) { const nestedStep = step.items.find(s => s.key === key); const label = nestedStep?.label || key; const strValue = String(value); // WP-26: Zeige Block-ID wenn vorhanden let blockIdDisplay = ""; if (nestedStep && (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line")) { const captureStep = nestedStep as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { blockId = slugify(`${step.key}-${captureStep.key}`); } if (blockId) { blockIdDisplay = ` ^${blockId}`; } } previewParts.push(`${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}${blockIdDisplay}`); } preview.createSpan({ text: previewParts.join(", ") }); } 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); // WP-26: NICHT renderStep() hier aufrufen, um Fokus-Verlust zu vermeiden // Das Re-Rendering wird nur bei expliziten Navigationen durchgeführt } }); // 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) { // WP-26: Track Section-Info für aktuelle nested Steps bevor Navigation const activeNestedStep = step.items[activeStepIndex]; if (activeNestedStep) { this.trackSectionInfoForLoopItem(activeNestedStep, step.key, currentState.draft); } 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; // WP-26: Track Section-Info für alle nested Steps bevor Commit for (const nestedStep of step.items) { if (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line") { this.trackSectionInfoForLoopItem(nestedStep, step.key, currentState.draft); } } 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 (items AND draft) if (wasNewItem) { for (const nestedStep of step.items) { if (nestedStep.type === "loop") { const nestedLoopKey = `${step.key}.${nestedStep.key}`; // Reset nested loop state completely: clear items and draft const resetState = { items: [], draft: {}, editIndex: null, activeItemStepIndex: 0, }; this.state.loopRuntimeStates.set(nestedLoopKey, resetState); // Also clear preview mode for nested loop fields const nestedLoopPreviewKeys: string[] = []; for (const nestedNestedStep of (nestedStep as LoopStep).items) { nestedLoopPreviewKeys.push(`${nestedLoopKey}.${nestedNestedStep.key}`); } nestedLoopPreviewKeys.forEach(key => this.previewMode.delete(key)); } } } // WP-26: Nach dem Speichern bleibt der Loop-Step aktiv (kein automatischer Wechsel zum nächsten Step) // Der Benutzer kann weitere Items hinzufügen oder mit "Next" zum nächsten Step navigieren 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, }); } // WP-26: Block-ID-Anzeige für capture_text mit section const captureStep = nestedStep as import("../interview/types").CaptureTextStep; if (captureStep.section) { let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { blockId = slugify(`${loopKey}-${captureStep.key}`); } if (blockId) { const blockIdDisplay = fieldContainer.createEl("div", { cls: "block-id-display", text: `Block-ID: ^${blockId}`, }); blockIdDisplay.style.fontSize = "0.85em"; blockIdDisplay.style.color = "var(--text-muted)"; blockIdDisplay.style.marginBottom = "0.5em"; blockIdDisplay.style.fontStyle = "italic"; } } // 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"; previewContainer.style.position = "relative"; // Add "Back to Edit" button wrapper (outside preview content, so it doesn't get cleared) const backToEditWrapper = editorContainer.createEl("div", { cls: "preview-back-button-wrapper", }); backToEditWrapper.style.display = isPreviewMode ? "block" : "none"; backToEditWrapper.style.position = "absolute"; backToEditWrapper.style.top = "0.5em"; backToEditWrapper.style.right = "0.5em"; backToEditWrapper.style.zIndex = "20"; const backToEditBtn = backToEditWrapper.createEl("button", { text: "✏️ Zurück zum Bearbeiten", cls: "mod-cta", }); backToEditBtn.onclick = () => { // Get current value from draft (it's already saved) const currentLoopState = this.state.loopRuntimeStates.get(loopKey); const currentValue = currentLoopState?.draft[nestedStep.key] || existingValue; // Update draft with current value (ensure it's saved) onFieldChange(nestedStep.key, String(currentValue)); // Toggle preview mode off this.previewMode.set(previewKey, false); // Re-render to show editor this.renderStep(); }; // 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); // WP-26: Speichere Fokus-Info vor onChange, um Fokus-Verlust zu vermeiden let hadFocus = false; let selectionStart = 0; let selectionEnd = 0; text.onChange((value) => { // WP-26: Speichere Fokus-Info vor State-Update if (textareaRef && document.activeElement === textareaRef) { hadFocus = true; selectionStart = textareaRef.selectionStart; selectionEnd = textareaRef.selectionEnd; } onFieldChange(nestedStep.key, value); // WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war if (hadFocus && textareaRef) { setTimeout(() => { if (textareaRef && document.body.contains(textareaRef)) { textareaRef.focus(); textareaRef.setSelectionRange(selectionStart, selectionEnd); } }, 0); } // 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 (only show in edit mode) if (!isPreviewMode) { setTimeout(() => { const textarea = textEditorContainer.querySelector("textarea"); if (textarea) { const itemToolbar = createMarkdownToolbar( textarea, undefined, // No preview toggle for loop items (app: App) => { // Entity picker for loop items if (!this.noteIndex) { new Notice("Note index not available"); return; } new EntityPickerModal( app, this.noteIndex, async (result: EntityPickerResult) => { // Check if inline micro edging is enabled const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; let linkText = `[[${result.basename}]]`; if (shouldRunInlineMicro) { // nestedStep is already available in this scope const edgeType = await this.handleInlineMicroEdging(nestedStep, result.basename, result.path); if (edgeType && typeof edgeType === "string") { linkText = `[[rel:${edgeType}|${result.basename}]]`; } } const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); }, async (app: App, textarea: HTMLTextAreaElement) => { // Edge-Type-Selektor für Loop-Items await this.handleEdgeTypeSelectorForTextarea(app, textarea, nestedStep, textEditorContainer); } ); textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); } }, 10); } // Render preview if in preview mode if (isPreviewMode && existingValue) { this.updatePreview(previewContainer, existingValue).then(() => { // After preview is rendered, ensure back button is visible backToEditWrapper.style.display = "block"; }); } } 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 with heading level selector const inputContainer = fieldContainer.createEl("div", { cls: "mindnet-field__input", }); inputContainer.style.width = "100%"; inputContainer.style.display = "flex"; inputContainer.style.gap = "0.5em"; inputContainer.style.alignItems = "center"; // Heading level dropdown (if enabled) const headingLevelDraftKey = `${nestedStep.key}_heading_level`; // Get loopState from the loopKey to access draft const currentLoopState = this.state.loopRuntimeStates.get(loopKey); let headingLevelDraftValue: number | undefined = undefined; if (currentLoopState) { headingLevelDraftValue = currentLoopState.draft[headingLevelDraftKey] as number | undefined; // If not in draft and we're editing, try to get from the item being edited if (headingLevelDraftValue === undefined && currentLoopState.editIndex !== null) { const itemBeingEdited = currentLoopState.items[currentLoopState.editIndex]; if (itemBeingEdited && typeof itemBeingEdited === "object") { headingLevelDraftValue = (itemBeingEdited as Record)[headingLevelDraftKey] as number | undefined; } } } let headingLevel: number | null = null; if (headingLevelDraftValue !== undefined && typeof headingLevelDraftValue === "number") { headingLevel = headingLevelDraftValue; } else if (nestedStep.heading_level?.enabled) { headingLevel = nestedStep.heading_level.default || 2; } if (nestedStep.heading_level?.enabled) { const headingSelectorContainer = inputContainer.createEl("div"); headingSelectorContainer.style.flexShrink = "0"; const headingLabel = headingSelectorContainer.createEl("label", { text: "H", attr: { for: `heading-level-${loopKey}-${nestedStep.key}` }, }); headingLabel.style.marginRight = "0.25em"; headingLabel.style.fontSize = "0.9em"; headingLabel.style.color = "var(--text-muted)"; const headingSelect = headingSelectorContainer.createEl("select", { attr: { id: `heading-level-${loopKey}-${nestedStep.key}` }, }); headingSelect.style.padding = "0.25em 0.5em"; headingSelect.style.border = "1px solid var(--background-modifier-border)"; headingSelect.style.borderRadius = "4px"; headingSelect.style.background = "var(--background-primary)"; headingSelect.style.fontSize = "0.9em"; headingSelect.style.minWidth = "3em"; // Add options H1-H6 for (let level = 1; level <= 6; level++) { const option = headingSelect.createEl("option", { text: `H${level}`, attr: { value: String(level) }, }); if (headingLevel === level) { option.selected = true; } } headingSelect.onchange = () => { const selectedLevel = parseInt(headingSelect.value, 10); onFieldChange(headingLevelDraftKey, selectedLevel); }; } // Text input (takes remaining space) const textInputContainer = inputContainer.createEl("div"); textInputContainer.style.flex = "1"; textInputContainer.style.minWidth = "0"; const fieldSetting = new Setting(textInputContainer); 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"; } // WP-26: Block-ID-Anzeige für capture_text_line mit heading_level let blockIdDisplay: HTMLElement | null = null; if (nestedStep.heading_level?.enabled) { const captureStep = nestedStep as import("../interview/types").CaptureTextLineStep; let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { blockId = slugify(`${loopKey}-${captureStep.key}`); } if (blockId) { blockIdDisplay = fieldSetting.settingEl.createEl("div", { cls: "block-id-display", text: `Block-ID: ^${blockId}`, }); blockIdDisplay.style.fontSize = "0.85em"; blockIdDisplay.style.color = "var(--text-muted)"; blockIdDisplay.style.marginTop = "0.25em"; blockIdDisplay.style.fontStyle = "italic"; } } 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, undefined, (app: App) => { // Open entity picker modal for LLM dialog if (!this.noteIndex) { new Notice("Note index not available"); return; } new EntityPickerModal( this.app, this.noteIndex, (result: EntityPickerResult) => { insertWikilinkIntoTextarea(textarea, result.basename); } ).open(); }, async (app: App, textarea: HTMLTextAreaElement) => { // Edge-Type-Selektor für LLM-Dialog (optional) const currentStep = getCurrentStep(this.state); if (currentStep) { await this.handleEdgeTypeSelectorForTextarea(app, textarea, currentStep, llmEditorContainer); } } ); 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(async () => { if (isReview) { console.log("=== FINISH WIZARD (Apply & Finish) ==="); // Save current step data before finishing const currentStep = getCurrentStep(this.state); if (currentStep) { this.saveCurrentStepData(currentStep); } // WP-26: Vocabulary ggf. laden (wird sonst erst in applyPatches geladen – dann wäre Dialog schon übersprungen) if (!this.vocabulary && this.settings?.edgeVocabularyPath) { try { const vocabText = await VocabularyLoader.loadText( this.app, this.settings.edgeVocabularyPath ); this.vocabulary = parseEdgeVocabulary(vocabText); } catch (e) { console.warn("[WP-26] Vocabulary konnte nicht geladen werden (Kanten-Dialog):", e); } } // WP-26: Übersichts-Modal immer anzeigen, wenn Vocabulary geladen ist (einmal alle Kanten prüfen/ändern). // Verhindert die langsame Schritt-für-Schritt-Bearbeitung; Standardkanten können unverändert übernommen werden. let sectionEdgeTypes: Map> | undefined = undefined; let noteEdgesFromModal: Map> | undefined = undefined; if (this.vocabulary) { try { const graphSchema = this.plugin?.ensureGraphSchemaLoaded ? await this.plugin.ensureGraphSchemaLoaded() : null; const overviewModal = new SectionEdgesOverviewModal( this.app, this.state.sectionSequence, this.vocabulary, graphSchema, this.state.collectedData, this.file?.path // Quelldatei für Link-Auflösung (Zieltyp ermitteln) ); const result = await overviewModal.show(); // Bei OK immer übernehmen (auch wenn nichts geändert wurde), damit Renderer korrekte Types/Inversen nutzt if (!result.cancelled) { sectionEdgeTypes = result.sectionEdges; noteEdgesFromModal = result.noteEdges; console.log("[WP-26] Edge-Types aus Übersicht übernommen:", { sectionEdgesCount: result.sectionEdges.size, noteEdgesCount: result.noteEdges.size, sectionEdges: Array.from(result.sectionEdges.entries()).map(([from, toMap]) => ({ from, to: Array.from(toMap.entries()), })), noteEdges: Array.from(result.noteEdges.entries()).map(([from, toMap]) => ({ from, to: Array.from(toMap.entries()), })), }); // WP-26: Note-Edges für Post-Run-Edging übernehmen for (const [fromBlockId, toMap] of result.noteEdges.entries()) { for (const [toNote, edgeType] of toMap.entries()) { const sectionInfo = fromBlockId === "ROOT" ? null : this.state.sectionSequence.find(s => s.blockId === fromBlockId); const sectionKey = sectionInfo ? `H${sectionInfo.heading.match(/^#+/)?.length || 2}:${sectionInfo.heading.replace(/^#+\s+/, "")}` : "ROOT"; this.state.pendingEdgeAssignments.push({ filePath: this.file.path, sectionKey: sectionKey, linkBasename: toNote, chosenRawType: edgeType, createdAt: Date.now(), }); } } } } catch (e) { console.warn("[WP-26] Fehler beim Anzeigen des Section-Edges-Übersichts-Modals:", e); // Continue without section edge types } } await this.applyPatches(sectionEdgeTypes, noteEdgesFromModal); // WP-26: Post-Run-Edging nur noch für Note-Edges (nicht für Section-Edges) // Section-Edges werden jetzt vollständig über die Übersichtsseite verwaltet // Note-Edges werden weiterhin über post_run edging verarbeitet const edgingMode = this.profile.edging?.mode; console.log("[Mindnet] Checking edging mode:", { profileKey: this.profile.key, edgingMode: edgingMode, hasEdging: !!this.profile.edging, pendingAssignments: this.state.pendingEdgeAssignments.length, }); // Support: post_run, both (inline_micro + post_run) // Nur ausführen, wenn Note-Edges vorhanden sind (nicht für Section-Edges) const shouldRunPostRun = (edgingMode === "post_run" || edgingMode === "both") && this.state.pendingEdgeAssignments.length > 0; if (shouldRunPostRun) { console.log("[Mindnet] Starting post-run edging für Note-Edges"); await this.runPostRunEdging(); } else { console.log("[Mindnet] Post-run edging skipped (mode:", edgingMode || "none", ", pending:", this.state.pendingEdgeAssignments.length, ")"); } 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(async () => { console.log("=== SAVE & EXIT ==="); await this.applyPatches(); this.onSaveAndExit({ applied: true, patches: this.state.patches, }); this.close(); }); }); } /** * Handle inline micro edging after entity picker selection. * Returns the selected edge type, or null if skipped/cancelled. */ /** * Handle edge type selector for textarea in interview mode. */ private async handleEdgeTypeSelectorForTextarea( app: App, textarea: HTMLTextAreaElement, step: InterviewStep, containerEl: HTMLElement ): Promise { try { const content = textarea.value; const context = detectEdgeSelectorContext(textarea, content); if (!context) { new Notice("Kontext konnte nicht erkannt werden"); return; } // Update callback to sync with state const onUpdate = (newContent: string) => { textarea.value = newContent; // Update stored value this.currentInputValues.set(step.key, newContent); this.state.collectedData.set(step.key, newContent); }; // For interview mode, we don't have a file, so pass null // We'll need to get source/target types differently if (!this.settings) { new Notice("Einstellungen nicht verfügbar"); return; } await changeEdgeTypeForLinks( app, textarea, null, // No file in interview mode this.settings, context, this.plugin?.ensureGraphSchemaLoaded ? { ensureGraphSchemaLoaded: async () => { if (this.plugin?.ensureGraphSchemaLoaded) { return await this.plugin.ensureGraphSchemaLoaded(); } return null; } } : undefined, onUpdate ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Fehler beim Ändern des Edge-Types: ${msg}`); console.error(e); } } private async handleInlineMicroEdging( step: InterviewStep, linkBasename: string, linkPath: string ): Promise { if (!this.settings) { console.warn("[Mindnet] Cannot run inline micro edging: settings not provided"); return null; } try { // Load vocabulary if not already loaded if (!this.vocabulary) { try { const vocabText = await VocabularyLoader.loadText( this.app, this.settings.edgeVocabularyPath ); this.vocabulary = parseEdgeVocabulary(vocabText); } catch (e) { console.warn("[Mindnet] Failed to load vocabulary for inline micro:", e); // Continue without vocabulary } } // Get graph schema let graphSchema = null; if (this.plugin?.ensureGraphSchemaLoaded) { graphSchema = await this.plugin.ensureGraphSchemaLoaded(); } // WP-26: Prüfe, ob es eine Block-ID-Referenz ist ([[#^block-id]]) let sourceType: string | undefined; let targetType: string | undefined; let sourceNoteId: string | undefined; let targetNoteId: string | undefined; const blockIdMatch = linkBasename.match(/^#\^(.+)$/); if (blockIdMatch && blockIdMatch[1]) { // Intra-Note-Edge: Block-ID-Referenz const blockId = blockIdMatch[1]; console.log(`[WP-26] Block-ID-Referenz erkannt: ${blockId}`); // Finde Source-Section (aktuelle Section aus Step) const currentStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; let currentBlockId: string | null = null; if (currentStep.block_id) { currentBlockId = currentStep.block_id; } else if (currentStep.generate_block_id) { currentBlockId = slugify(currentStep.key); } if (currentBlockId) { const sourceSection = this.state.generatedBlockIds.get(currentBlockId); if (sourceSection) { // WP-26: Verwende effektiven Section-Type (mit Heading-Level-basierter Fallback-Logik) const sectionIndex = this.state.sectionSequence.findIndex(s => s.blockId === currentBlockId); if (sectionIndex >= 0) { sourceType = this.getEffectiveSectionType(sourceSection, sectionIndex); console.log(`[WP-26] Source-Type aus Section (effektiv): ${sourceType}`); } else { sourceType = sourceSection.sectionType || sourceSection.noteType; console.log(`[WP-26] Source-Type aus Section: ${sourceType}`); } } } else { // Fallback: Verwende Note-Type sourceType = this.state.profile.note_type; } // Finde Target-Section (Block-ID aus generatedBlockIds) const targetSection = this.state.generatedBlockIds.get(blockId); if (targetSection) { targetType = targetSection.sectionType || targetSection.noteType; console.log(`[WP-26] Target-Type aus Section: ${targetType}`); } else { console.warn(`[WP-26] Block-ID "${blockId}" nicht in generatedBlockIds gefunden`); // Fallback: Verwende Note-Type targetType = this.state.profile.note_type; } // Für Intra-Note-Edges: sourceNoteId = targetNoteId (gleiche Note) const sourceContent = this.fileContent; const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); sourceNoteId = extractFrontmatterId(sourceContent) || undefined; targetNoteId = sourceNoteId; // Gleiche Note } else { // Inter-Note-Edge: Normale Wikilink-Referenz // WP-26: Verwende Section-Type statt Note-Type const currentStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep; let currentBlockId: string | null = null; if (currentStep.block_id) { currentBlockId = currentStep.block_id; } else if (currentStep.generate_block_id) { currentBlockId = slugify(currentStep.key); } // Versuche Section-Type zu ermitteln if (currentBlockId) { const sourceSection = this.state.generatedBlockIds.get(currentBlockId); if (sourceSection) { // WP-26: Verwende effektiven Section-Type (mit Heading-Level-basierter Fallback-Logik) const sectionIndex = this.state.sectionSequence.findIndex(s => s.blockId === currentBlockId); if (sectionIndex >= 0) { sourceType = this.getEffectiveSectionType(sourceSection, sectionIndex); console.log(`[WP-26] Source-Type aus Section (effektiv): ${sourceType}`); } else { sourceType = sourceSection.sectionType || sourceSection.noteType; console.log(`[WP-26] Source-Type aus Section: ${sourceType}`); } } else { // Versuche Section-Type aus sectionSequence zu finden (auch wenn keine Block-ID vorhanden) const sectionInfo = this.state.sectionSequence.find(s => s.stepKey === step.key); if (sectionInfo) { const sectionIndex = this.state.sectionSequence.findIndex(s => s.stepKey === step.key); if (sectionIndex >= 0) { sourceType = this.getEffectiveSectionType(sectionInfo, sectionIndex); console.log(`[WP-26] Source-Type aus sectionSequence (effektiv): ${sourceType}`); } else { sourceType = sectionInfo.sectionType || sectionInfo.noteType; console.log(`[WP-26] Source-Type aus sectionSequence: ${sourceType}`); } } } } else { // Versuche Section-Type aus sectionSequence zu finden (auch wenn keine Block-ID vorhanden) const sectionInfo = this.state.sectionSequence.find(s => s.stepKey === step.key); if (sectionInfo) { const sectionIndex = this.state.sectionSequence.findIndex(s => s.stepKey === step.key); if (sectionIndex >= 0) { sourceType = this.getEffectiveSectionType(sectionInfo, sectionIndex); console.log(`[WP-26] Source-Type aus sectionSequence (effektiv, kein Block-ID): ${sourceType}`); } else { sourceType = sectionInfo.sectionType || sectionInfo.noteType; console.log(`[WP-26] Source-Type aus sectionSequence (kein Block-ID): ${sourceType}`); } } } // Fallback: Verwende Note-Type aus Frontmatter if (!sourceType) { const sourceContent = this.fileContent; const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); sourceNoteId = extractFrontmatterId(sourceContent) || undefined; const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/); if (sourceFrontmatter && sourceFrontmatter[1]) { const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m); if (typeMatch && typeMatch[1]) { sourceType = typeMatch[1].trim(); console.log(`[WP-26] Source-Type aus Frontmatter (Fallback): ${sourceType}`); } } } else { // sourceNoteId bereits setzen, auch wenn Section-Type gefunden wurde const sourceContent = this.fileContent; const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); sourceNoteId = extractFrontmatterId(sourceContent) || undefined; } // Zieltyp ermitteln: Note-Type (ganze Note) oder Sektionstyp (bei Note#Abschnitt) try { const { resolveTargetTypeForNoteLink } = await import("../interview/targetTypeResolver"); const resolved = await resolveTargetTypeForNoteLink( this.app, linkBasename, this.file?.path || "" ); targetType = resolved.targetType ?? undefined; const targetFile = this.app.vault.getAbstractFileByPath(linkPath); if (targetFile && targetFile instanceof TFile) { const targetContent = await this.app.vault.read(targetFile); targetNoteId = extractFrontmatterId(targetContent) || undefined; } } catch (e) { console.debug("[Mindnet] Could not resolve target type for inline micro:", e); } } // Show inline edge type modal (linkBasename kann "Note" oder "Note#Abschnitt" sein) const modal = new InlineEdgeTypeModal( this.app, linkBasename, this.vocabulary, graphSchema, this.settings, sourceNoteId, targetNoteId, sourceType, targetType ); const result: InlineEdgeTypeResult = await modal.show(); // Handle result if (result.cancelled) { // Cancel: keep link but no assignment return null; } if (result.chosenRawType) { console.log("[Mindnet] Selected edge type for inline link:", { linkBasename, edgeType: result.chosenRawType, }); return result.chosenRawType; } // Skip: no assignment created return null; } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[Mindnet] Failed to handle inline micro edging:", e); new Notice(`Failed to handle inline edge type selection: ${msg}`); return null; } } /** * Run semantic mapping builder after interview finish (post_run mode). */ private async runPostRunEdging(): Promise { console.log("[Mindnet] runPostRunEdging called", { hasSettings: !!this.settings, hasPlugin: !!this.plugin, file: this.file.path, }); if (!this.settings) { console.warn("[Mindnet] Cannot run post-run edging: settings not provided"); new Notice("Edging: Settings nicht verfügbar"); return; } try { console.log("[Mindnet] Starting semantic mapping builder"); // Create settings override from profile edging config const edgingSettings: MindnetSettings = { ...this.settings, mappingWrapperCalloutType: this.profile.edging?.wrapperCalloutType || this.settings.mappingWrapperCalloutType, mappingWrapperTitle: this.profile.edging?.wrapperTitle || this.settings.mappingWrapperTitle, mappingWrapperFolded: this.profile.edging?.wrapperFolded !== undefined ? this.profile.edging.wrapperFolded : this.settings.mappingWrapperFolded, // Use prompt mode for unmapped links (default behavior) unassignedHandling: "prompt", // Don't overwrite existing mappings unless explicitly allowed allowOverwriteExistingMappings: false, }; // Run semantic mapping builder with pending assignments const result: BuildResult = await buildSemanticMappings( this.app, this.file, edgingSettings, false, // allowOverwrite: false (respect existing) this.plugin, { pendingAssignments: this.state.pendingEdgeAssignments, } ); // Show summary notice const summary = [ `Edger: Sections updated ${result.sectionsWithMappings}`, `kept ${result.existingMappingsKept}`, `changed ${result.newMappingsAssigned}`, ]; if (result.unmappedLinksSkipped > 0) { summary.push(`skipped ${result.unmappedLinksSkipped}`); } new Notice(summary.join(", ")); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to run semantic mapping builder: ${msg}`); console.error("[Mindnet] Failed to run post-run edging:", e); } } 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(); } } renderEntityPickerStep(step: InterviewStep, containerEl: HTMLElement): void { if (step.type !== "entity_picker") return; const existingValue = this.state.collectedData.get(step.key) as string | undefined; const selectedBasename = existingValue || ""; // Field container 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%"; inputContainer.style.display = "flex"; inputContainer.style.gap = "0.5em"; inputContainer.style.alignItems = "center"; // Readonly display of selected note const displayEl = inputContainer.createEl("div", { cls: "entity-picker-display", }); displayEl.style.flex = "1"; displayEl.style.padding = "0.5em"; displayEl.style.border = "1px solid var(--background-modifier-border)"; displayEl.style.borderRadius = "4px"; displayEl.style.background = "var(--background-secondary)"; displayEl.style.minHeight = "2.5em"; displayEl.style.display = "flex"; displayEl.style.alignItems = "center"; if (selectedBasename) { displayEl.textContent = `[[${selectedBasename}]]`; displayEl.style.color = "var(--text-normal)"; } else { displayEl.textContent = "(No note selected)"; displayEl.style.color = "var(--text-muted)"; displayEl.style.fontStyle = "italic"; } // Pick button const pickBtn = inputContainer.createEl("button", { text: selectedBasename ? "Change…" : "Pick note…", cls: "mod-cta", }); pickBtn.style.flexShrink = "0"; pickBtn.onclick = () => { if (!this.noteIndex) { new Notice("Note index not available"); return; } new EntityPickerModal( this.app, this.noteIndex, async (result: EntityPickerResult) => { // Check if inline micro edging is enabled // Support: inline_micro, both (inline_micro + post_run) const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; console.log("[Mindnet] Entity picker result:", { edgingMode, shouldRunInlineMicro, inlineMicroEnabled: this.settings?.inlineMicroEnabled, stepKey: step.key, }); let linkText = `[[${result.basename}]]`; if (shouldRunInlineMicro) { console.log("[Mindnet] Starting inline micro edging"); const edgeType = await this.handleInlineMicroEdging(step, result.basename, result.path); if (edgeType && typeof edgeType === "string") { // Use [[rel:type|link]] format linkText = `[[rel:${edgeType}|${result.basename}]]`; } } else { console.log("[Mindnet] Inline micro edging skipped", { edgingMode, inlineMicroEnabled: this.settings?.inlineMicroEnabled, }); } // Store link text (with rel: prefix if edge type was selected) this.state.collectedData.set(step.key, linkText); // Optionally store path for future use this.state.collectedData.set(`${step.key}_path`, result.path); this.renderStep(); } ).open(); }; } async applyPatches( sectionEdgeTypes?: Map>, noteEdges?: Map> ): 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); } // WP-26: Lade Vocabulary und GraphSchema für automatische Edge-Vorschläge let vocabulary: Vocabulary | null = null; let graphSchema: GraphSchema | null = null; try { // Lade Vocabulary if (!this.vocabulary && this.settings) { const vocabText = await VocabularyLoader.loadText( this.app, this.settings.edgeVocabularyPath ); this.vocabulary = parseEdgeVocabulary(vocabText); } if (this.vocabulary) { vocabulary = new Vocabulary(this.vocabulary); } // Lade GraphSchema if (this.plugin?.ensureGraphSchemaLoaded) { graphSchema = await this.plugin.ensureGraphSchemaLoaded(); } } catch (e) { console.warn("[WP-26] Fehler beim Laden von Vocabulary/GraphSchema für Renderer:", e); // Continue without vocabulary/schema - Renderer funktioniert auch ohne } // Use renderer to generate markdown from collected data const answers: RenderAnswers = { collectedData: this.state.collectedData, loopContexts: this.state.loopContexts, sectionSequence: Array.from(this.state.sectionSequence), // WP-26: Section-Sequenz übergeben }; // WP-26: Render-Optionen für Section-Types, Edge-Vorschläge und Note-Edges (inkl. [[Note#Abschnitt]]) const renderOptions: RenderOptions = { graphSchema: graphSchema, vocabulary: vocabulary, noteType: this.state.profile.note_type, sectionEdgeTypes: sectionEdgeTypes, noteEdges: noteEdges, }; // WP-26: Debug-Log für Section-Sequenz console.log(`[WP-26] Section-Sequenz vor Rendering:`, { count: this.state.sectionSequence.length, sections: this.state.sectionSequence.map(s => ({ stepKey: s.stepKey, blockId: s.blockId, sectionType: s.sectionType, heading: s.heading, })), }); const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers, renderOptions); 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) + "...", }); // WP-26: Debug-Log für generiertes Markdown console.log(`[WP-26] Vollständiges generiertes Markdown:`, renderedMarkdown); } // 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); } /** * WP-26: Track Section-Info für Loop-Items. * Wird aufgerufen, wenn ein Loop-Item gespeichert wird oder zwischen Steps navigiert wird. */ private trackSectionInfoForLoopItem( nestedStep: InterviewStep, loopKey: string, draft: Record ): void { // Nur für capture_text und capture_text_line Steps if (nestedStep.type !== "capture_text" && nestedStep.type !== "capture_text_line") { return; } // Type-Guards für sichere Zugriffe const isCaptureText = nestedStep.type === "capture_text"; const isCaptureTextLine = nestedStep.type === "capture_text_line"; const captureTextStep = isCaptureText ? nestedStep as import("../interview/types").CaptureTextStep : null; const captureTextLineStep = isCaptureTextLine ? nestedStep as import("../interview/types").CaptureTextLineStep : null; // Für capture_text: Nur wenn Section vorhanden ist if (isCaptureText && !captureTextStep?.section) { return; } // Für capture_text_line: Nur wenn heading_level enabled ist if (isCaptureTextLine && !captureTextLineStep?.heading_level?.enabled) { return; } // Verwende die richtige Step-Variable für die weitere Verarbeitung const captureStep = isCaptureText ? captureTextStep! : captureTextLineStep!; // WP-26: Block-ID ermitteln // Für Loop-Items wird die Block-ID im Renderer mit Item-Index nummeriert // Hier tracken wir nur die Basis-Block-ID ohne Index let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { // Für Loop-Items: Verwende nur Step-Key (Index wird im Renderer hinzugefügt) // Die tatsächliche Block-ID wird im Renderer mit Item-Index generiert blockId = slugify(captureStep.key); } // WP-26: Section-Type ermitteln const sectionType = captureStep.section_type || null; const noteType = this.state.profile.note_type; // WP-26: Heading-Text ermitteln let heading = ""; if (isCaptureText && captureTextStep?.section) { // Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext") heading = captureTextStep.section.replace(/^#+\s+/, ""); } else if (isCaptureTextLine) { // Für capture_text_line: Heading aus Draft-Wert const draftValue = draft[captureStep.key]; if (draftValue && typeof draftValue === "string") { heading = draftValue; } else { heading = captureStep.key; } } // WP-26: Section-Info erstellen const sectionInfo: SectionInfo = { stepKey: `${loopKey}.${captureStep.key}`, // Eindeutiger Key für Loop-Items sectionType: sectionType, heading: heading, blockId: blockId, noteType: noteType, }; // WP-26: Block-ID tracken (nur wenn vorhanden) if (blockId) { this.state.generatedBlockIds.set(blockId, sectionInfo); console.log(`[WP-26] Block-ID für Loop-Item getrackt: ${blockId} für Step ${loopKey}.${captureStep.key}`); } // WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden) // Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate) const existingIndex = this.state.sectionSequence.findIndex( s => s.stepKey === sectionInfo.stepKey && s.blockId === blockId ); if (existingIndex === -1) { // Neue Section hinzufügen this.state.sectionSequence.push(sectionInfo); console.log(`[WP-26] Section für Loop-Item zur Sequenz hinzugefügt: ${sectionInfo.stepKey} (Block-ID: ${blockId || "keine"})`); } else { // Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat) this.state.sectionSequence[existingIndex] = sectionInfo; console.log(`[WP-26] Section für Loop-Item aktualisiert: ${sectionInfo.stepKey}`); } } /** * WP-26: Track Section-Info während des Wizard-Durchlaufs. * Wird aufgerufen, wenn ein Step mit section/section_type gerendert wird. */ private trackSectionInfo(step: InterviewStep): void { // Nur für capture_text und capture_text_line Steps mit section if (step.type !== "capture_text" && step.type !== "capture_text_line") { return; } // Type-Guards für sichere Zugriffe const isCaptureText = step.type === "capture_text"; const isCaptureTextLine = step.type === "capture_text_line"; const captureTextStep = isCaptureText ? step as import("../interview/types").CaptureTextStep : null; const captureTextLineStep = isCaptureTextLine ? step as import("../interview/types").CaptureTextLineStep : null; console.log(`[WP-26] trackSectionInfo aufgerufen für Step ${step.key}`, { type: step.type, hasSection: isCaptureText ? !!captureTextStep?.section : false, section: isCaptureText ? captureTextStep?.section : undefined, hasSectionType: isCaptureText ? !!captureTextStep?.section_type : !!captureTextLineStep?.section_type, sectionType: isCaptureText ? captureTextStep?.section_type : captureTextLineStep?.section_type, hasBlockId: isCaptureText ? !!captureTextStep?.block_id : !!captureTextLineStep?.block_id, blockId: isCaptureText ? captureTextStep?.block_id : captureTextLineStep?.block_id, generateBlockId: isCaptureText ? captureTextStep?.generate_block_id : captureTextLineStep?.generate_block_id, hasHeadingLevel: isCaptureTextLine ? !!captureTextLineStep?.heading_level?.enabled : false, }); // Nur wenn Section vorhanden ist (für capture_text) if (isCaptureText && !captureTextStep?.section) { console.log(`[WP-26] Step ${step.key} hat keine section, überspringe`); return; } // Für capture_text_line: Nur wenn heading_level enabled ist if (isCaptureTextLine && !captureTextLineStep?.heading_level?.enabled) { console.log(`[WP-26] Step ${step.key} hat kein heading_level enabled, überspringe`); return; } // Verwende die richtige Step-Variable für die weitere Verarbeitung const captureStep = isCaptureText ? captureTextStep! : captureTextLineStep!; // WP-26: Block-ID ermitteln let blockId: string | null = null; if (captureStep.block_id) { blockId = captureStep.block_id; } else if (captureStep.generate_block_id) { blockId = slugify(captureStep.key); } // WP-26: Section-Type ermitteln const sectionType = captureStep.section_type || null; const noteType = this.state.profile.note_type; // WP-26: Heading-Text ermitteln let heading = ""; if (isCaptureText && captureTextStep?.section) { // Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext") heading = captureTextStep.section.replace(/^#+\s+/, ""); } else if (isCaptureTextLine) { // Für capture_text_line: Heading wird später aus dem eingegebenen Text generiert // Verwende Step-Key als Platzhalter heading = step.key; } // WP-26: Section-Info erstellen const sectionInfo: SectionInfo = { stepKey: captureStep.key, sectionType: sectionType, heading: heading, blockId: blockId, noteType: noteType, }; // WP-26: Block-ID tracken (nur wenn vorhanden) if (blockId) { this.state.generatedBlockIds.set(blockId, sectionInfo); console.log(`[WP-26] Block-ID getrackt: ${blockId} für Step ${captureStep.key}`); } // WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden) // Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate) const existingIndex = this.state.sectionSequence.findIndex( s => s.stepKey === captureStep.key && s.blockId === blockId ); if (existingIndex === -1) { // Neue Section hinzufügen this.state.sectionSequence.push(sectionInfo); console.log(`[WP-26] Section zur Sequenz hinzugefügt: ${captureStep.key} (Block-ID: ${blockId || "keine"})`); } else { // Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat) this.state.sectionSequence[existingIndex] = sectionInfo; console.log(`[WP-26] Section aktualisiert: ${captureStep.key}`); } } /** * Ermittelt den effektiven Section-Type basierend auf Heading-Level. * Wenn eine Section keinen expliziten Type hat, wird der Type der vorherigen Section * auf dem gleichen oder höheren Level verwendet, sonst Note-Type. */ private getEffectiveSectionType( section: SectionInfo, index: number ): string { // Wenn expliziter Section-Type vorhanden, verwende diesen if (section.sectionType) { return section.sectionType; } // Extrahiere Heading-Level aus der Überschrift const headingMatch = section.heading.match(/^(#{1,6})\s+/); const currentLevel = headingMatch ? headingMatch[1]?.length || 0 : 0; // Suche rückwärts nach der letzten Section mit explizitem Type auf gleichem oder höherem Level for (let i = index - 1; i >= 0; i--) { const prevSection = this.state.sectionSequence[i]; if (!prevSection) continue; const prevHeadingMatch = prevSection.heading.match(/^(#{1,6})\s+/); const prevLevel = prevHeadingMatch ? prevHeadingMatch[1]?.length || 0 : 0; // Wenn vorherige Section auf gleichem oder höherem Level einen expliziten Type hat if (prevLevel <= currentLevel && prevSection.sectionType) { return prevSection.sectionType; } // Wenn wir auf ein höheres Level stoßen, stoppe die Suche if (prevLevel < currentLevel) { break; } } // Fallback: Note-Type return section.noteType; } onClose(): void { const { contentEl } = this; contentEl.empty(); } }