MVP 1.1.0
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

Implement edge type selection enhancements in Mindnet plugin

- Added a new command for changing edge types in Markdown files, integrating edge type selection into the editor context.
- Enhanced the InterviewWizardModal to support edge type selection for textarea inputs, improving user interaction during interviews.
- Updated the LinkPromptModal to display the full link text when available, preserving the rel:type|link format.
- Introduced a button in the markdown toolbar for quick access to edge type selection, streamlining the user experience.
- Improved error handling and logging for edge type changes, ensuring better feedback during operations.
This commit is contained in:
Lars 2026-01-17 22:15:11 +01:00
parent 7e256bd2e9
commit 9f051423ce
9 changed files with 1201 additions and 87 deletions

View File

@ -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) {

View File

@ -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<GraphSchema | null> },
onUpdate?: (newContent: string) => void // Callback for textarea updates
): Promise<void> {
// 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<GraphSchema | null> },
onUpdate?: (newContent: string) => void,
linkStart?: number,
linkEnd?: number
): Promise<void> {
// 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<GraphSchema | null> },
onUpdate?: (newContent: string) => void
): Promise<void> {
// 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<string, LinkPromptDecision>();
// Also store the original link text for each basename to preserve display format
const linkTextMap = new Map<string, string>(); // 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<GraphSchema | null> }
): Promise<void> {
// 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<GraphSchema | null> },
onUpdate?: (newContent: string) => void
): Promise<void> {
// 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}`);
}

View File

@ -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);
}
}
}
}
}

View File

@ -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<void> {
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`);
}

View File

@ -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 {

View File

@ -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)

View File

@ -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<void> {
try {
const content = textarea.value;
const context = detectEdgeSelectorContext(textarea, content);
if (!context) {
new Notice("Kontext konnte nicht erkannt werden");
return;
}
// Update callback to sync with state
const onUpdate = (newContent: string) => {
textarea.value = newContent;
// Update stored value
this.currentInputValues.set(step.key, newContent);
this.state.collectedData.set(step.key, newContent);
};
// For interview mode, we don't have a file, so pass null
// We'll need to get source/target types differently
if (!this.settings) {
new Notice("Einstellungen nicht verfügbar");
return;
}
await changeEdgeTypeForLinks(
app,
textarea,
null, // No file in interview mode
this.settings,
context,
this.plugin?.ensureGraphSchemaLoaded ? { ensureGraphSchemaLoaded: async () => {
if (this.plugin?.ensureGraphSchemaLoaded) {
return await this.plugin.ensureGraphSchemaLoaded();
}
return null;
} } : undefined,
onUpdate
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Fehler beim Ändern des Edge-Types: ${msg}`);
console.error(e);
}
}
private async handleInlineMicroEdging(
step: InterviewStep,
linkBasename: string,

View File

@ -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}` });

View File

@ -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<void>
): 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) {