diff --git a/src/main.ts b/src/main.ts index 052b3a9..b44f8a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,7 @@ import { type PendingCreateHint, } from "./unresolvedLink/adoptHelpers"; import { AdoptNoteModal } from "./ui/AdoptNoteModal"; +import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "./mapping/edgeTypeSelector"; export default class MindnetCausalAssistantPlugin extends Plugin { settings: MindnetSettings; @@ -360,11 +361,53 @@ export default class MindnetCausalAssistantPlugin extends Plugin { }, }); + this.addCommand({ + id: "mindnet-change-edge-type", + name: "Mindnet: Edge-Type ändern", + editorCallback: async (editor) => { + try { + console.log("[Main] Edge-Type ändern command called"); + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || activeFile.extension !== "md") { + new Notice("Bitte öffnen Sie eine Markdown-Datei"); + return; + } + + console.log("[Main] Active file:", activeFile.path); + const content = editor.getValue(); + console.log("[Main] Content length:", content.length); + const context = detectEdgeSelectorContext(editor, content); + + if (!context) { + console.warn("[Main] Context could not be detected"); + new Notice("Kontext konnte nicht erkannt werden"); + return; + } + + console.log("[Main] Context detected:", context.mode); + await changeEdgeTypeForLinks( + this.app, + editor, + activeFile, + this.settings, + context, + { ensureGraphSchemaLoaded: () => this.ensureGraphSchemaLoaded() } + ); + console.log("[Main] changeEdgeTypeForLinks completed"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[Main] Error in edge-type command:", e); + new Notice(`Fehler beim Ändern des Edge-Types: ${msg}`); + } + }, + }); + this.addCommand({ id: "mindnet-build-semantic-mappings", name: "Mindnet: Build semantic mapping blocks (by section)", - callback: async () => { + editorCallback: async (editor) => { try { + console.log("[Main] Build semantic mapping blocks command called"); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new Notice("No active file"); @@ -376,6 +419,27 @@ export default class MindnetCausalAssistantPlugin extends Plugin { return; } + // Check context first - if there's a selection or cursor in link, use edge-type selector + const content = editor.getValue(); + const context = detectEdgeSelectorContext(editor, content); + + if (context && (context.mode === "single-link" || context.mode === "selection-links" || context.mode === "create-link")) { + // Use edge-type selector for specific links or create new link + console.log("[Main] Using edge-type selector for context:", context.mode); + await changeEdgeTypeForLinks( + this.app, + editor, + activeFile, + this.settings, + context, + { ensureGraphSchemaLoaded: () => this.ensureGraphSchemaLoaded() } + ); + return; + } + + // Otherwise, process whole note + console.log("[Main] Processing whole note"); + // Check if overwrite is needed let allowOverwrite = false; if (this.settings.allowOverwriteExistingMappings) { diff --git a/src/mapping/edgeTypeSelector.ts b/src/mapping/edgeTypeSelector.ts new file mode 100644 index 0000000..feebbd7 --- /dev/null +++ b/src/mapping/edgeTypeSelector.ts @@ -0,0 +1,852 @@ +/** + * Edge Type Selector: Change edge types for links in notes or interview fields. + * Handles different contexts: cursor in link, selection with links, whole note, etc. + */ + +import { App, Editor, TFile, Notice } from "obsidian"; +import type { MindnetSettings } from "../settings"; +import { extractWikilinks } from "./sectionParser"; +import { normalizeLinkTarget } from "../unresolvedLink/linkHelpers"; +import { LinkPromptModal, type LinkPromptDecision } from "../ui/LinkPromptModal"; +import { VocabularyLoader } from "../vocab/VocabularyLoader"; +import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary"; +import type { EdgeVocabulary } from "../vocab/types"; +import { parseGraphSchema, type GraphSchema } from "./graphSchema"; +import { getSourceType } from "./worklistBuilder"; +import type { LinkWorkItem } from "./worklistBuilder"; +import { extractExistingMappings } from "./mappingExtractor"; + +/** + * Find wikilink at cursor position in content. + * Returns link info with start/end positions and target. + */ +function findWikilinkAtPosition(content: string, cursorOffset: number): { start: number; end: number; target: string; basename: string } | null { + // Find all [[...]] pairs in content (including [[rel:type|link]] format) + const linkPattern = /\[\[([^\]]+)\]\]/g; + const matches: Array<{ start: number; end: number; target: string }> = []; + let match; + + while ((match = linkPattern.exec(content)) !== null) { + const start = match.index; + const end = match.index + match[0].length; + const target = match[1] || ""; + + if (target) { + matches.push({ start, end, target }); + } + } + + if (matches.length === 0) return null; + + // Find the link that contains the cursor or is closest to it + for (const link of matches) { + if (cursorOffset >= link.start && cursorOffset <= link.end) { + // Cursor is inside this link + let basename: string; + // Check if it's a rel: link format [[rel:type|link]] + if (link.target.startsWith("rel:")) { + // Extract link part after | + const parts = link.target.split("|"); + if (parts.length >= 2) { + // It's [[rel:type|link]] format, extract the link part + const linkPart = parts[1] || ""; + basename = normalizeLinkTarget(linkPart); + } else { + // Invalid format, skip + continue; + } + } else { + // Normal link format [[link]] + basename = normalizeLinkTarget(link.target); + } + if (basename) { + return { ...link, basename }; + } + } + } + + // Don't find nearest link - only return if cursor is directly inside a link + // This prevents accidentally selecting a link when cursor is just near it + return null; +} + +export interface EdgeSelectorContext { + mode: "single-link" | "selection-links" | "whole-note" | "create-link"; + linkBasename?: string; // For single-link mode + linkBasenames?: string[]; // For selection-links mode + selectedText?: string; // For create-link mode + startPos?: number; // Start position in content + endPos?: number; // End position in content +} + +/** + * Detect context from editor state (cursor position, selection). + * Works with both Editor and HTMLTextAreaElement. + */ +export function detectEdgeSelectorContext( + editorOrTextarea: Editor | HTMLTextAreaElement, + content: string +): EdgeSelectorContext | null { + let cursorOffset: number; + let selection: string; + let selectionStart: number; + let selectionEnd: number; + + if (editorOrTextarea instanceof HTMLTextAreaElement) { + // Textarea element + const textarea = editorOrTextarea; + cursorOffset = textarea.selectionStart; + selection = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd); + selectionStart = textarea.selectionStart; + selectionEnd = textarea.selectionEnd; + } else { + // Editor instance + const editor = editorOrTextarea; + const from = editor.getCursor("from"); + const to = editor.getCursor("to"); + selectionStart = editor.posToOffset(from); + selectionEnd = editor.posToOffset(to); + + // Check if there's actually a selection (from != to) + const hasSelection = from.line !== to.line || from.ch !== to.ch; + + if (hasSelection) { + selection = editor.getSelection(); + } else { + selection = ""; + } + + // Calculate cursor offset in content (use "from" position) + const cursor = from; + const lines = content.split(/\r?\n/); + cursorOffset = 0; + for (let i = 0; i < cursor.line && i < lines.length; i++) { + cursorOffset += lines[i]?.length || 0; + cursorOffset += 1; // Newline + } + cursorOffset += cursor.ch; + } + + console.log("[EdgeSelector] Context detection:", { + hasSelection: !!selection && selection.trim().length > 0, + selection: selection, + selectionLength: selection?.length || 0, + cursorOffset, + selectionStart, + selectionEnd, + selectionStartEqualsEnd: selectionStart === selectionEnd, + }); + + // Check if there's a selection (both string and position-based check) + const hasActualSelection = selection && selection.trim().length > 0 && selectionStart !== selectionEnd; + + if (hasActualSelection) { + // Extract all links in selection (including [[rel:type|link]] format) + const selectionText = content.substring(selectionStart, selectionEnd); + const linksInSelection = extractWikilinks(selectionText); + + console.log("[EdgeSelector] Selection contains links:", linksInSelection.length, linksInSelection); + + if (linksInSelection.length > 0) { + // Selection contains links - process all links in selection + console.log("[EdgeSelector] Mode: selection-links"); + return { + mode: "selection-links", + linkBasenames: linksInSelection, + startPos: selectionStart, + endPos: selectionEnd, + }; + } else { + // Selection without links - create link from selected text + console.log("[EdgeSelector] Mode: create-link"); + return { + mode: "create-link", + selectedText: selection.trim(), + startPos: selectionStart, + endPos: selectionEnd, + }; + } + } + + // No selection - check if cursor is inside a link + const linkAtCursor = findWikilinkAtPosition(content, cursorOffset); + if (linkAtCursor) { + console.log("[EdgeSelector] Mode: single-link, basename:", linkAtCursor.basename); + return { + mode: "single-link", + linkBasename: linkAtCursor.basename, + startPos: linkAtCursor.start, + endPos: linkAtCursor.end, + }; + } + + // Cursor outside link, no selection - process whole note + console.log("[EdgeSelector] Mode: whole-note"); + return { + mode: "whole-note", + }; +} + +/** + * Change edge type for a single link or multiple links. + * Works with both Editor and HTMLTextAreaElement. + */ +export async function changeEdgeTypeForLinks( + app: App, + editorOrTextarea: Editor | HTMLTextAreaElement, + file: TFile | null, // null for textarea (interview mode) + settings: MindnetSettings, + context: EdgeSelectorContext, + plugin?: { ensureGraphSchemaLoaded?: () => Promise }, + onUpdate?: (newContent: string) => void // Callback for textarea updates +): Promise { + // Store settings for use in helper functions + const wrapperCalloutType = settings.mappingWrapperCalloutType; + const wrapperTitle = settings.mappingWrapperTitle; + + // Load vocabulary and schema + let vocabulary: EdgeVocabulary | null = null; + let graphSchema: GraphSchema | null = null; + + try { + const vocabText = await VocabularyLoader.loadText(app, settings.edgeVocabularyPath); + vocabulary = parseEdgeVocabulary(vocabText); + } catch (e) { + new Notice(`Failed to load edge vocabulary: ${e instanceof Error ? e.message : String(e)}`); + return; + } + + if (plugin && plugin.ensureGraphSchemaLoaded) { + graphSchema = await plugin.ensureGraphSchemaLoaded(); + } else { + try { + const schemaText = await VocabularyLoader.loadText(app, settings.graphSchemaPath); + graphSchema = parseGraphSchema(schemaText); + } catch (e) { + console.warn(`Graph schema not available: ${e instanceof Error ? e.message : String(e)}`); + } + } + + // Get source type (only if file is available) + let sourceType: string | null = null; + if (file) { + sourceType = getSourceType(app, file); + } + + // Get content + let content: string; + if (file) { + content = await app.vault.read(file); + } else if (editorOrTextarea instanceof HTMLTextAreaElement) { + content = editorOrTextarea.value; + } else { + content = editorOrTextarea.getValue(); + } + + console.log("[EdgeSelector] Processing context:", context.mode); + + if (context.mode === "single-link") { + // Process single link + console.log("[EdgeSelector] Processing single link:", context.linkBasename, "at", context.startPos, "-", context.endPos); + await processSingleLink(app, editorOrTextarea, file, content, context.linkBasename!, vocabulary, sourceType, graphSchema, wrapperCalloutType, wrapperTitle, settings, plugin, onUpdate, context.startPos, context.endPos); + + // In normal editor mode (with file), update mapping blocks after changing link + if (file && editorOrTextarea instanceof Editor) { + // Read updated content from editor + const updatedContent = editorOrTextarea.getValue(); + // Write it back to file temporarily so updateMappingBlocks can process it + await app.vault.modify(file, updatedContent); + + // Update mapping blocks without prompting for other links + const { updateMappingBlocksForRelLinks } = await import("./updateMappingBlocks"); + await updateMappingBlocksForRelLinks(app, file, settings); + } + } else if (context.mode === "selection-links") { + // Process all links in selection + console.log("[EdgeSelector] Processing selection links:", context.linkBasenames); + await processMultipleLinks(app, editorOrTextarea, file, content, context.linkBasenames!, vocabulary, sourceType, graphSchema, context.startPos!, context.endPos!, wrapperCalloutType, wrapperTitle, settings, plugin, onUpdate); + + // In normal editor mode (with file), update mapping blocks after changing links + if (file && editorOrTextarea instanceof Editor) { + // Read updated content from editor + const updatedContent = editorOrTextarea.getValue(); + // Write it back to file temporarily so updateMappingBlocks can process it + await app.vault.modify(file, updatedContent); + + // Update mapping blocks without prompting for other links + const { updateMappingBlocksForRelLinks } = await import("./updateMappingBlocks"); + await updateMappingBlocksForRelLinks(app, file, settings); + } + } else if (context.mode === "whole-note") { + // Process whole note - only if file is available (not for textarea) + console.log("[EdgeSelector] Processing whole note"); + if (file) { + await processWholeNote(app, file, settings, vocabulary, graphSchema, plugin); + } else { + new Notice("Ganze Notiz neu zuordnen ist im Interview-Modus nicht verfügbar. Bitte verwenden Sie die Markierung oder positionieren Sie den Cursor in einem Link."); + } + } else if (context.mode === "create-link") { + // Create link from selected text and assign edge type + console.log("[EdgeSelector] Creating link from text:", context.selectedText); + await createLinkWithEdgeType(app, editorOrTextarea, file, content, context.selectedText!, vocabulary, sourceType, graphSchema, context.startPos!, context.endPos!, settings, plugin, onUpdate); + + // In normal editor mode (with file), update mapping blocks after creating link + if (file && editorOrTextarea instanceof Editor) { + // Read updated content from editor + const updatedContent = editorOrTextarea.getValue(); + // Write it back to file temporarily so updateMappingBlocks can process it + await app.vault.modify(file, updatedContent); + + // Update mapping blocks without prompting for other links + const { updateMappingBlocksForRelLinks } = await import("./updateMappingBlocks"); + await updateMappingBlocksForRelLinks(app, file, settings); + } + } +} + +/** + * Process a single link at cursor position. + */ +async function processSingleLink( + app: App, + editorOrTextarea: Editor | HTMLTextAreaElement, + file: TFile | null, + content: string, + linkBasename: string, + vocabulary: EdgeVocabulary, + sourceType: string | null, + graphSchema: GraphSchema | null, + wrapperCalloutType: string, + wrapperTitle: string, + settings: MindnetSettings, + plugin?: { ensureGraphSchemaLoaded?: () => Promise }, + onUpdate?: (newContent: string) => void, + linkStart?: number, + linkEnd?: number +): Promise { + // Get target type + const targetFile = app.metadataCache.getFirstLinkpathDest(linkBasename, ""); + const targetType = targetFile ? (app.metadataCache.getFileCache(targetFile)?.frontmatter?.type as string | undefined) || null : null; + + // Use link position from parameters if provided, otherwise find it + let actualLinkStart: number; + let actualLinkEnd: number; + + if (linkStart !== undefined && linkEnd !== undefined) { + // Use provided positions + actualLinkStart = linkStart; + actualLinkEnd = linkEnd; + } else { + // Find link position from cursor + let cursorOffset: number; + if (editorOrTextarea instanceof HTMLTextAreaElement) { + cursorOffset = editorOrTextarea.selectionStart; + } else { + const cursor = editorOrTextarea.getCursor(); + cursorOffset = editorOrTextarea.posToOffset(cursor); + } + + const linkAtCursor = findWikilinkAtPosition(content, cursorOffset); + if (!linkAtCursor) { + console.warn("[EdgeSelector] Could not find link at cursor position"); + return; + } + actualLinkStart = linkAtCursor.start; + actualLinkEnd = linkAtCursor.end; + } + + // Get cursor position for section detection + let cursorOffset: number; + if (editorOrTextarea instanceof HTMLTextAreaElement) { + cursorOffset = editorOrTextarea.selectionStart; + } else { + const cursor = editorOrTextarea.getCursor(); + cursorOffset = editorOrTextarea.posToOffset(cursor); + } + + const lines = content.split(/\r?\n/); + // Calculate cursor line + let cursorLine = 0; + let offset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i]?.length || 0; + if (offset + lineLength >= cursorOffset) { + cursorLine = i; + break; + } + offset += lineLength + 1; // +1 for newline + } + + // Find section containing cursor + let sectionContent = ""; + let sectionHeading: string | null = null; + let inSection = false; + + for (let i = cursorLine; i >= 0; i--) { + const line = lines[i]; + if (line === undefined) continue; + + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + sectionHeading = headingMatch[2]?.trim() || null; + inSection = true; + break; + } + } + + // Collect section content + const sectionStart = inSection ? (lines.findIndex((l, i) => i <= cursorLine && l.match(/^(#{1,6})\s+/)) + 1) : 0; + const sectionEnd = lines.findIndex((l, i) => i > cursorLine && l.match(/^(#{1,6})\s+/)); + const sectionLines = sectionEnd >= 0 ? lines.slice(sectionStart, sectionEnd) : lines.slice(sectionStart); + sectionContent = sectionLines.join("\n"); + + // Extract existing mappings from section + const mappingState = extractExistingMappings( + sectionContent, + wrapperCalloutType, + wrapperTitle + ); + + const currentType = mappingState.existingMappings.get(linkBasename) || null; + + // Get the original link text from content to preserve rel:type| format + const originalLinkText = content.substring(actualLinkStart, actualLinkEnd); + // Extract the inner part (without [[ and ]]) + const innerLinkText = originalLinkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); + + // Create work item with display link + const item: LinkWorkItem = { + link: linkBasename, + targetType, + currentType, + displayLink: innerLinkText, // Preserve rel:type|link format for display + }; + + // Show prompt modal + const prompt = new LinkPromptModal(app, item, vocabulary, sourceType, graphSchema); + const decision = await prompt.show(); + + if (decision.action === "skip" || decision.action === "keep") { + return; // No change needed + } + + // Apply edge type change: convert link to [[rel:type|link]] format + + // Ensure linkBasename is not empty + if (!linkBasename || linkBasename.trim() === "") { + new Notice("Link-Basename ist leer"); + return; + } + + // Get final edge type from decision + let finalEdgeType: string; + if (decision.action === "change" || decision.action === "setTypical") { + finalEdgeType = decision.alias || decision.edgeType; + } else { + return; // Skip or keep - no change + } + + // Ensure finalEdgeType is not empty + if (!finalEdgeType || finalEdgeType.trim() === "") { + new Notice("Edge-Type ist leer"); + return; + } + + const newLink = `[[rel:${finalEdgeType}|${linkBasename}]]`; + + console.log("[EdgeSelector] Replacing link:", { + linkBasename, + finalEdgeType, + newLink, + linkStart: actualLinkStart, + linkEnd: actualLinkEnd, + oldLink: content.substring(actualLinkStart, actualLinkEnd), + }); + + // Replace link in editor or textarea + if (editorOrTextarea instanceof HTMLTextAreaElement) { + const textarea = editorOrTextarea; + const newContent = + content.substring(0, actualLinkStart) + + newLink + + content.substring(actualLinkEnd); + + // Update textarea + textarea.value = newContent; + // Set cursor after the link + const newCursorPos = actualLinkStart + newLink.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + + // Call update callback if provided + if (onUpdate) { + onUpdate(newContent); + } + } else { + const editor = editorOrTextarea; + const from = editor.offsetToPos(actualLinkStart); + const to = editor.offsetToPos(actualLinkEnd); + editor.replaceRange(newLink, from, to); + + // In normal editor mode (with file), update mapping blocks after changing link + if (file) { + // Read updated content from editor + const updatedContent = editor.getValue(); + // Write it back to file temporarily so updateMappingBlocks can process it + await app.vault.modify(file, updatedContent); + + // Update mapping blocks without prompting for other links + const { updateMappingBlocksForRelLinks } = await import("./updateMappingBlocks"); + await updateMappingBlocksForRelLinks(app, file, settings); + } + } + + new Notice(`Edge type set to: ${finalEdgeType}`); +} + +/** + * Process multiple links in selection. + */ +async function processMultipleLinks( + app: App, + editorOrTextarea: Editor | HTMLTextAreaElement, + file: TFile | null, + content: string, + linkBasenames: string[], + vocabulary: EdgeVocabulary, + sourceType: string | null, + graphSchema: GraphSchema | null, + selectionStart: number, + selectionEnd: number, + wrapperCalloutType: string, + wrapperTitle: string, + settings: MindnetSettings, + plugin?: { ensureGraphSchemaLoaded?: () => Promise }, + onUpdate?: (newContent: string) => void +): Promise { + // Process each link in selection + const selectionText = content.substring(selectionStart, selectionEnd); + const lines = content.split(/\r?\n/); + + // Find section containing selection + const startLine = content.substring(0, selectionStart).split(/\r?\n/).length - 1; + let sectionHeading: string | null = null; + let inSection = false; + + for (let i = startLine; i >= 0; i--) { + const line = lines[i]; + if (line === undefined) continue; + + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + sectionHeading = headingMatch[2]?.trim() || null; + inSection = true; + break; + } + } + + // Extract existing mappings + const sectionStart = inSection ? (lines.findIndex((l, i) => i <= startLine && l.match(/^(#{1,6})\s+/)) + 1) : 0; + const sectionEnd = lines.findIndex((l, i) => i > startLine && l.match(/^(#{1,6})\s+/)); + const sectionLines = sectionEnd >= 0 ? lines.slice(sectionStart, sectionEnd) : lines.slice(sectionStart); + const sectionContent = sectionLines.join("\n"); + + // Extract existing mappings + const mappingState = extractExistingMappings( + sectionContent, + wrapperCalloutType, + wrapperTitle + ); + + // Process each link in the order they appear in the text + const relLinkRegex = /\[\[([^\]]+?)\]\]/g; + let match: RegExpExecArray | null; + const replacements: Array<{ start: number; end: number; newText: string }> = []; + + // First, collect all links in order of appearance in selection + const linksInOrder: Array<{ basename: string; matchIndex: number }> = []; + relLinkRegex.lastIndex = 0; + while ((match = relLinkRegex.exec(selectionText)) !== null) { + const linkText = match[1] || ""; + // Check if it's a rel: link format [[rel:type|link]] + let basename: string; + if (linkText.startsWith("rel:")) { + // Extract link part after | + const parts = linkText.split("|"); + if (parts.length >= 2) { + // It's [[rel:type|link]] format, extract the link part + const linkPart = parts[1] || ""; + basename = normalizeLinkTarget(linkPart); + } else { + // Invalid format, skip + continue; + } + } else { + // Normal link format [[link]] + basename = normalizeLinkTarget(linkText); + } + if (basename) { + linksInOrder.push({ + basename, + matchIndex: match.index, + }); + } + } + + // First, collect all decisions in order + const decisions = new Map(); + // Also store the original link text for each basename to preserve display format + const linkTextMap = new Map(); // basename -> original inner link text + + // Build link text map first + relLinkRegex.lastIndex = 0; + while ((match = relLinkRegex.exec(selectionText)) !== null) { + const linkText = match[1] || ""; + // Check if it's a rel: link format [[rel:type|link]] + let basename: string; + if (linkText.startsWith("rel:")) { + // Extract link part after | + const parts = linkText.split("|"); + if (parts.length >= 2) { + // It's [[rel:type|link]] format, extract the link part + const linkPart = parts[1] || ""; + basename = normalizeLinkTarget(linkPart); + } else { + // Invalid format, skip + continue; + } + } else { + // Normal link format [[link]] + basename = normalizeLinkTarget(linkText); + } + if (basename) { + linkTextMap.set(basename, linkText); + } + } + + for (const { basename } of linksInOrder) { + const targetFile = app.metadataCache.getFirstLinkpathDest(basename, ""); + const targetType = targetFile ? (app.metadataCache.getFileCache(targetFile)?.frontmatter?.type as string | undefined) || null : null; + const currentType = mappingState.existingMappings.get(basename) || null; + + // Get the original link text from map to preserve rel:type| format + const originalLinkText = linkTextMap.get(basename) || basename; + + const item: LinkWorkItem = { + link: basename, + targetType, + currentType, + displayLink: originalLinkText, // Preserve rel:type|link format for display + }; + + const prompt = new LinkPromptModal(app, item, vocabulary, sourceType, graphSchema); + const decision = await prompt.show(); + + if (decision.action !== "skip") { + decisions.set(basename, decision); + } + } + + // Apply replacements in reverse order + relLinkRegex.lastIndex = 0; + const allMatches: Array<{ match: RegExpExecArray; basename: string }> = []; + + while ((match = relLinkRegex.exec(selectionText)) !== null) { + const linkText = match[1] || ""; + // Check if it's a rel: link format [[rel:type|link]] + let basename: string; + if (linkText.startsWith("rel:")) { + // Extract link part after | + const parts = linkText.split("|"); + if (parts.length >= 2) { + // It's [[rel:type|link]] format, extract the link part + const linkPart = parts[1] || ""; + basename = normalizeLinkTarget(linkPart); + } else { + // Invalid format, skip + continue; + } + } else { + // Normal link format [[link]] + basename = normalizeLinkTarget(linkText); + } + if (basename && decisions.has(basename)) { + const decision = decisions.get(basename)!; + let finalEdgeType: string; + if (decision.action === "change" || decision.action === "setTypical") { + finalEdgeType = decision.alias || decision.edgeType; + } else { + continue; // Skip or keep - no change + } + // Ensure basename and finalEdgeType are not empty + if (!basename || basename.trim() === "" || !finalEdgeType || finalEdgeType.trim() === "") { + continue; + } + + const newLink = `[[rel:${finalEdgeType}|${basename}]]`; + + allMatches.push({ + match, + basename, + }); + + replacements.push({ + start: selectionStart + match.index, + end: selectionStart + match.index + match[0].length, + newText: newLink, + }); + } + } + + // Apply replacements in reverse order + replacements.sort((a, b) => b.start - a.start); + + if (editorOrTextarea instanceof HTMLTextAreaElement) { + const textarea = editorOrTextarea; + let newContent = content; + + for (const replacement of replacements) { + newContent = + newContent.substring(0, replacement.start) + + replacement.newText + + newContent.substring(replacement.end); + } + + // Update textarea + textarea.value = newContent; + // Set cursor at end of selection + const lastReplacement = replacements[replacements.length - 1]; + if (lastReplacement) { + const newCursorPos = lastReplacement.start + lastReplacement.newText.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + } + + // Call update callback if provided + if (onUpdate) { + onUpdate(newContent); + } + } else { + const editor = editorOrTextarea; + for (const replacement of replacements) { + const from = editor.offsetToPos(replacement.start); + const to = editor.offsetToPos(replacement.end); + editor.replaceRange(replacement.newText, from, to); + } + + // In normal editor mode (with file), we just update the links + // The user can manually trigger "Build semantic mapping blocks" if needed + // This avoids processing all links in the note + } + + new Notice(`Edge types updated for ${decisions.size} link(s)`); +} + +/** + * Process whole note using buildSemanticMappings. + */ +async function processWholeNote( + app: App, + file: TFile, + settings: MindnetSettings, + vocabulary: EdgeVocabulary | null, + graphSchema: GraphSchema | null, + plugin?: { ensureGraphSchemaLoaded?: () => Promise } +): Promise { + // Use existing buildSemanticMappings function + const { buildSemanticMappings } = await import("./semanticMappingBuilder"); + await buildSemanticMappings(app, file, settings, false, plugin); + new Notice("Edge types processed for whole note"); +} + +/** + * Create link from selected text and assign edge type. + */ +async function createLinkWithEdgeType( + app: App, + editorOrTextarea: Editor | HTMLTextAreaElement, + file: TFile | null, + content: string, + selectedText: string, + vocabulary: EdgeVocabulary, + sourceType: string | null, + graphSchema: GraphSchema | null, + selectionStart: number, + selectionEnd: number, + settings: MindnetSettings, + plugin?: { ensureGraphSchemaLoaded?: () => Promise }, + onUpdate?: (newContent: string) => void +): Promise { + // Use selected text as link basename - only remove invalid filename characters + // Invalid characters for filenames: < > : " / \ | ? * + // Keep spaces, Umlaute, and other valid characters + const linkBasename = selectedText + .replace(/[<>:"/\\|?*]/g, "") // Remove invalid filename characters + .trim(); + + // Show edge type selector for new link + const targetFile = app.metadataCache.getFirstLinkpathDest(linkBasename, ""); + const targetType = targetFile ? (app.metadataCache.getFileCache(targetFile)?.frontmatter?.type as string | undefined) || null : null; + + const item: LinkWorkItem = { + link: linkBasename, + targetType, + currentType: null, // New link, no current type + }; + + const prompt = new LinkPromptModal(app, item, vocabulary, sourceType, graphSchema); + const decision = await prompt.show(); + + if (decision.action === "skip") { + return; + } + + // Create link with edge type + let finalEdgeType: string; + if (decision.action === "change" || decision.action === "setTypical") { + finalEdgeType = decision.alias || decision.edgeType; + } else { + return; // Keep - shouldn't happen for new link + } + + // Ensure linkBasename and finalEdgeType are not empty + if (!linkBasename || linkBasename.trim() === "") { + new Notice("Link-Basename ist leer"); + return; + } + if (!finalEdgeType || finalEdgeType.trim() === "") { + new Notice("Edge-Type ist leer"); + return; + } + + const newLink = `[[rel:${finalEdgeType}|${linkBasename}]]`; + + // Replace selected text with link + if (editorOrTextarea instanceof HTMLTextAreaElement) { + const textarea = editorOrTextarea; + const newContent = + content.substring(0, selectionStart) + + newLink + + content.substring(selectionEnd); + + // Update textarea + textarea.value = newContent; + // Set cursor after the link + const newCursorPos = selectionStart + newLink.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + + // Call update callback if provided + if (onUpdate) { + onUpdate(newContent); + } + } else { + const editor = editorOrTextarea; + const from = editor.offsetToPos(selectionStart); + const to = editor.offsetToPos(selectionEnd); + editor.replaceRange(newLink, from, to); + + // In normal editor mode (with file), we just create the link + // The user can manually trigger "Build semantic mapping blocks" if needed + // This avoids processing all links in the note + } + + new Notice(`Link created with edge type: ${finalEdgeType}`); +} diff --git a/src/mapping/sectionParser.ts b/src/mapping/sectionParser.ts index 198a7a5..d326f03 100644 --- a/src/mapping/sectionParser.ts +++ b/src/mapping/sectionParser.ts @@ -2,6 +2,8 @@ * Section parser: Split markdown by headings and extract wikilinks per section. */ +import { normalizeLinkTarget } from "../unresolvedLink/linkHelpers"; + export interface NoteSection { heading: string | null; // null for content before first heading headingLevel: number; // 0 for content before first heading @@ -99,9 +101,31 @@ export function extractWikilinks(markdown: string): string[] { let match: RegExpExecArray | null; while ((match = wikilinkRegex.exec(markdown)) !== null) { if (match[1]) { - const link = match[1].trim(); - if (link) { - links.add(link); + const target = match[1].trim(); + if (target) { + // Check if it's a rel: link format [[rel:type|link]] + if (target.startsWith("rel:")) { + // Extract link part after | + const parts = target.split("|"); + if (parts.length >= 2) { + // It's [[rel:type|link]] format, extract the link part + const linkPart = parts[1] || ""; + if (linkPart) { + // Normalize: remove alias (|alias) and heading (#heading) + const normalized = normalizeLinkTarget(linkPart); + if (normalized) { + links.add(normalized); + } + } + } + } else { + // Normal link format [[link]] + // Normalize: remove alias (|alias) and heading (#heading) + const normalized = normalizeLinkTarget(target); + if (normalized) { + links.add(normalized); + } + } } } } diff --git a/src/mapping/updateMappingBlocks.ts b/src/mapping/updateMappingBlocks.ts new file mode 100644 index 0000000..70c3eb5 --- /dev/null +++ b/src/mapping/updateMappingBlocks.ts @@ -0,0 +1,158 @@ +/** + * Update mapping blocks for specific links without prompting for all links. + * Used when user changes specific links (single-link, selection-links, create-link). + */ + +import { App, TFile } from "obsidian"; +import type { MindnetSettings } from "../settings"; +import { splitIntoSections, type NoteSection } from "./sectionParser"; +import { parseEdgesFromCallouts } from "../parser/parseEdgesFromCallouts"; +import { + extractExistingMappings, + removeWrapperBlock, + type SectionMappingState, +} from "./mappingExtractor"; +import { + buildMappingBlock, + insertMappingBlock, + type MappingBuilderOptions, +} from "./mappingBuilder"; +import { convertRelLinksToEdges } from "../parser/parseRelLinks"; + +/** + * Update mapping blocks for sections containing rel: links, without prompting. + * Only processes links that are already in [[rel:type|link]] format. + */ +export async function updateMappingBlocksForRelLinks( + app: App, + file: TFile, + settings: MindnetSettings +): Promise { + 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; + + if (relLinkMappings.size === 0) { + // No rel: links to process + return; + } + + console.log(`[UpdateMappingBlocks] Converting ${relLinkMappings.size} rel: links to edge mappings`); + + // Split into sections + const sections = splitIntoSections(content); + + // Process sections in reverse order (to preserve line indices when modifying) + const modifiedSections: Array<{ section: NoteSection; newContent: string }> = []; + + for (const section of sections) { + if (section.links.length === 0) { + // No links, skip + modifiedSections.push({ + section, + newContent: section.content, + }); + continue; + } + + // Extract existing mappings + const mappingState = extractExistingMappings( + section.content, + settings.mappingWrapperCalloutType, + settings.mappingWrapperTitle + ); + + // Merge rel: link mappings (from [[rel:type|link]] format) into existing mappings + // These take precedence over file content mappings (update even if already mapped) + for (const [linkBasename, edgeType] of relLinkMappings.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; + })) { + // Always update rel: link mappings (they represent user's explicit choice) + mappingState.existingMappings.set(normalizedBasename, edgeType); + console.log(`[UpdateMappingBlocks] Updated rel: link mapping: ${normalizedBasename} -> ${edgeType}`); + } + } + + // Remove wrapper block if exists + let sectionContentWithoutWrapper = section.content; + if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) { + sectionContentWithoutWrapper = removeWrapperBlock( + section.content, + mappingState.wrapperBlockStartLine, + mappingState.wrapperBlockEndLine + ); + } + + // Build mapping block with updated mappings + const mappingOptions: MappingBuilderOptions = { + wrapperCalloutType: settings.mappingWrapperCalloutType, + wrapperTitle: settings.mappingWrapperTitle, + wrapperFolded: settings.mappingWrapperFolded, + defaultEdgeType: settings.defaultEdgeType, + assignUnmapped: "none", // Don't assign unmapped links, just use existing mappings + }; + + const mappingBlock = buildMappingBlock( + section.links, + mappingState.existingMappings, + mappingOptions + ); + + if (mappingBlock) { + const newContent = insertMappingBlock(sectionContentWithoutWrapper, mappingBlock); + modifiedSections.push({ + section, + newContent, + }); + } else { + // No mapping block needed + modifiedSections.push({ + section, + newContent: sectionContentWithoutWrapper, + }); + } + } + + // Reconstruct file content with modified sections + let newContent = ""; + let currentLine = 0; + + for (let i = 0; i < modifiedSections.length; i++) { + const { section, newContent: sectionNewContent } = modifiedSections[i]!; + + // Add lines before this section + if (currentLine < section.startLine) { + newContent += lines.slice(currentLine, section.startLine).join("\n"); + if (currentLine < section.startLine) { + newContent += "\n"; + } + } + + // Add modified section content + newContent += sectionNewContent; + + currentLine = section.endLine; + + // Add newline between sections (except for last section) + if (i < modifiedSections.length - 1) { + newContent += "\n"; + } + } + + // Add remaining lines after last section + if (currentLine < lines.length) { + newContent += "\n" + lines.slice(currentLine).join("\n"); + } + + // Write back to file + await app.vault.modify(file, newContent); + + console.log(`[UpdateMappingBlocks] Updated mapping blocks for ${modifiedSections.length} sections`); +} diff --git a/src/mapping/worklistBuilder.ts b/src/mapping/worklistBuilder.ts index 1b39499..1e7feeb 100644 --- a/src/mapping/worklistBuilder.ts +++ b/src/mapping/worklistBuilder.ts @@ -11,6 +11,7 @@ export interface LinkWorkItem { link: string; // Wikilink basename targetType: string | null; // Note type from metadataCache/frontmatter currentType: string | null; // Existing edge type mapping, if any + displayLink?: string; // Optional: Full link text to display (e.g., "rel:type|link" or "link|alias") } export interface SectionWorklist { diff --git a/src/parser/parseRelLinks.ts b/src/parser/parseRelLinks.ts index 713d978..80fa6f4 100644 --- a/src/parser/parseRelLinks.ts +++ b/src/parser/parseRelLinks.ts @@ -24,7 +24,12 @@ export function extractRelLinks(content: string): RelLink[] { 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) + const linkPart = match[2]?.trim(); + + // If no link part after |, skip this match (invalid format) + if (!linkPart || linkPart.trim() === "") { + continue; + } if (edgeType && linkPart) { // Extract basename (remove alias and heading) diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 2befed2..7d36167 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -25,6 +25,7 @@ import { extractFrontmatterId } from "../parser/parseFrontmatter"; import { createMarkdownToolbar, } from "./markdownToolbar"; +import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "../mapping/edgeTypeSelector"; import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; import { NoteIndex } from "../entityPicker/noteIndex"; import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"; @@ -506,6 +507,10 @@ export class InterviewWizardModal extends Modal { 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); @@ -1354,69 +1359,9 @@ export class InterviewWizardModal extends Modal { if (textarea) { const itemToolbar = createMarkdownToolbar( textarea, - async () => { - // Get current value from textarea before toggling - let currentValue = textarea.value; - console.log("Preview toggle clicked (loop)", { - textareaValue: currentValue, - textareaValueLength: currentValue?.length || 0, - existingValue: existingValue, - existingValueLength: existingValue?.length || 0, - previewKey: previewKey, - loopKey: loopKey, - nestedStepKey: nestedStep.key, - }); - - // If textarea is empty, try to get from draft - if (!currentValue || currentValue.trim() === "") { - const currentLoopState = this.state.loopRuntimeStates.get(loopKey); - console.log("Textarea empty, checking draft", { - loopStateExists: !!currentLoopState, - draftValue: currentLoopState?.draft[nestedStep.key], - }); - if (currentLoopState) { - const draftValue = currentLoopState.draft[nestedStep.key]; - if (draftValue) { - currentValue = String(draftValue); - console.log("Using draft value", { draftValue: currentValue }); - } - } - } - - // Update draft with current value - onFieldChange(nestedStep.key, currentValue); - - // Toggle preview mode - const newPreviewMode = !this.previewMode.get(previewKey); - this.previewMode.set(previewKey, newPreviewMode); - console.log("Preview mode toggled", { - newPreviewMode: newPreviewMode, - previewKey: previewKey, - }); - - // 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 || ""; - console.log("Rendering preview", { - valueToRender: valueToRender, - valueLength: valueToRender.length, - valuePreview: valueToRender.substring(0, 100), - }); - await this.updatePreview(previewContainer, valueToRender); - } else { - // Switching back to edit mode - previewContainer.style.display = "none"; - textEditorContainer.style.display = "block"; - backToEditWrapper.style.display = "none"; - } - }, + undefined, // No preview toggle for loop items (app: App) => { - // Open entity picker modal for loop nested step + // Entity picker for loop items if (!this.noteIndex) { new Notice("Note index not available"); return; @@ -1425,8 +1370,7 @@ export class InterviewWizardModal extends Modal { app, this.noteIndex, async (result: EntityPickerResult) => { - // Check if inline micro edging is enabled (also for toolbar in loops) - // Support: inline_micro, both (inline_micro + post_run) + // Check if inline micro edging is enabled const edgingMode = this.profile.edging?.mode; const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && @@ -1435,21 +1379,21 @@ export class InterviewWizardModal extends Modal { 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)"); + // nestedStep is already available in this scope 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(); + }, + async (app: App, textarea: HTMLTextAreaElement) => { + // Edge-Type-Selektor für Loop-Items + await this.handleEdgeTypeSelectorForTextarea(app, textarea, nestedStep, textEditorContainer); } ); textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); @@ -2236,6 +2180,13 @@ export class InterviewWizardModal extends Modal { 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); @@ -2380,6 +2331,60 @@ export class InterviewWizardModal extends Modal { * Handle inline micro edging after entity picker selection. * Returns the selected edge type, or null if skipped/cancelled. */ + /** + * Handle edge type selector for textarea in interview mode. + */ + private async handleEdgeTypeSelectorForTextarea( + app: App, + textarea: HTMLTextAreaElement, + step: InterviewStep, + containerEl: HTMLElement + ): Promise { + try { + const content = textarea.value; + const context = detectEdgeSelectorContext(textarea, content); + + if (!context) { + new Notice("Kontext konnte nicht erkannt werden"); + return; + } + + // Update callback to sync with state + const onUpdate = (newContent: string) => { + textarea.value = newContent; + // Update stored value + this.currentInputValues.set(step.key, newContent); + this.state.collectedData.set(step.key, newContent); + }; + + // For interview mode, we don't have a file, so pass null + // We'll need to get source/target types differently + if (!this.settings) { + new Notice("Einstellungen nicht verfügbar"); + return; + } + + await changeEdgeTypeForLinks( + app, + textarea, + null, // No file in interview mode + this.settings, + context, + this.plugin?.ensureGraphSchemaLoaded ? { ensureGraphSchemaLoaded: async () => { + if (this.plugin?.ensureGraphSchemaLoaded) { + return await this.plugin.ensureGraphSchemaLoaded(); + } + return null; + } } : undefined, + onUpdate + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + new Notice(`Fehler beim Ändern des Edge-Types: ${msg}`); + console.error(e); + } + } + private async handleInlineMicroEdging( step: InterviewStep, linkBasename: string, diff --git a/src/ui/LinkPromptModal.ts b/src/ui/LinkPromptModal.ts index 91af0e2..e243b70 100644 --- a/src/ui/LinkPromptModal.ts +++ b/src/ui/LinkPromptModal.ts @@ -63,7 +63,9 @@ export class LinkPromptModal extends Modal { // Link info const linkInfo = contentEl.createEl("div", { cls: "link-info" }); - linkInfo.createEl("h2", { text: `Link: [[${this.item.link}]]` }); + // Use displayLink if available (preserves rel:type|link format), otherwise use link + const linkDisplayText = this.item.displayLink || this.item.link; + linkInfo.createEl("h2", { text: `Link: [[${linkDisplayText}]]` }); if (this.item.targetType) { linkInfo.createEl("p", { text: `Target type: ${this.item.targetType}` }); diff --git a/src/ui/markdownToolbar.ts b/src/ui/markdownToolbar.ts index aae58c8..7ad8404 100644 --- a/src/ui/markdownToolbar.ts +++ b/src/ui/markdownToolbar.ts @@ -394,7 +394,8 @@ function applyToTextarea( export function createMarkdownToolbar( textarea: HTMLTextAreaElement, onTogglePreview?: () => void, - onPickNote?: (app: any) => void + onPickNote?: (app: any) => void, + onChangeEdgeType?: (app: any, textarea: HTMLTextAreaElement) => Promise ): HTMLElement { const toolbar = document.createElement("div"); toolbar.className = "markdown-toolbar"; @@ -490,17 +491,6 @@ export function createMarkdownToolbar( }); toolbar.appendChild(codeBtn); - // Link (WikiLink) - const linkBtn = createToolbarButton("🔗", "Wiki Link", () => { - const result = applyWikiLink( - textarea.value, - textarea.selectionStart, - textarea.selectionEnd - ); - applyToTextarea(textarea, result); - }); - toolbar.appendChild(linkBtn); - // Pick note button (if callback provided) if (onPickNote) { const pickNoteBtn = createToolbarButton("📎", "Pick note…", () => { @@ -514,6 +504,19 @@ export function createMarkdownToolbar( }); toolbar.appendChild(pickNoteBtn); } + + // Edge-Type-Selektor Button + if (onChangeEdgeType) { + const edgeTypeBtn = createToolbarButton("🔗", "Edge-Type ändern", async () => { + const app = (window as any).app; + if (app && onChangeEdgeType) { + await onChangeEdgeType(app, textarea); + } else { + console.warn("App not available for edge type selector"); + } + }); + toolbar.appendChild(edgeTypeBtn); + } // Preview toggle if (onTogglePreview) {