From 6d5f6203c4b2d6cc854eeb60805571e159cb09d0 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 5 Feb 2026 12:04:28 +0100 Subject: [PATCH] Implement inverse edge creation functionality in writerActions - Added a new function to automatically create inverse edges in target notes/sections after inserting edges in the "note_links" and "candidates" zones. - Refactored the zone detection logic to separate content-based detection from file-based detection, improving code clarity and maintainability. - Enhanced logging for better tracking of edge creation processes and potential issues during execution. --- src/workbench/writerActions.ts | 300 ++++++++++++++++++++++++++++++++- src/workbench/zoneDetector.ts | 22 ++- 2 files changed, 313 insertions(+), 9 deletions(-) diff --git a/src/workbench/writerActions.ts b/src/workbench/writerActions.ts index 5768d19..15b6e7b 100644 --- a/src/workbench/writerActions.ts +++ b/src/workbench/writerActions.ts @@ -5,7 +5,7 @@ import { Modal, Setting, TFile, Notice } from "obsidian"; import type { App, Editor } from "obsidian"; import type { MissingLinkTodo, CandidateCleanupTodo, MissingSlotTodo } from "./types"; -import { detectZone, findSection } from "./zoneDetector"; +import { detectZone, detectZoneFromContent, findSection } from "./zoneDetector"; import { splitIntoSections } from "../mapping/sectionParser"; import { EntityPickerModal } from "../ui/EntityPickerModal"; import { NoteIndex } from "../entityPicker/noteIndex"; @@ -84,12 +84,34 @@ export async function insertEdgeForward( console.log("[insertEdgeForward] Inserting into note_links zone"); } await insertEdgeInZone(app, editor, file, "note_links", edgeType, targetLink, settings); + + // Automatically create inverse edge in target note/section + await createInverseEdge( + app, + edgeType, + todo, + vocabulary, + edgeVocabulary, + settings, + debugLogging + ); } else if (targetZone === "candidates") { // Insert in Kandidaten zone if (debugLogging) { console.log("[insertEdgeForward] Inserting into candidates zone"); } await insertEdgeInZone(app, editor, file, "candidates", edgeType, targetLink, settings); + + // Automatically create inverse edge in target note/section + await createInverseEdge( + app, + edgeType, + todo, + vocabulary, + edgeVocabulary, + settings, + debugLogging + ); } else { // Insert in source section - need to select section if multiple exist // IMPORTANT: Use source note (fromNodeRef.file), not current note! @@ -218,9 +240,260 @@ export async function insertEdgeForward( if (debugLogging) { console.log("[insertEdgeForward] Edge insertion completed"); } + + // Automatically create inverse edge in target note/section + await createInverseEdge( + app, + edgeType, + todo, + vocabulary, + edgeVocabulary, + settings, + debugLogging + ); } } +/** + * Automatically create inverse edge in target note/section. + */ +async function createInverseEdge( + app: App, + forwardEdgeType: string, + todo: MissingLinkTodo, + vocabulary: Vocabulary, + edgeVocabulary: EdgeVocabulary, + settings: MindnetSettings, + debugLogging?: boolean +): Promise { + try { + console.log("[createInverseEdge] Starting inverse edge creation for:", forwardEdgeType); + + // Get canonical type and inverse + const canonical = vocabulary.getCanonical(forwardEdgeType); + if (!canonical) { + console.log("[createInverseEdge] No canonical type found for:", forwardEdgeType); + return; // Can't create inverse without canonical type + } + + const inverseCanonical = vocabulary.getInverse(canonical); + if (!inverseCanonical) { + console.log("[createInverseEdge] No inverse defined for:", canonical); + return; // No inverse defined, skip + } + + // Get the raw inverse type (use canonical or find an alias) + const inverseEntry = edgeVocabulary.byCanonical.get(inverseCanonical); + const inverseEdgeType = inverseEntry?.aliases?.[0] || inverseCanonical; + + console.log("[createInverseEdge] Forward:", forwardEdgeType, "-> Canonical:", canonical, "-> Inverse:", inverseEdgeType); + + // Find target file + const targetFileRef = todo.toNodeRef.file; + let targetFile: TFile | null = null; + + const possiblePaths = [ + targetFileRef, + targetFileRef + ".md", + targetFileRef.replace(/\.md$/, ""), + targetFileRef.replace(/\.md$/, "") + ".md", + ]; + + for (const path of possiblePaths) { + const found = app.vault.getAbstractFileByPath(path); + if (found && found instanceof TFile) { + targetFile = found; + break; + } + } + + if (!targetFile) { + const basename = targetFileRef.replace(/\.md$/, "").split("/").pop() || targetFileRef; + const resolved = app.metadataCache.getFirstLinkpathDest(basename, todo.fromNodeRef.file); + if (resolved) { + targetFile = resolved; + } + } + + if (!targetFile) { + console.log("[createInverseEdge] Target file not found:", targetFileRef); + return; + } + + // Build source link (for inverse edge, source is the original target) + const sourceBasename = todo.fromNodeRef.file.replace(/\.md$/, "").split("/").pop() || todo.fromNodeRef.file; + const sourceLink = todo.fromNodeRef.heading + ? `${sourceBasename}#${todo.fromNodeRef.heading}` + : sourceBasename; + + // Always ask user to select target section (or whole note) + // Open target file in editor first + console.log("[createInverseEdge] Opening target file:", targetFile.path); + await app.workspace.openLinkText(targetFile.path, "", false); + + // Wait a bit for the editor to be ready + await new Promise(resolve => setTimeout(resolve, 100)); + + const targetEditor = app.workspace.activeEditor?.editor; + if (!targetEditor) { + console.error("[createInverseEdge] Could not get editor for target file:", targetFile.path); + return; + } + + // Load sections from target file + const { splitIntoSections } = await import("../mapping/sectionParser"); + const targetContent = await app.vault.read(targetFile); + const sections = splitIntoSections(targetContent); + + // Filter out special zones + const contentSections = sections.filter( + (s) => s.heading !== "Kandidaten" && s.heading !== "Note-Verbindungen" + ); + + // Show selection modal: user can choose a section or "whole note" + const selectedTarget = await selectTargetForInverseEdge( + app, + targetFile, + contentSections, + todo.toNodeRef.heading, + debugLogging + ); + + if (!selectedTarget) { + console.log("[createInverseEdge] User cancelled target selection"); + return; // User cancelled + } + + console.log("[createInverseEdge] Selected target:", selectedTarget.type, selectedTarget.heading || "(whole note)"); + console.log("[createInverseEdge] Inverse edge type:", inverseEdgeType, "source link:", sourceLink); + + try { + if (selectedTarget.type === "note_links") { + console.log("[createInverseEdge] Calling insertEdgeInZone with note_links"); + await insertEdgeInZone(app, targetEditor, targetFile, "note_links", inverseEdgeType, sourceLink, settings); + console.log("[createInverseEdge] insertEdgeInZone completed"); + } else { + // Use selected section + const targetSection = await findSection(app, targetFile, selectedTarget.heading); + if (targetSection) { + console.log("[createInverseEdge] Found target section:", targetSection.heading || "(root)"); + await insertEdgeInSection( + app, + targetEditor, + targetFile, + targetSection, + inverseEdgeType, + sourceLink, + settings + ); + console.log("[createInverseEdge] insertEdgeInSection completed"); + } else { + console.log("[createInverseEdge] Target section not found, falling back to note_links"); + // Fallback to note_links if section not found + await insertEdgeInZone(app, targetEditor, targetFile, "note_links", inverseEdgeType, sourceLink, settings); + console.log("[createInverseEdge] insertEdgeInZone (fallback) completed"); + } + } + + console.log("[createInverseEdge] Inverse edge creation completed successfully"); + } catch (error) { + console.error("[createInverseEdge] Error during edge insertion:", error); + throw error; // Re-throw to see the error + } + } catch (error) { + console.error("[createInverseEdge] Error creating inverse edge:", error); + // Don't throw - inverse edge creation is optional + } +} + +/** + * Select target section or whole note for inverse edge insertion. + */ +async function selectTargetForInverseEdge( + app: App, + file: TFile, + sections: Array<{ heading: string | null; startLine: number; endLine: number }>, + preferredHeading: string | null, + debugLogging?: boolean +): Promise<{ type: "section" | "note_links"; heading: string | null } | null> { + return new Promise((resolve) => { + let resolved = false; + const modal = new Modal(app); + modal.titleEl.textContent = "Ziel für inverse Kante wählen"; + const description = preferredHeading + ? `Wo soll die inverse Kante eingefügt werden? (Empfohlen: "${preferredHeading}")` + : "Wo soll die inverse Kante eingefügt werden?"; + modal.contentEl.createEl("p", { text: description }); + + const doResolve = (value: { type: "section" | "note_links"; heading: string | null } | null) => { + if (!resolved) { + resolved = true; + resolve(value); + } + }; + + // Add "Whole note" option + const wholeNoteSetting = new Setting(modal.contentEl); + wholeNoteSetting.setName("📄 Ganze Note (Note-Verbindungen)"); + wholeNoteSetting.setDesc("Edge wird in der Note-Verbindungen Zone eingefügt (note-level)"); + wholeNoteSetting.addButton((btn) => { + btn.setButtonText("Auswählen"); + btn.onClick(() => { + if (debugLogging) { + console.log("[selectTargetForInverseEdge] User selected: whole note"); + } + doResolve({ type: "note_links", heading: null }); + modal.close(); + }); + }); + + // Add section options + for (const section of sections) { + if (!section) continue; + const sectionName = section.heading || "(Root section)"; + const isPreferred = preferredHeading && headingsMatch(section.heading, preferredHeading); + const setting = new Setting(modal.contentEl); + + if (isPreferred) { + setting.setName(`⭐ ${sectionName} (empfohlen)`); + setting.setDesc(`Zeilen ${section.startLine + 1}-${section.endLine} - Empfohlen basierend auf Chain Template`); + } else { + setting.setName(sectionName); + setting.setDesc(`Zeilen ${section.startLine + 1}-${section.endLine}`); + } + + setting.addButton((btn) => { + if (isPreferred) { + btn.setButtonText("Auswählen (Empfohlen)"); + btn.setCta(); // Highlight recommended option + } else { + btn.setButtonText("Auswählen"); + } + btn.onClick(() => { + if (debugLogging) { + console.log("[selectTargetForInverseEdge] User selected section:", sectionName); + } + doResolve({ type: "section", heading: section.heading }); + modal.close(); + }); + }); + } + + modal.onClose = () => { + if (debugLogging) { + console.log("[selectTargetForInverseEdge] Modal closed, resolved:", resolved); + if (!resolved) { + console.log("[selectTargetForInverseEdge] Resolving with null (user cancelled)"); + } + } + if (!resolved) { + doResolve(null); + } + }; + modal.open(); + }); +} + /** * Insert edge in zone (Kandidaten or Note-Verbindungen). */ @@ -233,7 +506,15 @@ async function insertEdgeInZone( targetLink: string, settings: MindnetSettings ): Promise { - const zone = await detectZone(app, file, zoneType); + console.log("[insertEdgeInZone] Starting, file:", file.path, "zoneType:", zoneType, "edgeType:", edgeType, "targetLink:", targetLink); + + // Use editor content instead of reading from vault (editor might have unsaved changes) + const editorContent = editor.getValue(); + console.log("[insertEdgeInZone] Editor content length:", editorContent.length); + + // Detect zone from editor content + const zone = detectZoneFromContent(editorContent, zoneType); + console.log("[insertEdgeInZone] Zone detection result:", { exists: zone.exists, heading: zone.heading, startLine: zone.startLine, endLine: zone.endLine }); const wrapperCalloutType = settings.mappingWrapperCalloutType || "abstract"; const wrapperTitle = settings.mappingWrapperTitle || "🕸️ Semantic Mapping"; @@ -241,40 +522,53 @@ async function insertEdgeInZone( if (!zone.exists) { // Create zone + console.log("[insertEdgeInZone] Zone does not exist, creating new zone"); const content = editor.getValue(); const newZone = `\n\n## ${zoneType === "candidates" ? "Kandidaten" : "Note-Verbindungen"}\n\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; const newContent = content + newZone; editor.setValue(newContent); + console.log("[insertEdgeInZone] New zone created and content set"); return; } // Insert in existing zone + console.log("[insertEdgeInZone] Zone exists, inserting into existing zone"); const content = editor.getValue(); const lines = content.split(/\r?\n/); // Find insertion point (end of zone, before next heading) let insertLine = zone.endLine - 1; + console.log("[insertEdgeInZone] Initial insertLine:", insertLine); // Check if zone has a wrapper callout using settings const zoneContent = zone.content; const hasWrapper = zoneContent.includes(`> [!${wrapperCalloutType}]`) && zoneContent.includes(wrapperTitle); + console.log("[insertEdgeInZone] Has wrapper:", hasWrapper, "wrapperCalloutType:", wrapperCalloutType, "wrapperTitle:", wrapperTitle); if (hasWrapper) { // Insert inside wrapper - find the end of the wrapper block + console.log("[insertEdgeInZone] Inserting inside existing wrapper"); const wrapperEndLine = findWrapperBlockEnd(lines, zone.startLine, wrapperCalloutType, wrapperTitle); + console.log("[insertEdgeInZone] Wrapper end line:", wrapperEndLine); if (wrapperEndLine !== null) { insertLine = wrapperEndLine - 1; } const newEdge = `>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + console.log("[insertEdgeInZone] Inserting edge at line:", insertLine, "edge:", newEdge); lines.splice(insertLine, 0, ...newEdge.split("\n")); } else { // Create wrapper and insert edge + console.log("[insertEdgeInZone] Creating new wrapper and inserting edge"); const wrapper = `\n> [!${wrapperCalloutType}]${foldMarker} ${wrapperTitle}\n>> [!edge] ${edgeType}\n>> [[${targetLink}]]\n`; + console.log("[insertEdgeInZone] Inserting wrapper at line:", insertLine, "wrapper:", wrapper); lines.splice(insertLine, 0, ...wrapper.split("\n")); } - editor.setValue(lines.join("\n")); + const newContent = lines.join("\n"); + console.log("[insertEdgeInZone] Setting new content, length:", newContent.length); + editor.setValue(newContent); + console.log("[insertEdgeInZone] Content set successfully"); } /** diff --git a/src/workbench/zoneDetector.ts b/src/workbench/zoneDetector.ts index 5d3be3c..5e7c879 100644 --- a/src/workbench/zoneDetector.ts +++ b/src/workbench/zoneDetector.ts @@ -13,14 +13,12 @@ export interface ZoneInfo { } /** - * Detect if a zone exists in a file. + * Detect if a zone exists in content (from editor or file). */ -export async function detectZone( - app: App, - file: TFile, +export function detectZoneFromContent( + content: string, zoneType: "candidates" | "note_links" -): Promise { - const content = await app.vault.read(file); +): ZoneInfo { const lines = content.split(/\r?\n/); const heading = zoneType === "candidates" ? "## Kandidaten" : "## Note-Verbindungen"; @@ -72,6 +70,18 @@ export async function detectZone( }; } +/** + * Detect if a zone exists in a file. + */ +export async function detectZone( + app: App, + file: TFile, + zoneType: "candidates" | "note_links" +): Promise { + const content = await app.vault.read(file); + return detectZoneFromContent(content, zoneType); +} + /** * Find section by heading in file. */