From 7ea36fbed4d11e0908653f81ebafe4382bb4d48d Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 17 Jan 2026 11:54:14 +0100 Subject: [PATCH] Enhance inline micro edge handling and settings - Introduced new settings for enabling inline micro edge suggestions and configuring the maximum number of alternatives displayed. - Updated the InterviewWizardModal to support inline micro edging, allowing users to select edge types immediately after inserting links. - Enhanced the semantic mapping builder to incorporate pending edge assignments, improving the handling of rel:type links. - Improved the user experience with clearer logging and error handling during inline edge type selection and mapping processes. --- src/entityPicker/wikilink.ts | 35 +- src/interview/parseInterviewConfig.ts | 2 +- src/interview/sectionKeyResolver.ts | 99 +++++ src/interview/types.ts | 2 +- src/interview/wizardState.ts | 12 + src/mapping/semanticMappingBuilder.ts | 82 ++++- src/mapping/worklistBuilder.ts | 18 +- src/parser/parseRelLinks.ts | 76 ++++ src/settings.ts | 7 + .../interview/sectionKeyResolver.test.ts | 124 +++++++ .../mapping/pendingAssignmentMerge.test.ts | 103 ++++++ src/ui/InlineEdgeTypeModal.ts | 341 ++++++++++++++++++ src/ui/InterviewWizardModal.ts | 228 +++++++++++- src/ui/MindnetSettingTab.ts | 34 ++ src/ui/markdownToolbar.ts | 35 ++ 15 files changed, 1171 insertions(+), 27 deletions(-) create mode 100644 src/interview/sectionKeyResolver.ts create mode 100644 src/parser/parseRelLinks.ts create mode 100644 src/tests/interview/sectionKeyResolver.test.ts create mode 100644 src/tests/mapping/pendingAssignmentMerge.test.ts create mode 100644 src/ui/InlineEdgeTypeModal.ts diff --git a/src/entityPicker/wikilink.ts b/src/entityPicker/wikilink.ts index 32e97da..98d8ff9 100644 --- a/src/entityPicker/wikilink.ts +++ b/src/entityPicker/wikilink.ts @@ -42,6 +42,7 @@ export function insertWikilink( /** * Insert wikilink into a textarea element. * Updates the textarea value and cursor position. + * @param basename - Can be a simple basename or a full link like "rel:type|basename" or "basename" */ export function insertWikilinkIntoTextarea( textarea: HTMLTextAreaElement, @@ -51,11 +52,37 @@ export function insertWikilinkIntoTextarea( const selEnd = textarea.selectionEnd; const currentText = textarea.value; - const result = insertWikilink(currentText, selStart, selEnd, basename); + // Check if basename already contains link format (e.g., "rel:type|basename") + let wikilink: string; + if (basename.startsWith("rel:") || basename.includes("|")) { + // Already in format like "rel:type|basename" or "basename|alias" + // Wrap with [[...]] + wikilink = `[[${basename}]]`; + } else { + // Simple basename, use normal insertWikilink + const result = insertWikilink(currentText, selStart, selEnd, basename); + textarea.value = result.text; + textarea.setSelectionRange(result.cursorPos, result.cursorPos); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + return; + } - textarea.value = result.text; - textarea.setSelectionRange(result.cursorPos, result.cursorPos); + // Insert the full wikilink + const hasSelection = selEnd > selStart; + let newText: string; + let cursorPos: number; - // Trigger input event so Obsidian can update its state + if (hasSelection) { + // Replace selection with wikilink + newText = currentText.substring(0, selStart) + wikilink + currentText.substring(selEnd); + cursorPos = selStart + wikilink.length; + } else { + // Insert at cursor position + newText = currentText.substring(0, selStart) + wikilink + currentText.substring(selEnd); + cursorPos = selStart + wikilink.length; + } + + textarea.value = newText; + textarea.setSelectionRange(cursorPos, cursorPos); textarea.dispatchEvent(new Event("input", { bubbles: true })); } diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts index 7ae772c..97bda82 100644 --- a/src/interview/parseInterviewConfig.ts +++ b/src/interview/parseInterviewConfig.ts @@ -123,7 +123,7 @@ function parseProfile( const edgingRaw = raw.edging as Record; profile.edging = {}; - if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro") { + if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro" || edgingRaw.mode === "both") { profile.edging.mode = edgingRaw.mode; } if (typeof edgingRaw.wrapperCalloutType === "string") { diff --git a/src/interview/sectionKeyResolver.ts b/src/interview/sectionKeyResolver.ts new file mode 100644 index 0000000..b926e00 --- /dev/null +++ b/src/interview/sectionKeyResolver.ts @@ -0,0 +1,99 @@ +/** + * Section key resolution for inline micro edge assignments. + */ + +import { App, TFile } from "obsidian"; +import type { InterviewStep } from "./types"; + +export interface SectionKeyContext { + file: TFile; + step: InterviewStep; + insertionPoint?: number; // Line number where link is inserted (optional) +} + +/** + * Get section key for wizard context. + * + * Options: + * A) if step config provides output.sectionKey -> use it + * B) else parse active file headings and choose nearest heading at insertion point + * C) fallback "ROOT" + */ +export async function getSectionKeyForWizardContext( + app: App, + context: SectionKeyContext +): Promise { + // Option A: Check if step config provides output.sectionKey + // Note: Only some step types have output property (e.g., CaptureTextStep, CaptureTextLineStep) + if ( + (context.step.type === "capture_text" || context.step.type === "capture_text_line") && + "output" in context.step && + context.step.output && + typeof context.step.output === "object" + ) { + const output = context.step.output as Record; + if (typeof output.sectionKey === "string" && output.sectionKey.trim()) { + return output.sectionKey.trim(); + } + } + + // Option B: Parse file headings and find nearest heading + try { + const content = await app.vault.read(context.file); + const lines = content.split(/\r?\n/); + + // Find headings in file + const headings: Array<{ level: number; text: string; lineIndex: number }> = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + + // Match markdown headings: # Heading, ## Heading, etc. + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch && headingMatch[1] && headingMatch[2]) { + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + headings.push({ level, text, lineIndex: i }); + } + } + + // If we have an insertion point, find nearest heading before it + if (context.insertionPoint !== undefined && headings.length > 0) { + // Find last heading before insertion point + let nearestHeading: typeof headings[0] | null = null; + for (const heading of headings) { + if (heading.lineIndex <= context.insertionPoint) { + if (!nearestHeading || heading.lineIndex > nearestHeading.lineIndex) { + nearestHeading = heading; + } + } + } + + if (nearestHeading) { + // Format: "H2:Heading Text..." (truncate if too long) + const prefix = `H${nearestHeading.level}:`; + const text = nearestHeading.text.length > 50 + ? nearestHeading.text.substring(0, 50) + "..." + : nearestHeading.text; + return `${prefix}${text}`; + } + } + + // If no insertion point or no heading found before it, use last heading + if (headings.length > 0) { + const lastHeading = headings[headings.length - 1]; + if (lastHeading) { + const prefix = `H${lastHeading.level}:`; + const text = lastHeading.text.length > 50 + ? lastHeading.text.substring(0, 50) + "..." + : lastHeading.text; + return `${prefix}${text}`; + } + } + } catch (e) { + console.warn("[Mindnet] Failed to parse headings for section key:", e); + } + + // Option C: Fallback to "ROOT" + return "ROOT"; +} diff --git a/src/interview/types.ts b/src/interview/types.ts index 8470d69..3fe6847 100644 --- a/src/interview/types.ts +++ b/src/interview/types.ts @@ -16,7 +16,7 @@ export interface InterviewProfile { defaults?: Record; steps: InterviewStep[]; edging?: { - mode?: "none" | "post_run" | "inline_micro"; // Semantic mapping mode (default: "none") + mode?: "none" | "post_run" | "inline_micro" | "both"; // Semantic mapping mode (default: "none"). "both" enables inline_micro + post_run wrapperCalloutType?: string; // Override wrapper callout type wrapperTitle?: string; // Override wrapper title wrapperFolded?: boolean; // Override wrapper folded state diff --git a/src/interview/wizardState.ts b/src/interview/wizardState.ts index 8d9fd57..e71e47f 100644 --- a/src/interview/wizardState.ts +++ b/src/interview/wizardState.ts @@ -1,6 +1,16 @@ import type { InterviewProfile, InterviewStep } from "./types"; import type { LoopRuntimeState } from "./loopState"; +export interface PendingEdgeAssignment { + filePath: string; + sectionKey: string; // identifies heading/section in file (e.g. "H2:Wendepunkte..." or "ROOT") + linkBasename: string; // target note basename + chosenRawType: string; // user chosen edge type (alias allowed) + sourceNoteId?: string; // from frontmatter.id (optional) + targetNoteId?: string; // if resolved (optional) + createdAt: number; +} + export interface WizardState { profile: InterviewProfile; currentStepIndex: number; @@ -10,6 +20,7 @@ export interface WizardState { loopRuntimeStates: Map; // loop step key -> runtime state patches: Patch[]; // Collected patches to apply activeLoopPath: string[]; // Stack of loop keys representing current nesting level (e.g. ["items", "item_list"]) + pendingEdgeAssignments: PendingEdgeAssignment[]; // Inline micro edge assignments collected during wizard } export interface Patch { @@ -38,6 +49,7 @@ export function createWizardState(profile: InterviewProfile): WizardState { loopRuntimeStates: new Map(), patches: [], activeLoopPath: [], // Start at top level + pendingEdgeAssignments: [], // Start with empty pending assignments }; } diff --git a/src/mapping/semanticMappingBuilder.ts b/src/mapping/semanticMappingBuilder.ts index 4bc9d2e..44e7060 100644 --- a/src/mapping/semanticMappingBuilder.ts +++ b/src/mapping/semanticMappingBuilder.ts @@ -22,6 +22,7 @@ import { VocabularyLoader } from "../vocab/VocabularyLoader"; import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; import type { EdgeVocabulary } from "../vocab/types"; import { parseGraphSchema, type GraphSchema } from "./graphSchema"; +import { convertRelLinksToEdges } from "../parser/parseRelLinks"; export interface BuildResult { sectionsProcessed: number; @@ -32,6 +33,10 @@ export interface BuildResult { unmappedLinksSkipped: number; } +export interface BuildOptions { + pendingAssignments?: import("../interview/wizardState").PendingEdgeAssignment[]; +} + /** * Build semantic mapping blocks for all sections in a note. */ @@ -40,11 +45,18 @@ export async function buildSemanticMappings( file: TFile, settings: MindnetSettings, allowOverwrite: boolean, - plugin?: { ensureGraphSchemaLoaded?: () => Promise } + plugin?: { ensureGraphSchemaLoaded?: () => Promise }, + options?: BuildOptions ): Promise { - const content = await app.vault.read(file); + let content = await app.vault.read(file); const lines = content.split(/\r?\n/); + // Convert [[rel:type|link]] links to normal [[link]] and extract edge mappings + const { convertedContent, edgeMappings: relLinkMappings } = convertRelLinksToEdges(content); + content = convertedContent; + + console.log(`[Mindnet] Converted ${relLinkMappings.size} rel: links to edge mappings`); + // Load vocabulary and schema if prompt mode let vocabulary: EdgeVocabulary | null = null; let graphSchema: GraphSchema | null = null; @@ -86,6 +98,14 @@ export async function buildSemanticMappings( // Split into sections const sections = splitIntoSections(content); + // Build pending assignments map by section key (from rel: links) + // rel: links are already converted, so we use the extracted mappings + const pendingBySection = new Map>(); // sectionKey -> (linkBasename -> edgeType) + + // Add rel: link mappings to pendingBySection (all go to ROOT for now, will be refined per section) + // We'll match them to sections when processing each section + const globalRelMappings = relLinkMappings; + const result: BuildResult = { sectionsProcessed: 0, sectionsWithMappings: 0, @@ -119,6 +139,37 @@ export async function buildSemanticMappings( settings.mappingWrapperTitle ); + // Merge pending assignments into existing mappings BEFORE building worklist + // This ensures pending assignments are available when worklist is built + // Determine section key for this section + // Note: section.heading is string | null, headingLevel is number + const sectionKey = section.heading + ? `H${section.headingLevel}:${section.heading}` + : "ROOT"; + + // Merge rel: link mappings (from [[rel:type|link]] format) into existing mappings + // These take precedence over file content mappings + for (const [linkBasename, edgeType] of globalRelMappings.entries()) { + // Check if this link exists in this section + const normalizedBasename = linkBasename.split("|")[0]?.split("#")[0]?.trim() || linkBasename; + if (section.links.some(link => { + const normalizedLink = link.split("|")[0]?.split("#")[0]?.trim() || link; + return normalizedLink === normalizedBasename || link === normalizedBasename; + })) { + mappingState.existingMappings.set(normalizedBasename, edgeType); + console.log(`[Mindnet] Merged rel: link mapping into existing mappings: ${normalizedBasename} -> ${edgeType}`); + } + } + + // Also merge legacy pending assignments if any (for backward compatibility) + const pendingForSection = pendingBySection.get(sectionKey); + if (pendingForSection) { + for (const [linkBasename, edgeType] of pendingForSection.entries()) { + mappingState.existingMappings.set(linkBasename, edgeType); + console.log(`[Mindnet] Merged pending assignment into existing mappings: ${linkBasename} -> ${edgeType}`); + } + } + // Remove wrapper block if exists let sectionContentWithoutWrapper = section.content; if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) { @@ -134,14 +185,39 @@ export async function buildSemanticMappings( if (settings.unassignedHandling === "prompt" && vocabulary) { // Prompt mode: interactive assignment + // Pass the already-merged mappingState (with pending assignments) to worklist builder const worklist = await buildSectionWorklist( app, section.content, section.heading, settings.mappingWrapperCalloutType, - settings.mappingWrapperTitle + settings.mappingWrapperTitle, + mappingState // Pass merged mappingState (includes pending assignments) ); + // Worklist items should already have currentType set from mappingState (which includes pending assignments) + // But let's verify and add debug logging + console.log(`[Mindnet] Worklist built with mappingState (includes pending assignments):`, { + sectionKey, + itemsWithCurrentType: worklist.items.filter(i => i.currentType).length, + totalItems: worklist.items.length, + pendingAssignmentsCount: pendingForSection ? pendingForSection.size : 0, + mappingStateSize: mappingState.existingMappings.size, + }); + + // Debug: Log which items have currentType and which don't + for (const item of worklist.items) { + const normalizedBasename = item.link.split("|")[0]?.split("#")[0]?.trim() || item.link; + if (item.currentType) { + console.log(`[Mindnet] Worklist item has currentType: ${item.link} -> ${item.currentType}`); + } else if (pendingForSection && pendingForSection.has(normalizedBasename)) { + // This shouldn't happen if mappingState was passed correctly, but log as warning + console.warn(`[Mindnet] Worklist item missing currentType despite pending assignment: ${item.link} (normalized: ${normalizedBasename})`); + // Fallback: set it now + item.currentType = pendingForSection.get(normalizedBasename) || null; + } + } + // Process each link in worklist for (const item of worklist.items) { // Always prompt user (even for existing mappings) diff --git a/src/mapping/worklistBuilder.ts b/src/mapping/worklistBuilder.ts index d5b03c4..1b39499 100644 --- a/src/mapping/worklistBuilder.ts +++ b/src/mapping/worklistBuilder.ts @@ -20,19 +20,21 @@ export interface SectionWorklist { /** * Build worklist for a section. + * @param mappingState Optional pre-extracted mapping state (if provided, won't re-extract) */ export async function buildSectionWorklist( app: App, sectionContent: string, sectionHeading: string | null, wrapperCalloutType: string, - wrapperTitle: string + wrapperTitle: string, + mappingState?: import("./mappingExtractor").SectionMappingState ): Promise { // Extract all wikilinks (deduplicated) const links = extractWikilinks(sectionContent); - // Extract existing mappings - const mappingState = extractExistingMappings( + // Extract existing mappings (only if not provided) + const finalMappingState = mappingState || extractExistingMappings( sectionContent, wrapperCalloutType, wrapperTitle @@ -57,8 +59,14 @@ export async function buildSectionWorklist( } } - // Get current mapping - const currentType = mappingState.existingMappings.get(link) || null; + // Get current mapping - try both exact match and normalized match + let currentType = finalMappingState.existingMappings.get(link) || null; + + // If no exact match, try normalized (for pending assignments) + if (!currentType) { + const normalizedLink = link.split("|")[0]?.split("#")[0]?.trim() || link; + currentType = finalMappingState.existingMappings.get(normalizedLink) || null; + } items.push({ link, diff --git a/src/parser/parseRelLinks.ts b/src/parser/parseRelLinks.ts new file mode 100644 index 0000000..713d978 --- /dev/null +++ b/src/parser/parseRelLinks.ts @@ -0,0 +1,76 @@ +/** + * Parse and convert [[rel:type|Link]] format links to edge callouts. + * + * Format: [[rel:edgeType|LinkBasename]] + * Example: [[rel:depends_on|System-Architektur]] + */ + +export interface RelLink { + edgeType: string; + linkBasename: string; + fullMatch: string; // The complete [[rel:type|link]] string + startIndex: number; + endIndex: number; +} + +/** + * Extract all [[rel:type|link]] links from markdown content. + */ +export function extractRelLinks(content: string): RelLink[] { + const relLinks: RelLink[] = []; + // Match [[rel:type|link]] or [[rel:type|link|alias]] or [[rel:type|link#heading]] + const relLinkRegex = /\[\[rel:([^\|#\]]+)(?:\|([^\]]+?))?\]\]/g; + + let match: RegExpExecArray | null; + while ((match = relLinkRegex.exec(content)) !== null) { + const edgeType = match[1]?.trim(); + const linkPart = match[2]?.trim() || match[1]?.trim(); // If no |, use type as link (fallback) + + if (edgeType && linkPart) { + // Extract basename (remove alias and heading) + const basename = linkPart.split("|")[0]?.split("#")[0]?.trim() || linkPart; + + relLinks.push({ + edgeType, + linkBasename: basename, + fullMatch: match[0], + startIndex: match.index, + endIndex: match.index + match[0].length, + }); + } + } + + return relLinks; +} + +/** + * Convert [[rel:type|link]] links to normal [[link]] and return edge mappings. + * Returns the converted content and a map of link -> edgeType. + */ +export function convertRelLinksToEdges(content: string): { + convertedContent: string; + edgeMappings: Map; // linkBasename -> edgeType +} { + const relLinks = extractRelLinks(content); + const edgeMappings = new Map(); + + // Process in reverse order to preserve indices + let convertedContent = content; + for (let i = relLinks.length - 1; i >= 0; i--) { + const relLink = relLinks[i]; + if (!relLink) continue; + + // Replace [[rel:type|link]] with [[link]] + const normalLink = `[[${relLink.linkBasename}]]`; + convertedContent = + convertedContent.substring(0, relLink.startIndex) + + normalLink + + convertedContent.substring(relLink.endIndex); + + // Store mapping (normalize basename) + const normalizedBasename = relLink.linkBasename.split("|")[0]?.split("#")[0]?.trim() || relLink.linkBasename; + edgeMappings.set(normalizedBasename, relLink.edgeType); + } + + return { convertedContent, edgeMappings }; +} diff --git a/src/settings.ts b/src/settings.ts index e726031..79bf2f6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -26,6 +26,10 @@ export interface MindnetSettings { unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt" allowOverwriteExistingMappings: boolean; // default: false defaultNotesFolder: string; // default: "" (vault root) + // Inline micro edge suggester settings + inlineMicroEnabled: boolean; // default: true + inlineMaxAlternatives: number; // default: 6 + inlineCancelBehavior: "keep_link"; // default: "keep_link" (future: "revert") } export const DEFAULT_SETTINGS: MindnetSettings = { @@ -55,6 +59,9 @@ export interface MindnetSettings { unassignedHandling: "prompt", allowOverwriteExistingMappings: false, defaultNotesFolder: "", + inlineMicroEnabled: true, + inlineMaxAlternatives: 6, + inlineCancelBehavior: "keep_link", }; /** diff --git a/src/tests/interview/sectionKeyResolver.test.ts b/src/tests/interview/sectionKeyResolver.test.ts new file mode 100644 index 0000000..26d735b --- /dev/null +++ b/src/tests/interview/sectionKeyResolver.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { getSectionKeyForWizardContext } from "../../interview/sectionKeyResolver"; +import type { InterviewStep } from "../../interview/types"; +import { App, TFile } from "obsidian"; + +describe("getSectionKeyForWizardContext", () => { + const mockApp = { + vault: { + read: async (file: TFile) => { + if (file.path === "test.md") { + return `# Heading 1 + +Content before first heading. + +## Heading 2 + +Content in section 2. + +### Heading 3 + +Content in section 3. +`; + } + return ""; + }, + }, + } as unknown as App; + + const mockFile = { + path: "test.md", + basename: "test", + } as TFile; + + it("should use output.sectionKey if provided (future feature)", async () => { + // Note: This test documents future behavior when output.sectionKey is added to types + // For now, we test that the function handles steps without sectionKey gracefully + const step: InterviewStep = { + type: "capture_text", + key: "test", + // output.sectionKey would be here if supported + }; + + const result = await getSectionKeyForWizardContext(mockApp, { + file: mockFile, + step, + }); + + // Should fall back to heading parsing or ROOT + expect(result).toBeTruthy(); + }); + + it("should return ROOT if no headings found", async () => { + const step: InterviewStep = { + type: "capture_text", + key: "test", + }; + + const emptyApp = { + vault: { + read: async () => "No headings here.", + }, + } as unknown as App; + + const result = await getSectionKeyForWizardContext(emptyApp, { + file: mockFile, + step, + }); + + expect(result).toBe("ROOT"); + }); + + it("should find nearest heading before insertion point", async () => { + const step: InterviewStep = { + type: "capture_text", + key: "test", + }; + + const result = await getSectionKeyForWizardContext(mockApp, { + file: mockFile, + step, + insertionPoint: 5, // After "Content before first heading" + }); + + expect(result).toMatch(/^H1:Heading 1/); + }); + + it("should use last heading if insertion point is after all headings", async () => { + const step: InterviewStep = { + type: "capture_text", + key: "test", + }; + + const result = await getSectionKeyForWizardContext(mockApp, { + file: mockFile, + step, + insertionPoint: 100, // After all content + }); + + expect(result).toMatch(/^H3:Heading 3/); + }); + + it("should truncate long heading text", async () => { + const longHeadingApp = { + vault: { + read: async () => { + return `## This is a very long heading that should be truncated because it exceeds the maximum length of 50 characters +`; + }, + }, + } as unknown as App; + + const step: InterviewStep = { + type: "capture_text", + key: "test", + }; + + const result = await getSectionKeyForWizardContext(longHeadingApp, { + file: mockFile, + step, + }); + + expect(result).toMatch(/^H2:This is a very long heading that should be trunca\.\.\./); + }); +}); diff --git a/src/tests/mapping/pendingAssignmentMerge.test.ts b/src/tests/mapping/pendingAssignmentMerge.test.ts new file mode 100644 index 0000000..c299504 --- /dev/null +++ b/src/tests/mapping/pendingAssignmentMerge.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import type { PendingEdgeAssignment } from "../../interview/wizardState"; + +describe("Pending assignment merge", () => { + it("should group pending assignments by section key", () => { + const assignments: PendingEdgeAssignment[] = [ + { + filePath: "test.md", + sectionKey: "H2:Section 1", + linkBasename: "link1", + chosenRawType: "causes", + createdAt: Date.now(), + }, + { + filePath: "test.md", + sectionKey: "H2:Section 1", + linkBasename: "link2", + chosenRawType: "enables", + createdAt: Date.now(), + }, + { + filePath: "test.md", + sectionKey: "ROOT", + linkBasename: "link3", + chosenRawType: "relates", + createdAt: Date.now(), + }, + ]; + + // Group by section key + const bySection = new Map>(); + for (const assignment of assignments) { + if (!bySection.has(assignment.sectionKey)) { + bySection.set(assignment.sectionKey, new Map()); + } + const sectionMap = bySection.get(assignment.sectionKey)!; + sectionMap.set(assignment.linkBasename, assignment.chosenRawType); + } + + expect(bySection.size).toBe(2); + expect(bySection.has("H2:Section 1")).toBe(true); + expect(bySection.has("ROOT")).toBe(true); + + const section1Map = bySection.get("H2:Section 1")!; + expect(section1Map.get("link1")).toBe("causes"); + expect(section1Map.get("link2")).toBe("enables"); + + const rootMap = bySection.get("ROOT")!; + expect(rootMap.get("link3")).toBe("relates"); + }); + + it("should normalize link basenames (remove aliases and headings)", () => { + const assignments: PendingEdgeAssignment[] = [ + { + filePath: "test.md", + sectionKey: "H2:Section 1", + linkBasename: "link1|alias", + chosenRawType: "causes", + createdAt: Date.now(), + }, + { + filePath: "test.md", + sectionKey: "H2:Section 1", + linkBasename: "link2#heading", + chosenRawType: "enables", + createdAt: Date.now(), + }, + ]; + + // Simulate normalization + const normalized = new Map(); + for (const assignment of assignments) { + const normalizedBasename = assignment.linkBasename.split("|")[0]?.split("#")[0]?.trim() || assignment.linkBasename; + normalized.set(normalizedBasename, assignment.chosenRawType); + } + + expect(normalized.get("link1")).toBe("causes"); + expect(normalized.get("link2")).toBe("enables"); + }); + + it("should not overwrite existing mappings", () => { + const existingMappings = new Map([ + ["link1", "existing-type"], + ]); + + const pendingAssignment: PendingEdgeAssignment = { + filePath: "test.md", + sectionKey: "H2:Section 1", + linkBasename: "link1", + chosenRawType: "new-type", + createdAt: Date.now(), + }; + + // Simulate merge: only add if not already present + const normalizedBasename = pendingAssignment.linkBasename.split("|")[0]?.split("#")[0]?.trim() || pendingAssignment.linkBasename; + if (!existingMappings.has(normalizedBasename)) { + existingMappings.set(normalizedBasename, pendingAssignment.chosenRawType); + } + + // Should keep existing mapping + expect(existingMappings.get("link1")).toBe("existing-type"); + }); +}); diff --git a/src/ui/InlineEdgeTypeModal.ts b/src/ui/InlineEdgeTypeModal.ts new file mode 100644 index 0000000..52512df --- /dev/null +++ b/src/ui/InlineEdgeTypeModal.ts @@ -0,0 +1,341 @@ +/** + * Inline micro edge type chooser modal. + * Shows recommended edge types as chips with OK/Skip/Cancel buttons. + * Improved: Auto-selects first typical, shows alternatives on click, allows choosing other types. + */ + +import { App, Modal, Notice } from "obsidian"; +import type { EdgeVocabulary } from "../vocab/types"; +import type { GraphSchema } from "../mapping/graphSchema"; +import type { MindnetSettings } from "../settings"; +import { EdgeTypeChooserModal, type EdgeTypeChoice } from "./EdgeTypeChooserModal"; + +export interface InlineEdgeTypeResult { + chosenRawType: string | null; // null means skip + cancelled: boolean; // true if user cancelled (should keep link but no assignment) +} + +export class InlineEdgeTypeModal extends Modal { + private linkBasename: string; + private sourceNoteId?: string; + private targetNoteId?: string; + private sourceType?: string; + private targetType?: string; + private vocabulary: EdgeVocabulary | null; + private graphSchema: GraphSchema | null; + private settings: MindnetSettings; + private resolve: (result: InlineEdgeTypeResult) => void; + private result: InlineEdgeTypeResult | null = null; + private selectedEdgeType: string | null = null; + private selectedAlias: string | null = null; + private expandedAlternatives: Set = new Set(); // Track which chips show alternatives + + constructor( + app: App, + linkBasename: string, + vocabulary: EdgeVocabulary | null, + graphSchema: GraphSchema | null, + settings: MindnetSettings, + sourceNoteId?: string, + targetNoteId?: string, + sourceType?: string, + targetType?: string + ) { + super(app); + this.linkBasename = linkBasename; + this.sourceNoteId = sourceNoteId; + this.targetNoteId = targetNoteId; + this.sourceType = sourceType; + this.targetType = targetType; + this.vocabulary = vocabulary; + this.graphSchema = graphSchema; + this.settings = settings; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("inline-edge-type-modal"); + + // Title + contentEl.createEl("h2", { + text: "Edge type for this link?", + }); + + // Link info + const linkInfo = contentEl.createEl("div", { + cls: "inline-edge-type-modal__link-info", + }); + linkInfo.textContent = `Link: [[${this.linkBasename}]]`; + + // Get recommendations + const recommendations = this.getRecommendations(); + const typical = recommendations.typical; + const alternatives = recommendations.alternatives; + const prohibited = recommendations.prohibited; + + // If no recommendations at all, open full chooser directly + if (typical.length === 0 && alternatives.length === 0 && this.vocabulary) { + this.openFullChooser(); + return; + } + + // Auto-select first typical if available + if (typical.length > 0 && typical[0] && !this.selectedEdgeType) { + this.selectedEdgeType = typical[0]; + this.result = { chosenRawType: typical[0], cancelled: false }; + } + + // Recommended chips (typical) - all are directly selectable, first one is preselected + if (typical.length > 0) { + const typicalContainer = contentEl.createEl("div", { + cls: "inline-edge-type-modal__section", + }); + typicalContainer.createEl("div", { + cls: "inline-edge-type-modal__section-label", + text: "Recommended:", + }); + const chipsContainer = typicalContainer.createEl("div", { + cls: "inline-edge-type-modal__chips", + }); + + for (let i = 0; i < typical.length; i++) { + const edgeType = typical[i]; + if (!edgeType) continue; + + const chip = chipsContainer.createEl("button", { + cls: "inline-edge-type-modal__chip", + text: edgeType, + }); + + // First typical is preselected + if (i === 0) { + chip.addClass("mod-cta"); + } + + chip.onclick = () => { + this.selectEdgeType(edgeType); + }; + } + } + + // Alternatives chips (always visible, not hidden) + if (alternatives.length > 0) { + const altContainer = contentEl.createEl("div", { + cls: "inline-edge-type-modal__section", + }); + + altContainer.createEl("div", { + cls: "inline-edge-type-modal__section-label", + text: "Alternatives:", + }); + const chipsContainer = altContainer.createEl("div", { + cls: "inline-edge-type-modal__chips", + }); + + for (const edgeType of alternatives) { + const chip = chipsContainer.createEl("button", { + cls: "inline-edge-type-modal__chip", + text: edgeType, + }); + chip.onclick = () => this.selectEdgeType(edgeType); + } + } + + // Prohibited (show but disabled) + if (prohibited.length > 0) { + const prohibitedContainer = contentEl.createEl("div", { + cls: "inline-edge-type-modal__section", + }); + prohibitedContainer.createEl("div", { + cls: "inline-edge-type-modal__section-label", + text: "Prohibited (not recommended):", + }); + const chipsContainer = prohibitedContainer.createEl("div", { + cls: "inline-edge-type-modal__chips", + }); + + for (const edgeType of prohibited) { + const chip = chipsContainer.createEl("button", { + cls: "inline-edge-type-modal__chip", + text: edgeType, + }); + chip.disabled = true; + chip.addClass("mod-muted"); + } + } + + // "Choose other type" button + if (this.vocabulary) { + const otherTypeBtn = contentEl.createEl("button", { + text: "Anderen Type wählen...", + cls: "inline-edge-type-modal__other-type-btn", + }); + otherTypeBtn.style.marginTop = "1em"; + otherTypeBtn.style.width = "100%"; + otherTypeBtn.onclick = async () => { + const chooser = new EdgeTypeChooserModal( + this.app, + this.vocabulary!, + this.sourceType || null, + this.targetType || null, + this.graphSchema + ); + const choice: EdgeTypeChoice | null = await chooser.show(); + if (choice) { + // Store the choice and close modal + this.selectEdgeType(choice.edgeType, choice.alias); + // Close this modal immediately after selection + this.close(); + } + }; + } + + // Buttons + const buttonContainer = contentEl.createEl("div", { + cls: "inline-edge-type-modal__buttons", + }); + + const okBtn = buttonContainer.createEl("button", { + text: "OK", + cls: "mod-cta", + }); + okBtn.onclick = () => { + // If no type selected yet, use preselected + if (!this.result && this.selectedEdgeType) { + this.result = { chosenRawType: this.selectedEdgeType, cancelled: false }; + } else if (!this.result) { + // No selection possible, treat as skip + this.result = { chosenRawType: null, cancelled: false }; + } + this.close(); + }; + + const skipBtn = buttonContainer.createEl("button", { + text: "Skip", + }); + skipBtn.onclick = () => { + this.result = { chosenRawType: null, cancelled: false }; + this.close(); + }; + + const cancelBtn = buttonContainer.createEl("button", { + text: "Cancel", + }); + cancelBtn.onclick = () => { + this.result = { chosenRawType: null, cancelled: true }; + this.close(); + }; + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + + // Resolve with result (or skip if no result) + if (this.resolve) { + this.resolve(this.result || { chosenRawType: null, cancelled: false }); + } + } + + /** + * Show modal and return result. + */ + async show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } + + private selectEdgeType(edgeType: string, alias?: string | null): void { + // Remove previous selection + const chips = this.contentEl.querySelectorAll(".inline-edge-type-modal__chip"); + const chipsArray = Array.from(chips); + for (const chip of chipsArray) { + if (chip instanceof HTMLElement) { + chip.removeClass("mod-cta"); + } + } + + // Mark selected + const selectedChip = chipsArray.find( + (chip) => chip.textContent === edgeType || chip.textContent?.includes(edgeType) + ); + if (selectedChip && selectedChip instanceof HTMLElement) { + selectedChip.addClass("mod-cta"); + } + + // Store result + this.selectedEdgeType = edgeType; + this.selectedAlias = alias || null; + this.result = { chosenRawType: edgeType, cancelled: false }; + } + + private async openFullChooser(): Promise { + if (!this.vocabulary) { + // No vocabulary, treat as skip + this.result = { chosenRawType: null, cancelled: false }; + this.close(); + return; + } + + const chooser = new EdgeTypeChooserModal( + this.app, + this.vocabulary, + this.sourceType || null, + this.targetType || null, + this.graphSchema + ); + const choice: EdgeTypeChoice | null = await chooser.show(); + + if (choice) { + this.selectEdgeType(choice.edgeType, choice.alias); + this.close(); + } else { + // User cancelled chooser, treat as skip + this.result = { chosenRawType: null, cancelled: false }; + this.close(); + } + } + + private getRecommendations(): { + typical: string[]; + alternatives: string[]; + prohibited: string[]; + } { + const typical: string[] = []; + const alternatives: string[] = []; + const prohibited: string[] = []; + + // Try to get hints from graph schema + if (this.graphSchema && this.sourceType && this.targetType) { + const sourceMap = this.graphSchema.schema.get(this.sourceType); + if (sourceMap) { + const hints = sourceMap.get(this.targetType); + if (hints) { + typical.push(...hints.typical); + prohibited.push(...hints.prohibited); + } + } + } + + // If no schema hints, use vocabulary + if (typical.length === 0 && this.vocabulary) { + // Get top common edge types from vocabulary + // EdgeVocabulary has byCanonical: Map + const edgeTypes = Array.from(this.vocabulary.byCanonical.keys()); + // Sort by usage or just take first N + const maxAlternatives = this.settings.inlineMaxAlternatives || 6; + alternatives.push(...edgeTypes.slice(0, maxAlternatives)); + } + + // Limit alternatives to maxAlternatives + const maxAlternatives = this.settings.inlineMaxAlternatives || 6; + if (alternatives.length > maxAlternatives) { + alternatives.splice(maxAlternatives); + } + + return { typical, alternatives, prohibited }; + } +} diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 5ee2211..2befed2 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -31,6 +31,12 @@ 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 { 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, @@ -69,6 +75,8 @@ export class InterviewWizardModal extends Modal { private previewMode: Map = new Map(); // Note index for entity picker (shared instance) private noteIndex: NoteIndex | null = null; + // Vocabulary and schema for inline micro suggester + private vocabulary: EdgeVocabulary | null = null; constructor( app: App, @@ -469,8 +477,33 @@ export class InterviewWizardModal extends Modal { new EntityPickerModal( app, this.noteIndex, - (result: EntityPickerResult) => { - insertWikilinkIntoTextarea(textarea, result.basename); + async (result: EntityPickerResult) => { + // Check if inline micro edging is enabled (also for toolbar) + // 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; + + let linkText = `[[${result.basename}]]`; + + if (shouldRunInlineMicro) { + // Get current step for section key resolution + const currentStep = getCurrentStep(this.state); + if (currentStep) { + console.log("[Mindnet] Starting inline micro edging from toolbar"); + const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); + if (edgeType && typeof edgeType === "string") { + // Use [[rel:type|link]] format + linkText = `[[rel:${edgeType}|${result.basename}]]`; + } + } + } + + // Insert link with rel: prefix if edge type was selected + // Extract inner part (without [[ and ]]) + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); } @@ -1391,8 +1424,30 @@ export class InterviewWizardModal extends Modal { new EntityPickerModal( app, this.noteIndex, - (result: EntityPickerResult) => { - insertWikilinkIntoTextarea(textarea, result.basename); + async (result: EntityPickerResult) => { + // Check if inline micro edging is enabled (also for toolbar in loops) + // 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; + + let linkText = `[[${result.basename}]]`; + + if (shouldRunInlineMicro) { + // Get current step for section key resolution (use nested step in loop context) + console.log("[Mindnet] Starting inline micro edging from toolbar (loop)"); + const edgeType = await this.handleInlineMicroEdging(nestedStep, result.basename, result.path); + if (edgeType && typeof edgeType === "string") { + // Use [[rel:type|link]] format + linkText = `[[rel:${edgeType}|${result.basename}]]`; + } + } + + // Insert link with rel: prefix if edge type was selected + // Extract inner part (without [[ and ]]) + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); } @@ -2278,17 +2333,22 @@ export class InterviewWizardModal extends Modal { } this.applyPatches(); - // Run semantic mapping builder if edging mode is post_run + // Run semantic mapping builder if edging mode is post_run or both + const edgingMode = this.profile.edging?.mode; console.log("[Mindnet] Checking edging mode:", { profileKey: this.profile.key, - edgingMode: this.profile.edging?.mode, + edgingMode: edgingMode, hasEdging: !!this.profile.edging, + pendingAssignments: this.state.pendingEdgeAssignments.length, }); - if (this.profile.edging?.mode === "post_run") { + + // Support: post_run, both (inline_micro + post_run) + const shouldRunPostRun = edgingMode === "post_run" || edgingMode === "both"; + if (shouldRunPostRun) { console.log("[Mindnet] Starting post-run edging"); await this.runPostRunEdging(); } else { - console.log("[Mindnet] Post-run edging skipped (mode:", this.profile.edging?.mode || "none", ")"); + console.log("[Mindnet] Post-run edging skipped (mode:", edgingMode || "none", ")"); } this.onSubmit({ applied: true, patches: this.state.patches }); @@ -2316,6 +2376,114 @@ export class InterviewWizardModal extends Modal { }); } + /** + * Handle inline micro edging after entity picker selection. + * Returns the selected edge type, or null if skipped/cancelled. + */ + private async handleInlineMicroEdging( + step: InterviewStep, + linkBasename: string, + linkPath: string + ): Promise { + if (!this.settings) { + console.warn("[Mindnet] Cannot run inline micro edging: settings not provided"); + return null; + } + + try { + // Load vocabulary if not already loaded + if (!this.vocabulary) { + try { + const vocabText = await VocabularyLoader.loadText( + this.app, + this.settings.edgeVocabularyPath + ); + this.vocabulary = parseEdgeVocabulary(vocabText); + } catch (e) { + console.warn("[Mindnet] Failed to load vocabulary for inline micro:", e); + // Continue without vocabulary + } + } + + // Get graph schema + let graphSchema = null; + if (this.plugin?.ensureGraphSchemaLoaded) { + graphSchema = await this.plugin.ensureGraphSchemaLoaded(); + } + + // Get source note ID and type + const sourceContent = this.fileContent; + const { extractFrontmatterId } = await import("../parser/parseFrontmatter"); + const sourceNoteId = extractFrontmatterId(sourceContent) || undefined; + const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/); + let sourceType: string | undefined; + if (sourceFrontmatter && sourceFrontmatter[1]) { + const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m); + if (typeMatch && typeMatch[1]) { + sourceType = typeMatch[1].trim(); + } + } + + // Get target note ID and type + let targetNoteId: string | undefined; + let targetType: string | undefined; + try { + const targetFile = this.app.vault.getAbstractFileByPath(linkPath); + if (targetFile && targetFile instanceof TFile) { + const targetContent = await this.app.vault.read(targetFile); + targetNoteId = extractFrontmatterId(targetContent) || undefined; + const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/); + if (targetFrontmatter && targetFrontmatter[1]) { + const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m); + if (typeMatch && typeMatch[1]) { + targetType = typeMatch[1].trim(); + } + } + } + } catch (e) { + // Target note might not exist yet, that's OK + console.debug("[Mindnet] Could not read target note for inline micro:", e); + } + + // Show inline edge type modal + 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). */ @@ -2348,13 +2516,16 @@ export class InterviewWizardModal extends Modal { allowOverwriteExistingMappings: false, }; - // Run semantic mapping builder + // Run semantic mapping builder with pending assignments const result: BuildResult = await buildSemanticMappings( this.app, this.file, edgingSettings, false, // allowOverwrite: false (respect existing) - this.plugin + this.plugin, + { + pendingAssignments: this.state.pendingEdgeAssignments, + } ); // Show summary notice @@ -2536,11 +2707,42 @@ export class InterviewWizardModal extends Modal { new EntityPickerModal( this.app, this.noteIndex, - (result: EntityPickerResult) => { - // Store basename in collected data - this.state.collectedData.set(step.key, result.basename); + 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(); diff --git a/src/ui/MindnetSettingTab.ts b/src/ui/MindnetSettingTab.ts index 7e8fe3f..1af9f8c 100644 --- a/src/ui/MindnetSettingTab.ts +++ b/src/ui/MindnetSettingTab.ts @@ -583,6 +583,40 @@ export class MindnetSettingTab extends PluginSettingTab { }) ); + // Inline micro edge suggester + new Setting(containerEl) + .setName("Inline micro edge suggester enabled") + .setDesc( + "Aktiviert den Inline-Micro-Edge-Suggester. Zeigt nach dem Einfügen eines Links über den Entity Picker sofort eine Edge-Typ-Auswahl an (nur wenn Profil edging.mode=inline_micro)." + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.inlineMicroEnabled) + .onChange(async (value) => { + this.plugin.settings.inlineMicroEnabled = value; + await this.plugin.saveSettings(); + }) + ); + + // Inline max alternatives + new Setting(containerEl) + .setName("Inline max alternatives") + .setDesc( + "Maximale Anzahl von alternativen Edge-Typen, die im Inline-Micro-Modal angezeigt werden (Standard: 6)." + ) + .addText((text) => + text + .setPlaceholder("6") + .setValue(String(this.plugin.settings.inlineMaxAlternatives)) + .onChange(async (value) => { + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && numValue > 0) { + this.plugin.settings.inlineMaxAlternatives = numValue; + await this.plugin.saveSettings(); + } + }) + ); + // ============================================ // 8. Debug & Development // ============================================ diff --git a/src/ui/markdownToolbar.ts b/src/ui/markdownToolbar.ts index a8d1e47..aae58c8 100644 --- a/src/ui/markdownToolbar.ts +++ b/src/ui/markdownToolbar.ts @@ -73,6 +73,41 @@ export function applyWikiLink( return { newText, newSelectionStart, newSelectionEnd }; } +/** + * Apply rel:type link formatting (for inline edge type assignment). + * Format: [[rel:edgeType|link]] + * If no selection, inserts [[rel:type|text]] with cursor between brackets. + */ +export function applyRelLink( + text: string, + selectionStart: number, + selectionEnd: number, + edgeType: string +): ToolbarResult { + const selectedText = text.substring(selectionStart, selectionEnd); + + let newText: string; + let newSelectionStart: number; + let newSelectionEnd: number; + + if (selectedText) { + // Wrap selected text with [[rel:type|...]] + const relLink = `[[rel:${edgeType}|${selectedText}]]`; + newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd); + newSelectionStart = selectionStart + relLink.length; + newSelectionEnd = newSelectionStart; + } else { + // Insert [[rel:type|text]] with cursor between brackets + const relLink = `[[rel:${edgeType}|text]]`; + newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd); + // Select "text" part for easy editing + newSelectionStart = selectionStart + relLink.indexOf("|") + 1; + newSelectionEnd = selectionStart + 4; // Select "text" + } + + return { newText, newSelectionStart, newSelectionEnd }; +} + /** * Apply heading prefix to current line(s). * Toggles heading if already present.