mindnet_obsidian/src/ui/InterviewWizardModal.ts
Lars 0a346d3886
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled
Enhance profile selection and interview wizard functionality
- Introduced preferred note types in ProfileSelectionModal to prioritize user selections, improving the user experience during profile selection.
- Updated InterviewWizardModal to accept initial pending edge assignments, allowing for better state management and user feedback.
- Added a new action, `create_section_in_note`, to the todo generation process, expanding the capabilities of the interview workflow.
- Enhanced the startWizardAfterCreate function to support initial pending edge assignments, streamlining the wizard initiation process.
- Improved CSS styles for preferred profiles, enhancing visual distinction in the profile selection interface.
2026-02-07 21:22:35 +01:00

3672 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<import("../mapping/graphSchema").GraphSchema | null> }; // Optional plugin instance
// Store current input values to save on navigation
private currentInputValues: Map<string, string> = new Map();
// Store preview mode state per step key
private previewMode: Map<string, boolean> = new Map();
// 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<import("../mapping/graphSchema").GraphSchema | null> },
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<void> {
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, "</p><p>")
.replace(/\n/g, "<br>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>");
fallbackDiv.innerHTML = `<p>${html}</p>`;
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<string, unknown>);
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<string, unknown>)[headingLevelDraftKey] as number | undefined;
}
}
}
let headingLevel: number | null = null;
if (headingLevelDraftValue !== undefined && typeof headingLevelDraftValue === "number") {
headingLevel = headingLevelDraftValue;
} else if (nestedStep.heading_level?.enabled) {
headingLevel = nestedStep.heading_level.default || 2;
}
if (nestedStep.heading_level?.enabled) {
const headingSelectorContainer = inputContainer.createEl("div");
headingSelectorContainer.style.flexShrink = "0";
const headingLabel = headingSelectorContainer.createEl("label", {
text: "H",
attr: { for: `heading-level-${loopKey}-${nestedStep.key}` },
});
headingLabel.style.marginRight = "0.25em";
headingLabel.style.fontSize = "0.9em";
headingLabel.style.color = "var(--text-muted)";
const headingSelect = headingSelectorContainer.createEl("select", {
attr: { id: `heading-level-${loopKey}-${nestedStep.key}` },
});
headingSelect.style.padding = "0.25em 0.5em";
headingSelect.style.border = "1px solid var(--background-modifier-border)";
headingSelect.style.borderRadius = "4px";
headingSelect.style.background = "var(--background-primary)";
headingSelect.style.fontSize = "0.9em";
headingSelect.style.minWidth = "3em";
// Add options H1-H6
for (let level = 1; level <= 6; level++) {
const option = headingSelect.createEl("option", {
text: `H${level}`,
attr: { value: String(level) },
});
if (headingLevel === level) {
option.selected = true;
}
}
headingSelect.onchange = () => {
const selectedLevel = parseInt(headingSelect.value, 10);
onFieldChange(headingLevelDraftKey, selectedLevel);
};
}
// Text input (takes remaining space)
const textInputContainer = inputContainer.createEl("div");
textInputContainer.style.flex = "1";
textInputContainer.style.minWidth = "0";
const fieldSetting = new Setting(textInputContainer);
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<string, unknown>;
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
// Show parent item fields (excluding the nested loop field itself)
const parentFields: string[] = [];
for (const [key, value] of Object.entries(parentItemObj)) {
if (nestedStepKey && key !== nestedStepKey && value && typeof value === "string" && value.trim() !== "") {
const step = loopStep.items.find(s => s.key === key);
const label = step?.label || key;
parentFields.push(`${label}: ${value.trim().substring(0, 60)}${value.trim().length > 60 ? "..." : ""}`);
}
}
if (parentFields.length > 0) {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} - ${parentFields.join(" | ")}`;
} else {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} (bearbeiten)`;
}
} else {
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
text: `Item ${parentLoopState.editIndex + 1} (bearbeiten)`,
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
}
} 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<string, unknown>;
for (const [key, value] of Object.entries(itemObj)) {
if (value && typeof value === "string" && value.trim() !== "") {
itemText = value.trim();
break;
}
}
} else if (item) {
itemText = String(item);
}
itemEl.textContent = itemText.length > 50 ? itemText.substring(0, 50) + "..." : itemText;
// Action buttons (same as normal loop)
const buttonContainer = itemEl.createEl("div", {
cls: "loop-item-actions",
});
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<string, Map<string, string>> | undefined = undefined;
let noteEdgesFromModal: Map<string, Map<string, string>> | 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<void> {
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<string | null> {
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<void> {
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<string, Map<string, string>>,
noteEdges?: Map<string, Map<string, string>>
): Promise<void> {
console.log("=== APPLY PATCHES ===", {
patchCount: this.state.patches.length,
patches: this.state.patches.map(p => ({
type: p.type,
field: p.field,
value: typeof p.value === "string"
? (p.value.length > 50 ? p.value.substring(0, 50) + "..." : p.value)
: p.value,
})),
collectedDataKeys: Array.from(this.state.collectedData.keys()),
loopContexts: Array.from(this.state.loopContexts.entries()).map(([key, items]) => ({
loopKey: key,
itemsCount: items.length,
})),
});
let updatedContent = this.fileContent;
// Apply frontmatter patches
for (const patch of this.state.patches) {
if (patch.type === "frontmatter" && patch.field) {
console.log("Apply frontmatter patch", {
field: patch.field,
value: patch.value,
});
updatedContent = this.applyFrontmatterPatch(
updatedContent,
patch.field,
patch.value
);
}
}
// 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<string, unknown>
): 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();
}
}