Enhance inline micro edge handling and settings
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Introduced new settings for enabling inline micro edge suggestions and configuring the maximum number of alternatives displayed.
- Updated the InterviewWizardModal to support inline micro edging, allowing users to select edge types immediately after inserting links.
- Enhanced the semantic mapping builder to incorporate pending edge assignments, improving the handling of rel:type links.
- Improved the user experience with clearer logging and error handling during inline edge type selection and mapping processes.
This commit is contained in:
Lars 2026-01-17 11:54:14 +01:00
parent 0fa2197ab2
commit 7ea36fbed4
15 changed files with 1171 additions and 27 deletions

View File

@ -42,6 +42,7 @@ export function insertWikilink(
/**
* Insert wikilink into a textarea element.
* Updates the textarea value and cursor position.
* @param basename - Can be a simple basename or a full link like "rel:type|basename" or "basename"
*/
export function insertWikilinkIntoTextarea(
textarea: HTMLTextAreaElement,
@ -51,11 +52,37 @@ export function insertWikilinkIntoTextarea(
const selEnd = textarea.selectionEnd;
const currentText = textarea.value;
const result = insertWikilink(currentText, selStart, selEnd, basename);
// Check if basename already contains link format (e.g., "rel:type|basename")
let wikilink: string;
if (basename.startsWith("rel:") || basename.includes("|")) {
// Already in format like "rel:type|basename" or "basename|alias"
// Wrap with [[...]]
wikilink = `[[${basename}]]`;
} else {
// Simple basename, use normal insertWikilink
const result = insertWikilink(currentText, selStart, selEnd, basename);
textarea.value = result.text;
textarea.setSelectionRange(result.cursorPos, result.cursorPos);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
textarea.value = result.text;
textarea.setSelectionRange(result.cursorPos, result.cursorPos);
// Insert the full wikilink
const hasSelection = selEnd > selStart;
let newText: string;
let cursorPos: number;
// Trigger input event so Obsidian can update its state
if (hasSelection) {
// Replace selection with wikilink
newText = currentText.substring(0, selStart) + wikilink + currentText.substring(selEnd);
cursorPos = selStart + wikilink.length;
} else {
// Insert at cursor position
newText = currentText.substring(0, selStart) + wikilink + currentText.substring(selEnd);
cursorPos = selStart + wikilink.length;
}
textarea.value = newText;
textarea.setSelectionRange(cursorPos, cursorPos);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}

View File

@ -123,7 +123,7 @@ function parseProfile(
const edgingRaw = raw.edging as Record<string, unknown>;
profile.edging = {};
if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro") {
if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro" || edgingRaw.mode === "both") {
profile.edging.mode = edgingRaw.mode;
}
if (typeof edgingRaw.wrapperCalloutType === "string") {

View File

@ -0,0 +1,99 @@
/**
* Section key resolution for inline micro edge assignments.
*/
import { App, TFile } from "obsidian";
import type { InterviewStep } from "./types";
export interface SectionKeyContext {
file: TFile;
step: InterviewStep;
insertionPoint?: number; // Line number where link is inserted (optional)
}
/**
* Get section key for wizard context.
*
* Options:
* A) if step config provides output.sectionKey -> use it
* B) else parse active file headings and choose nearest heading at insertion point
* C) fallback "ROOT"
*/
export async function getSectionKeyForWizardContext(
app: App,
context: SectionKeyContext
): Promise<string> {
// Option A: Check if step config provides output.sectionKey
// Note: Only some step types have output property (e.g., CaptureTextStep, CaptureTextLineStep)
if (
(context.step.type === "capture_text" || context.step.type === "capture_text_line") &&
"output" in context.step &&
context.step.output &&
typeof context.step.output === "object"
) {
const output = context.step.output as Record<string, unknown>;
if (typeof output.sectionKey === "string" && output.sectionKey.trim()) {
return output.sectionKey.trim();
}
}
// Option B: Parse file headings and find nearest heading
try {
const content = await app.vault.read(context.file);
const lines = content.split(/\r?\n/);
// Find headings in file
const headings: Array<{ level: number; text: string; lineIndex: number }> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
// Match markdown headings: # Heading, ## Heading, etc.
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch && headingMatch[1] && headingMatch[2]) {
const level = headingMatch[1].length;
const text = headingMatch[2].trim();
headings.push({ level, text, lineIndex: i });
}
}
// If we have an insertion point, find nearest heading before it
if (context.insertionPoint !== undefined && headings.length > 0) {
// Find last heading before insertion point
let nearestHeading: typeof headings[0] | null = null;
for (const heading of headings) {
if (heading.lineIndex <= context.insertionPoint) {
if (!nearestHeading || heading.lineIndex > nearestHeading.lineIndex) {
nearestHeading = heading;
}
}
}
if (nearestHeading) {
// Format: "H2:Heading Text..." (truncate if too long)
const prefix = `H${nearestHeading.level}:`;
const text = nearestHeading.text.length > 50
? nearestHeading.text.substring(0, 50) + "..."
: nearestHeading.text;
return `${prefix}${text}`;
}
}
// If no insertion point or no heading found before it, use last heading
if (headings.length > 0) {
const lastHeading = headings[headings.length - 1];
if (lastHeading) {
const prefix = `H${lastHeading.level}:`;
const text = lastHeading.text.length > 50
? lastHeading.text.substring(0, 50) + "..."
: lastHeading.text;
return `${prefix}${text}`;
}
}
} catch (e) {
console.warn("[Mindnet] Failed to parse headings for section key:", e);
}
// Option C: Fallback to "ROOT"
return "ROOT";
}

View File

@ -16,7 +16,7 @@ export interface InterviewProfile {
defaults?: Record<string, unknown>;
steps: InterviewStep[];
edging?: {
mode?: "none" | "post_run" | "inline_micro"; // Semantic mapping mode (default: "none")
mode?: "none" | "post_run" | "inline_micro" | "both"; // Semantic mapping mode (default: "none"). "both" enables inline_micro + post_run
wrapperCalloutType?: string; // Override wrapper callout type
wrapperTitle?: string; // Override wrapper title
wrapperFolded?: boolean; // Override wrapper folded state

View File

@ -1,6 +1,16 @@
import type { InterviewProfile, InterviewStep } from "./types";
import type { LoopRuntimeState } from "./loopState";
export interface PendingEdgeAssignment {
filePath: string;
sectionKey: string; // identifies heading/section in file (e.g. "H2:Wendepunkte..." or "ROOT")
linkBasename: string; // target note basename
chosenRawType: string; // user chosen edge type (alias allowed)
sourceNoteId?: string; // from frontmatter.id (optional)
targetNoteId?: string; // if resolved (optional)
createdAt: number;
}
export interface WizardState {
profile: InterviewProfile;
currentStepIndex: number;
@ -10,6 +20,7 @@ export interface WizardState {
loopRuntimeStates: Map<string, LoopRuntimeState>; // loop step key -> runtime state
patches: Patch[]; // Collected patches to apply
activeLoopPath: string[]; // Stack of loop keys representing current nesting level (e.g. ["items", "item_list"])
pendingEdgeAssignments: PendingEdgeAssignment[]; // Inline micro edge assignments collected during wizard
}
export interface Patch {
@ -38,6 +49,7 @@ export function createWizardState(profile: InterviewProfile): WizardState {
loopRuntimeStates: new Map(),
patches: [],
activeLoopPath: [], // Start at top level
pendingEdgeAssignments: [], // Start with empty pending assignments
};
}

View File

@ -22,6 +22,7 @@ import { VocabularyLoader } from "../vocab/VocabularyLoader";
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
import type { EdgeVocabulary } from "../vocab/types";
import { parseGraphSchema, type GraphSchema } from "./graphSchema";
import { convertRelLinksToEdges } from "../parser/parseRelLinks";
export interface BuildResult {
sectionsProcessed: number;
@ -32,6 +33,10 @@ export interface BuildResult {
unmappedLinksSkipped: number;
}
export interface BuildOptions {
pendingAssignments?: import("../interview/wizardState").PendingEdgeAssignment[];
}
/**
* Build semantic mapping blocks for all sections in a note.
*/
@ -40,11 +45,18 @@ export async function buildSemanticMappings(
file: TFile,
settings: MindnetSettings,
allowOverwrite: boolean,
plugin?: { ensureGraphSchemaLoaded?: () => Promise<GraphSchema | null> }
plugin?: { ensureGraphSchemaLoaded?: () => Promise<GraphSchema | null> },
options?: BuildOptions
): Promise<BuildResult> {
const content = await app.vault.read(file);
let content = await app.vault.read(file);
const lines = content.split(/\r?\n/);
// Convert [[rel:type|link]] links to normal [[link]] and extract edge mappings
const { convertedContent, edgeMappings: relLinkMappings } = convertRelLinksToEdges(content);
content = convertedContent;
console.log(`[Mindnet] Converted ${relLinkMappings.size} rel: links to edge mappings`);
// Load vocabulary and schema if prompt mode
let vocabulary: EdgeVocabulary | null = null;
let graphSchema: GraphSchema | null = null;
@ -86,6 +98,14 @@ export async function buildSemanticMappings(
// Split into sections
const sections = splitIntoSections(content);
// Build pending assignments map by section key (from rel: links)
// rel: links are already converted, so we use the extracted mappings
const pendingBySection = new Map<string, Map<string, string>>(); // sectionKey -> (linkBasename -> edgeType)
// Add rel: link mappings to pendingBySection (all go to ROOT for now, will be refined per section)
// We'll match them to sections when processing each section
const globalRelMappings = relLinkMappings;
const result: BuildResult = {
sectionsProcessed: 0,
sectionsWithMappings: 0,
@ -119,6 +139,37 @@ export async function buildSemanticMappings(
settings.mappingWrapperTitle
);
// Merge pending assignments into existing mappings BEFORE building worklist
// This ensures pending assignments are available when worklist is built
// Determine section key for this section
// Note: section.heading is string | null, headingLevel is number
const sectionKey = section.heading
? `H${section.headingLevel}:${section.heading}`
: "ROOT";
// Merge rel: link mappings (from [[rel:type|link]] format) into existing mappings
// These take precedence over file content mappings
for (const [linkBasename, edgeType] of globalRelMappings.entries()) {
// Check if this link exists in this section
const normalizedBasename = linkBasename.split("|")[0]?.split("#")[0]?.trim() || linkBasename;
if (section.links.some(link => {
const normalizedLink = link.split("|")[0]?.split("#")[0]?.trim() || link;
return normalizedLink === normalizedBasename || link === normalizedBasename;
})) {
mappingState.existingMappings.set(normalizedBasename, edgeType);
console.log(`[Mindnet] Merged rel: link mapping into existing mappings: ${normalizedBasename} -> ${edgeType}`);
}
}
// Also merge legacy pending assignments if any (for backward compatibility)
const pendingForSection = pendingBySection.get(sectionKey);
if (pendingForSection) {
for (const [linkBasename, edgeType] of pendingForSection.entries()) {
mappingState.existingMappings.set(linkBasename, edgeType);
console.log(`[Mindnet] Merged pending assignment into existing mappings: ${linkBasename} -> ${edgeType}`);
}
}
// Remove wrapper block if exists
let sectionContentWithoutWrapper = section.content;
if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) {
@ -134,14 +185,39 @@ export async function buildSemanticMappings(
if (settings.unassignedHandling === "prompt" && vocabulary) {
// Prompt mode: interactive assignment
// Pass the already-merged mappingState (with pending assignments) to worklist builder
const worklist = await buildSectionWorklist(
app,
section.content,
section.heading,
settings.mappingWrapperCalloutType,
settings.mappingWrapperTitle
settings.mappingWrapperTitle,
mappingState // Pass merged mappingState (includes pending assignments)
);
// Worklist items should already have currentType set from mappingState (which includes pending assignments)
// But let's verify and add debug logging
console.log(`[Mindnet] Worklist built with mappingState (includes pending assignments):`, {
sectionKey,
itemsWithCurrentType: worklist.items.filter(i => i.currentType).length,
totalItems: worklist.items.length,
pendingAssignmentsCount: pendingForSection ? pendingForSection.size : 0,
mappingStateSize: mappingState.existingMappings.size,
});
// Debug: Log which items have currentType and which don't
for (const item of worklist.items) {
const normalizedBasename = item.link.split("|")[0]?.split("#")[0]?.trim() || item.link;
if (item.currentType) {
console.log(`[Mindnet] Worklist item has currentType: ${item.link} -> ${item.currentType}`);
} else if (pendingForSection && pendingForSection.has(normalizedBasename)) {
// This shouldn't happen if mappingState was passed correctly, but log as warning
console.warn(`[Mindnet] Worklist item missing currentType despite pending assignment: ${item.link} (normalized: ${normalizedBasename})`);
// Fallback: set it now
item.currentType = pendingForSection.get(normalizedBasename) || null;
}
}
// Process each link in worklist
for (const item of worklist.items) {
// Always prompt user (even for existing mappings)

View File

@ -20,19 +20,21 @@ export interface SectionWorklist {
/**
* Build worklist for a section.
* @param mappingState Optional pre-extracted mapping state (if provided, won't re-extract)
*/
export async function buildSectionWorklist(
app: App,
sectionContent: string,
sectionHeading: string | null,
wrapperCalloutType: string,
wrapperTitle: string
wrapperTitle: string,
mappingState?: import("./mappingExtractor").SectionMappingState
): Promise<SectionWorklist> {
// Extract all wikilinks (deduplicated)
const links = extractWikilinks(sectionContent);
// Extract existing mappings
const mappingState = extractExistingMappings(
// Extract existing mappings (only if not provided)
const finalMappingState = mappingState || extractExistingMappings(
sectionContent,
wrapperCalloutType,
wrapperTitle
@ -57,8 +59,14 @@ export async function buildSectionWorklist(
}
}
// Get current mapping
const currentType = mappingState.existingMappings.get(link) || null;
// Get current mapping - try both exact match and normalized match
let currentType = finalMappingState.existingMappings.get(link) || null;
// If no exact match, try normalized (for pending assignments)
if (!currentType) {
const normalizedLink = link.split("|")[0]?.split("#")[0]?.trim() || link;
currentType = finalMappingState.existingMappings.get(normalizedLink) || null;
}
items.push({
link,

View File

@ -0,0 +1,76 @@
/**
* Parse and convert [[rel:type|Link]] format links to edge callouts.
*
* Format: [[rel:edgeType|LinkBasename]]
* Example: [[rel:depends_on|System-Architektur]]
*/
export interface RelLink {
edgeType: string;
linkBasename: string;
fullMatch: string; // The complete [[rel:type|link]] string
startIndex: number;
endIndex: number;
}
/**
* Extract all [[rel:type|link]] links from markdown content.
*/
export function extractRelLinks(content: string): RelLink[] {
const relLinks: RelLink[] = [];
// Match [[rel:type|link]] or [[rel:type|link|alias]] or [[rel:type|link#heading]]
const relLinkRegex = /\[\[rel:([^\|#\]]+)(?:\|([^\]]+?))?\]\]/g;
let match: RegExpExecArray | null;
while ((match = relLinkRegex.exec(content)) !== null) {
const edgeType = match[1]?.trim();
const linkPart = match[2]?.trim() || match[1]?.trim(); // If no |, use type as link (fallback)
if (edgeType && linkPart) {
// Extract basename (remove alias and heading)
const basename = linkPart.split("|")[0]?.split("#")[0]?.trim() || linkPart;
relLinks.push({
edgeType,
linkBasename: basename,
fullMatch: match[0],
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
}
return relLinks;
}
/**
* Convert [[rel:type|link]] links to normal [[link]] and return edge mappings.
* Returns the converted content and a map of link -> edgeType.
*/
export function convertRelLinksToEdges(content: string): {
convertedContent: string;
edgeMappings: Map<string, string>; // linkBasename -> edgeType
} {
const relLinks = extractRelLinks(content);
const edgeMappings = new Map<string, string>();
// Process in reverse order to preserve indices
let convertedContent = content;
for (let i = relLinks.length - 1; i >= 0; i--) {
const relLink = relLinks[i];
if (!relLink) continue;
// Replace [[rel:type|link]] with [[link]]
const normalLink = `[[${relLink.linkBasename}]]`;
convertedContent =
convertedContent.substring(0, relLink.startIndex) +
normalLink +
convertedContent.substring(relLink.endIndex);
// Store mapping (normalize basename)
const normalizedBasename = relLink.linkBasename.split("|")[0]?.split("#")[0]?.trim() || relLink.linkBasename;
edgeMappings.set(normalizedBasename, relLink.edgeType);
}
return { convertedContent, edgeMappings };
}

View File

@ -26,6 +26,10 @@ export interface MindnetSettings {
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
allowOverwriteExistingMappings: boolean; // default: false
defaultNotesFolder: string; // default: "" (vault root)
// Inline micro edge suggester settings
inlineMicroEnabled: boolean; // default: true
inlineMaxAlternatives: number; // default: 6
inlineCancelBehavior: "keep_link"; // default: "keep_link" (future: "revert")
}
export const DEFAULT_SETTINGS: MindnetSettings = {
@ -55,6 +59,9 @@ export interface MindnetSettings {
unassignedHandling: "prompt",
allowOverwriteExistingMappings: false,
defaultNotesFolder: "",
inlineMicroEnabled: true,
inlineMaxAlternatives: 6,
inlineCancelBehavior: "keep_link",
};
/**

View File

@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { getSectionKeyForWizardContext } from "../../interview/sectionKeyResolver";
import type { InterviewStep } from "../../interview/types";
import { App, TFile } from "obsidian";
describe("getSectionKeyForWizardContext", () => {
const mockApp = {
vault: {
read: async (file: TFile) => {
if (file.path === "test.md") {
return `# Heading 1
Content before first heading.
## Heading 2
Content in section 2.
### Heading 3
Content in section 3.
`;
}
return "";
},
},
} as unknown as App;
const mockFile = {
path: "test.md",
basename: "test",
} as TFile;
it("should use output.sectionKey if provided (future feature)", async () => {
// Note: This test documents future behavior when output.sectionKey is added to types
// For now, we test that the function handles steps without sectionKey gracefully
const step: InterviewStep = {
type: "capture_text",
key: "test",
// output.sectionKey would be here if supported
};
const result = await getSectionKeyForWizardContext(mockApp, {
file: mockFile,
step,
});
// Should fall back to heading parsing or ROOT
expect(result).toBeTruthy();
});
it("should return ROOT if no headings found", async () => {
const step: InterviewStep = {
type: "capture_text",
key: "test",
};
const emptyApp = {
vault: {
read: async () => "No headings here.",
},
} as unknown as App;
const result = await getSectionKeyForWizardContext(emptyApp, {
file: mockFile,
step,
});
expect(result).toBe("ROOT");
});
it("should find nearest heading before insertion point", async () => {
const step: InterviewStep = {
type: "capture_text",
key: "test",
};
const result = await getSectionKeyForWizardContext(mockApp, {
file: mockFile,
step,
insertionPoint: 5, // After "Content before first heading"
});
expect(result).toMatch(/^H1:Heading 1/);
});
it("should use last heading if insertion point is after all headings", async () => {
const step: InterviewStep = {
type: "capture_text",
key: "test",
};
const result = await getSectionKeyForWizardContext(mockApp, {
file: mockFile,
step,
insertionPoint: 100, // After all content
});
expect(result).toMatch(/^H3:Heading 3/);
});
it("should truncate long heading text", async () => {
const longHeadingApp = {
vault: {
read: async () => {
return `## This is a very long heading that should be truncated because it exceeds the maximum length of 50 characters
`;
},
},
} as unknown as App;
const step: InterviewStep = {
type: "capture_text",
key: "test",
};
const result = await getSectionKeyForWizardContext(longHeadingApp, {
file: mockFile,
step,
});
expect(result).toMatch(/^H2:This is a very long heading that should be trunca\.\.\./);
});
});

View File

@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import type { PendingEdgeAssignment } from "../../interview/wizardState";
describe("Pending assignment merge", () => {
it("should group pending assignments by section key", () => {
const assignments: PendingEdgeAssignment[] = [
{
filePath: "test.md",
sectionKey: "H2:Section 1",
linkBasename: "link1",
chosenRawType: "causes",
createdAt: Date.now(),
},
{
filePath: "test.md",
sectionKey: "H2:Section 1",
linkBasename: "link2",
chosenRawType: "enables",
createdAt: Date.now(),
},
{
filePath: "test.md",
sectionKey: "ROOT",
linkBasename: "link3",
chosenRawType: "relates",
createdAt: Date.now(),
},
];
// Group by section key
const bySection = new Map<string, Map<string, string>>();
for (const assignment of assignments) {
if (!bySection.has(assignment.sectionKey)) {
bySection.set(assignment.sectionKey, new Map());
}
const sectionMap = bySection.get(assignment.sectionKey)!;
sectionMap.set(assignment.linkBasename, assignment.chosenRawType);
}
expect(bySection.size).toBe(2);
expect(bySection.has("H2:Section 1")).toBe(true);
expect(bySection.has("ROOT")).toBe(true);
const section1Map = bySection.get("H2:Section 1")!;
expect(section1Map.get("link1")).toBe("causes");
expect(section1Map.get("link2")).toBe("enables");
const rootMap = bySection.get("ROOT")!;
expect(rootMap.get("link3")).toBe("relates");
});
it("should normalize link basenames (remove aliases and headings)", () => {
const assignments: PendingEdgeAssignment[] = [
{
filePath: "test.md",
sectionKey: "H2:Section 1",
linkBasename: "link1|alias",
chosenRawType: "causes",
createdAt: Date.now(),
},
{
filePath: "test.md",
sectionKey: "H2:Section 1",
linkBasename: "link2#heading",
chosenRawType: "enables",
createdAt: Date.now(),
},
];
// Simulate normalization
const normalized = new Map<string, string>();
for (const assignment of assignments) {
const normalizedBasename = assignment.linkBasename.split("|")[0]?.split("#")[0]?.trim() || assignment.linkBasename;
normalized.set(normalizedBasename, assignment.chosenRawType);
}
expect(normalized.get("link1")).toBe("causes");
expect(normalized.get("link2")).toBe("enables");
});
it("should not overwrite existing mappings", () => {
const existingMappings = new Map<string, string>([
["link1", "existing-type"],
]);
const pendingAssignment: PendingEdgeAssignment = {
filePath: "test.md",
sectionKey: "H2:Section 1",
linkBasename: "link1",
chosenRawType: "new-type",
createdAt: Date.now(),
};
// Simulate merge: only add if not already present
const normalizedBasename = pendingAssignment.linkBasename.split("|")[0]?.split("#")[0]?.trim() || pendingAssignment.linkBasename;
if (!existingMappings.has(normalizedBasename)) {
existingMappings.set(normalizedBasename, pendingAssignment.chosenRawType);
}
// Should keep existing mapping
expect(existingMappings.get("link1")).toBe("existing-type");
});
});

View File

@ -0,0 +1,341 @@
/**
* Inline micro edge type chooser modal.
* Shows recommended edge types as chips with OK/Skip/Cancel buttons.
* Improved: Auto-selects first typical, shows alternatives on click, allows choosing other types.
*/
import { App, Modal, Notice } from "obsidian";
import type { EdgeVocabulary } from "../vocab/types";
import type { GraphSchema } from "../mapping/graphSchema";
import type { MindnetSettings } from "../settings";
import { EdgeTypeChooserModal, type EdgeTypeChoice } from "./EdgeTypeChooserModal";
export interface InlineEdgeTypeResult {
chosenRawType: string | null; // null means skip
cancelled: boolean; // true if user cancelled (should keep link but no assignment)
}
export class InlineEdgeTypeModal extends Modal {
private linkBasename: string;
private sourceNoteId?: string;
private targetNoteId?: string;
private sourceType?: string;
private targetType?: string;
private vocabulary: EdgeVocabulary | null;
private graphSchema: GraphSchema | null;
private settings: MindnetSettings;
private resolve: (result: InlineEdgeTypeResult) => void;
private result: InlineEdgeTypeResult | null = null;
private selectedEdgeType: string | null = null;
private selectedAlias: string | null = null;
private expandedAlternatives: Set<string> = new Set(); // Track which chips show alternatives
constructor(
app: App,
linkBasename: string,
vocabulary: EdgeVocabulary | null,
graphSchema: GraphSchema | null,
settings: MindnetSettings,
sourceNoteId?: string,
targetNoteId?: string,
sourceType?: string,
targetType?: string
) {
super(app);
this.linkBasename = linkBasename;
this.sourceNoteId = sourceNoteId;
this.targetNoteId = targetNoteId;
this.sourceType = sourceType;
this.targetType = targetType;
this.vocabulary = vocabulary;
this.graphSchema = graphSchema;
this.settings = settings;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("inline-edge-type-modal");
// Title
contentEl.createEl("h2", {
text: "Edge type for this link?",
});
// Link info
const linkInfo = contentEl.createEl("div", {
cls: "inline-edge-type-modal__link-info",
});
linkInfo.textContent = `Link: [[${this.linkBasename}]]`;
// Get recommendations
const recommendations = this.getRecommendations();
const typical = recommendations.typical;
const alternatives = recommendations.alternatives;
const prohibited = recommendations.prohibited;
// If no recommendations at all, open full chooser directly
if (typical.length === 0 && alternatives.length === 0 && this.vocabulary) {
this.openFullChooser();
return;
}
// Auto-select first typical if available
if (typical.length > 0 && typical[0] && !this.selectedEdgeType) {
this.selectedEdgeType = typical[0];
this.result = { chosenRawType: typical[0], cancelled: false };
}
// Recommended chips (typical) - all are directly selectable, first one is preselected
if (typical.length > 0) {
const typicalContainer = contentEl.createEl("div", {
cls: "inline-edge-type-modal__section",
});
typicalContainer.createEl("div", {
cls: "inline-edge-type-modal__section-label",
text: "Recommended:",
});
const chipsContainer = typicalContainer.createEl("div", {
cls: "inline-edge-type-modal__chips",
});
for (let i = 0; i < typical.length; i++) {
const edgeType = typical[i];
if (!edgeType) continue;
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
});
// First typical is preselected
if (i === 0) {
chip.addClass("mod-cta");
}
chip.onclick = () => {
this.selectEdgeType(edgeType);
};
}
}
// Alternatives chips (always visible, not hidden)
if (alternatives.length > 0) {
const altContainer = contentEl.createEl("div", {
cls: "inline-edge-type-modal__section",
});
altContainer.createEl("div", {
cls: "inline-edge-type-modal__section-label",
text: "Alternatives:",
});
const chipsContainer = altContainer.createEl("div", {
cls: "inline-edge-type-modal__chips",
});
for (const edgeType of alternatives) {
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
});
chip.onclick = () => this.selectEdgeType(edgeType);
}
}
// Prohibited (show but disabled)
if (prohibited.length > 0) {
const prohibitedContainer = contentEl.createEl("div", {
cls: "inline-edge-type-modal__section",
});
prohibitedContainer.createEl("div", {
cls: "inline-edge-type-modal__section-label",
text: "Prohibited (not recommended):",
});
const chipsContainer = prohibitedContainer.createEl("div", {
cls: "inline-edge-type-modal__chips",
});
for (const edgeType of prohibited) {
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
});
chip.disabled = true;
chip.addClass("mod-muted");
}
}
// "Choose other type" button
if (this.vocabulary) {
const otherTypeBtn = contentEl.createEl("button", {
text: "Anderen Type wählen...",
cls: "inline-edge-type-modal__other-type-btn",
});
otherTypeBtn.style.marginTop = "1em";
otherTypeBtn.style.width = "100%";
otherTypeBtn.onclick = async () => {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary!,
this.sourceType || null,
this.targetType || null,
this.graphSchema
);
const choice: EdgeTypeChoice | null = await chooser.show();
if (choice) {
// Store the choice and close modal
this.selectEdgeType(choice.edgeType, choice.alias);
// Close this modal immediately after selection
this.close();
}
};
}
// Buttons
const buttonContainer = contentEl.createEl("div", {
cls: "inline-edge-type-modal__buttons",
});
const okBtn = buttonContainer.createEl("button", {
text: "OK",
cls: "mod-cta",
});
okBtn.onclick = () => {
// If no type selected yet, use preselected
if (!this.result && this.selectedEdgeType) {
this.result = { chosenRawType: this.selectedEdgeType, cancelled: false };
} else if (!this.result) {
// No selection possible, treat as skip
this.result = { chosenRawType: null, cancelled: false };
}
this.close();
};
const skipBtn = buttonContainer.createEl("button", {
text: "Skip",
});
skipBtn.onclick = () => {
this.result = { chosenRawType: null, cancelled: false };
this.close();
};
const cancelBtn = buttonContainer.createEl("button", {
text: "Cancel",
});
cancelBtn.onclick = () => {
this.result = { chosenRawType: null, cancelled: true };
this.close();
};
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
// Resolve with result (or skip if no result)
if (this.resolve) {
this.resolve(this.result || { chosenRawType: null, cancelled: false });
}
}
/**
* Show modal and return result.
*/
async show(): Promise<InlineEdgeTypeResult> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
private selectEdgeType(edgeType: string, alias?: string | null): void {
// Remove previous selection
const chips = this.contentEl.querySelectorAll(".inline-edge-type-modal__chip");
const chipsArray = Array.from(chips);
for (const chip of chipsArray) {
if (chip instanceof HTMLElement) {
chip.removeClass("mod-cta");
}
}
// Mark selected
const selectedChip = chipsArray.find(
(chip) => chip.textContent === edgeType || chip.textContent?.includes(edgeType)
);
if (selectedChip && selectedChip instanceof HTMLElement) {
selectedChip.addClass("mod-cta");
}
// Store result
this.selectedEdgeType = edgeType;
this.selectedAlias = alias || null;
this.result = { chosenRawType: edgeType, cancelled: false };
}
private async openFullChooser(): Promise<void> {
if (!this.vocabulary) {
// No vocabulary, treat as skip
this.result = { chosenRawType: null, cancelled: false };
this.close();
return;
}
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType || null,
this.targetType || null,
this.graphSchema
);
const choice: EdgeTypeChoice | null = await chooser.show();
if (choice) {
this.selectEdgeType(choice.edgeType, choice.alias);
this.close();
} else {
// User cancelled chooser, treat as skip
this.result = { chosenRawType: null, cancelled: false };
this.close();
}
}
private getRecommendations(): {
typical: string[];
alternatives: string[];
prohibited: string[];
} {
const typical: string[] = [];
const alternatives: string[] = [];
const prohibited: string[] = [];
// Try to get hints from graph schema
if (this.graphSchema && this.sourceType && this.targetType) {
const sourceMap = this.graphSchema.schema.get(this.sourceType);
if (sourceMap) {
const hints = sourceMap.get(this.targetType);
if (hints) {
typical.push(...hints.typical);
prohibited.push(...hints.prohibited);
}
}
}
// If no schema hints, use vocabulary
if (typical.length === 0 && this.vocabulary) {
// Get top common edge types from vocabulary
// EdgeVocabulary has byCanonical: Map<CanonicalEdgeType, EdgeTypeEntry>
const edgeTypes = Array.from(this.vocabulary.byCanonical.keys());
// Sort by usage or just take first N
const maxAlternatives = this.settings.inlineMaxAlternatives || 6;
alternatives.push(...edgeTypes.slice(0, maxAlternatives));
}
// Limit alternatives to maxAlternatives
const maxAlternatives = this.settings.inlineMaxAlternatives || 6;
if (alternatives.length > maxAlternatives) {
alternatives.splice(maxAlternatives);
}
return { typical, alternatives, prohibited };
}
}

View File

@ -31,6 +31,12 @@ import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"
import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink";
import { buildSemanticMappings, type BuildResult } from "../mapping/semanticMappingBuilder";
import type { MindnetSettings } from "../settings";
import { InlineEdgeTypeModal, type InlineEdgeTypeResult } from "./InlineEdgeTypeModal";
import { getSectionKeyForWizardContext } from "../interview/sectionKeyResolver";
import type { PendingEdgeAssignment } from "../interview/wizardState";
import { VocabularyLoader } from "../vocab/VocabularyLoader";
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
import type { EdgeVocabulary } from "../vocab/types";
import {
type LoopRuntimeState,
createLoopState,
@ -69,6 +75,8 @@ export class InterviewWizardModal extends Modal {
private previewMode: Map<string, boolean> = new Map();
// Note index for entity picker (shared instance)
private noteIndex: NoteIndex | null = null;
// Vocabulary and schema for inline micro suggester
private vocabulary: EdgeVocabulary | null = null;
constructor(
app: App,
@ -469,8 +477,33 @@ export class InterviewWizardModal extends Modal {
new EntityPickerModal(
app,
this.noteIndex,
(result: EntityPickerResult) => {
insertWikilinkIntoTextarea(textarea, result.basename);
async (result: EntityPickerResult) => {
// Check if inline micro edging is enabled (also for toolbar)
// Support: inline_micro, both (inline_micro + post_run)
const edgingMode = this.profile.edging?.mode;
const shouldRunInlineMicro =
(edgingMode === "inline_micro" || edgingMode === "both") &&
this.settings?.inlineMicroEnabled !== false;
let linkText = `[[${result.basename}]]`;
if (shouldRunInlineMicro) {
// Get current step for section key resolution
const currentStep = getCurrentStep(this.state);
if (currentStep) {
console.log("[Mindnet] Starting inline micro edging from toolbar");
const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path);
if (edgeType && typeof edgeType === "string") {
// Use [[rel:type|link]] format
linkText = `[[rel:${edgeType}|${result.basename}]]`;
}
}
}
// Insert link with rel: prefix if edge type was selected
// Extract inner part (without [[ and ]])
const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, "");
insertWikilinkIntoTextarea(textarea, innerLink);
}
).open();
}
@ -1391,8 +1424,30 @@ export class InterviewWizardModal extends Modal {
new EntityPickerModal(
app,
this.noteIndex,
(result: EntityPickerResult) => {
insertWikilinkIntoTextarea(textarea, result.basename);
async (result: EntityPickerResult) => {
// Check if inline micro edging is enabled (also for toolbar in loops)
// Support: inline_micro, both (inline_micro + post_run)
const edgingMode = this.profile.edging?.mode;
const shouldRunInlineMicro =
(edgingMode === "inline_micro" || edgingMode === "both") &&
this.settings?.inlineMicroEnabled !== false;
let linkText = `[[${result.basename}]]`;
if (shouldRunInlineMicro) {
// Get current step for section key resolution (use nested step in loop context)
console.log("[Mindnet] Starting inline micro edging from toolbar (loop)");
const edgeType = await this.handleInlineMicroEdging(nestedStep, result.basename, result.path);
if (edgeType && typeof edgeType === "string") {
// Use [[rel:type|link]] format
linkText = `[[rel:${edgeType}|${result.basename}]]`;
}
}
// Insert link with rel: prefix if edge type was selected
// Extract inner part (without [[ and ]])
const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, "");
insertWikilinkIntoTextarea(textarea, innerLink);
}
).open();
}
@ -2278,17 +2333,22 @@ export class InterviewWizardModal extends Modal {
}
this.applyPatches();
// Run semantic mapping builder if edging mode is post_run
// Run semantic mapping builder if edging mode is post_run or both
const edgingMode = this.profile.edging?.mode;
console.log("[Mindnet] Checking edging mode:", {
profileKey: this.profile.key,
edgingMode: this.profile.edging?.mode,
edgingMode: edgingMode,
hasEdging: !!this.profile.edging,
pendingAssignments: this.state.pendingEdgeAssignments.length,
});
if (this.profile.edging?.mode === "post_run") {
// Support: post_run, both (inline_micro + post_run)
const shouldRunPostRun = edgingMode === "post_run" || edgingMode === "both";
if (shouldRunPostRun) {
console.log("[Mindnet] Starting post-run edging");
await this.runPostRunEdging();
} else {
console.log("[Mindnet] Post-run edging skipped (mode:", this.profile.edging?.mode || "none", ")");
console.log("[Mindnet] Post-run edging skipped (mode:", edgingMode || "none", ")");
}
this.onSubmit({ applied: true, patches: this.state.patches });
@ -2316,6 +2376,114 @@ export class InterviewWizardModal extends Modal {
});
}
/**
* Handle inline micro edging after entity picker selection.
* Returns the selected edge type, or null if skipped/cancelled.
*/
private async handleInlineMicroEdging(
step: InterviewStep,
linkBasename: string,
linkPath: string
): Promise<string | null> {
if (!this.settings) {
console.warn("[Mindnet] Cannot run inline micro edging: settings not provided");
return null;
}
try {
// Load vocabulary if not already loaded
if (!this.vocabulary) {
try {
const vocabText = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
this.vocabulary = parseEdgeVocabulary(vocabText);
} catch (e) {
console.warn("[Mindnet] Failed to load vocabulary for inline micro:", e);
// Continue without vocabulary
}
}
// Get graph schema
let graphSchema = null;
if (this.plugin?.ensureGraphSchemaLoaded) {
graphSchema = await this.plugin.ensureGraphSchemaLoaded();
}
// Get source note ID and type
const sourceContent = this.fileContent;
const { extractFrontmatterId } = await import("../parser/parseFrontmatter");
const sourceNoteId = extractFrontmatterId(sourceContent) || undefined;
const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/);
let sourceType: string | undefined;
if (sourceFrontmatter && sourceFrontmatter[1]) {
const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m);
if (typeMatch && typeMatch[1]) {
sourceType = typeMatch[1].trim();
}
}
// Get target note ID and type
let targetNoteId: string | undefined;
let targetType: string | undefined;
try {
const targetFile = this.app.vault.getAbstractFileByPath(linkPath);
if (targetFile && targetFile instanceof TFile) {
const targetContent = await this.app.vault.read(targetFile);
targetNoteId = extractFrontmatterId(targetContent) || undefined;
const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/);
if (targetFrontmatter && targetFrontmatter[1]) {
const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m);
if (typeMatch && typeMatch[1]) {
targetType = typeMatch[1].trim();
}
}
}
} catch (e) {
// Target note might not exist yet, that's OK
console.debug("[Mindnet] Could not read target note for inline micro:", e);
}
// Show inline edge type modal
const modal = new InlineEdgeTypeModal(
this.app,
linkBasename,
this.vocabulary,
graphSchema,
this.settings,
sourceNoteId,
targetNoteId,
sourceType,
targetType
);
const result: InlineEdgeTypeResult = await modal.show();
// Handle result
if (result.cancelled) {
// Cancel: keep link but no assignment
return null;
}
if (result.chosenRawType) {
console.log("[Mindnet] Selected edge type for inline link:", {
linkBasename,
edgeType: result.chosenRawType,
});
return result.chosenRawType;
}
// Skip: no assignment created
return null;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[Mindnet] Failed to handle inline micro edging:", e);
new Notice(`Failed to handle inline edge type selection: ${msg}`);
return null;
}
}
/**
* Run semantic mapping builder after interview finish (post_run mode).
*/
@ -2348,13 +2516,16 @@ export class InterviewWizardModal extends Modal {
allowOverwriteExistingMappings: false,
};
// Run semantic mapping builder
// Run semantic mapping builder with pending assignments
const result: BuildResult = await buildSemanticMappings(
this.app,
this.file,
edgingSettings,
false, // allowOverwrite: false (respect existing)
this.plugin
this.plugin,
{
pendingAssignments: this.state.pendingEdgeAssignments,
}
);
// Show summary notice
@ -2536,11 +2707,42 @@ export class InterviewWizardModal extends Modal {
new EntityPickerModal(
this.app,
this.noteIndex,
(result: EntityPickerResult) => {
// Store basename in collected data
this.state.collectedData.set(step.key, result.basename);
async (result: EntityPickerResult) => {
// Check if inline micro edging is enabled
// Support: inline_micro, both (inline_micro + post_run)
const edgingMode = this.profile.edging?.mode;
const shouldRunInlineMicro =
(edgingMode === "inline_micro" || edgingMode === "both") &&
this.settings?.inlineMicroEnabled !== false;
console.log("[Mindnet] Entity picker result:", {
edgingMode,
shouldRunInlineMicro,
inlineMicroEnabled: this.settings?.inlineMicroEnabled,
stepKey: step.key,
});
let linkText = `[[${result.basename}]]`;
if (shouldRunInlineMicro) {
console.log("[Mindnet] Starting inline micro edging");
const edgeType = await this.handleInlineMicroEdging(step, result.basename, result.path);
if (edgeType && typeof edgeType === "string") {
// Use [[rel:type|link]] format
linkText = `[[rel:${edgeType}|${result.basename}]]`;
}
} else {
console.log("[Mindnet] Inline micro edging skipped", {
edgingMode,
inlineMicroEnabled: this.settings?.inlineMicroEnabled,
});
}
// Store link text (with rel: prefix if edge type was selected)
this.state.collectedData.set(step.key, linkText);
// Optionally store path for future use
this.state.collectedData.set(`${step.key}_path`, result.path);
this.renderStep();
}
).open();

View File

@ -583,6 +583,40 @@ export class MindnetSettingTab extends PluginSettingTab {
})
);
// Inline micro edge suggester
new Setting(containerEl)
.setName("Inline micro edge suggester enabled")
.setDesc(
"Aktiviert den Inline-Micro-Edge-Suggester. Zeigt nach dem Einfügen eines Links über den Entity Picker sofort eine Edge-Typ-Auswahl an (nur wenn Profil edging.mode=inline_micro)."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.inlineMicroEnabled)
.onChange(async (value) => {
this.plugin.settings.inlineMicroEnabled = value;
await this.plugin.saveSettings();
})
);
// Inline max alternatives
new Setting(containerEl)
.setName("Inline max alternatives")
.setDesc(
"Maximale Anzahl von alternativen Edge-Typen, die im Inline-Micro-Modal angezeigt werden (Standard: 6)."
)
.addText((text) =>
text
.setPlaceholder("6")
.setValue(String(this.plugin.settings.inlineMaxAlternatives))
.onChange(async (value) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.inlineMaxAlternatives = numValue;
await this.plugin.saveSettings();
}
})
);
// ============================================
// 8. Debug & Development
// ============================================

View File

@ -73,6 +73,41 @@ export function applyWikiLink(
return { newText, newSelectionStart, newSelectionEnd };
}
/**
* Apply rel:type link formatting (for inline edge type assignment).
* Format: [[rel:edgeType|link]]
* If no selection, inserts [[rel:type|text]] with cursor between brackets.
*/
export function applyRelLink(
text: string,
selectionStart: number,
selectionEnd: number,
edgeType: string
): ToolbarResult {
const selectedText = text.substring(selectionStart, selectionEnd);
let newText: string;
let newSelectionStart: number;
let newSelectionEnd: number;
if (selectedText) {
// Wrap selected text with [[rel:type|...]]
const relLink = `[[rel:${edgeType}|${selectedText}]]`;
newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd);
newSelectionStart = selectionStart + relLink.length;
newSelectionEnd = newSelectionStart;
} else {
// Insert [[rel:type|text]] with cursor between brackets
const relLink = `[[rel:${edgeType}|text]]`;
newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd);
// Select "text" part for easy editing
newSelectionStart = selectionStart + relLink.indexOf("|") + 1;
newSelectionEnd = selectionStart + 4; // Select "text"
}
return { newText, newSelectionStart, newSelectionEnd };
}
/**
* Apply heading prefix to current line(s).
* Toggles heading if already present.