MVP 1.1.0
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:
parent
7e256bd2e9
commit
9f051423ce
66
src/main.ts
66
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) {
|
||||
|
|
|
|||
852
src/mapping/edgeTypeSelector.ts
Normal file
852
src/mapping/edgeTypeSelector.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
158
src/mapping/updateMappingBlocks.ts
Normal file
158
src/mapping/updateMappingBlocks.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}` });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user