Enhance inline micro edge handling and settings
- Introduced new settings for enabling inline micro edge suggestions and configuring the maximum number of alternatives displayed. - Updated the InterviewWizardModal to support inline micro edging, allowing users to select edge types immediately after inserting links. - Enhanced the semantic mapping builder to incorporate pending edge assignments, improving the handling of rel:type links. - Improved the user experience with clearer logging and error handling during inline edge type selection and mapping processes.
This commit is contained in:
parent
0fa2197ab2
commit
7ea36fbed4
|
|
@ -42,6 +42,7 @@ export function insertWikilink(
|
||||||
/**
|
/**
|
||||||
* Insert wikilink into a textarea element.
|
* Insert wikilink into a textarea element.
|
||||||
* Updates the textarea value and cursor position.
|
* 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(
|
export function insertWikilinkIntoTextarea(
|
||||||
textarea: HTMLTextAreaElement,
|
textarea: HTMLTextAreaElement,
|
||||||
|
|
@ -51,11 +52,37 @@ export function insertWikilinkIntoTextarea(
|
||||||
const selEnd = textarea.selectionEnd;
|
const selEnd = textarea.selectionEnd;
|
||||||
const currentText = textarea.value;
|
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;
|
// Insert the full wikilink
|
||||||
textarea.setSelectionRange(result.cursorPos, result.cursorPos);
|
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 }));
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ function parseProfile(
|
||||||
const edgingRaw = raw.edging as Record<string, unknown>;
|
const edgingRaw = raw.edging as Record<string, unknown>;
|
||||||
profile.edging = {};
|
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;
|
profile.edging.mode = edgingRaw.mode;
|
||||||
}
|
}
|
||||||
if (typeof edgingRaw.wrapperCalloutType === "string") {
|
if (typeof edgingRaw.wrapperCalloutType === "string") {
|
||||||
|
|
|
||||||
99
src/interview/sectionKeyResolver.ts
Normal file
99
src/interview/sectionKeyResolver.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ export interface InterviewProfile {
|
||||||
defaults?: Record<string, unknown>;
|
defaults?: Record<string, unknown>;
|
||||||
steps: InterviewStep[];
|
steps: InterviewStep[];
|
||||||
edging?: {
|
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
|
wrapperCalloutType?: string; // Override wrapper callout type
|
||||||
wrapperTitle?: string; // Override wrapper title
|
wrapperTitle?: string; // Override wrapper title
|
||||||
wrapperFolded?: boolean; // Override wrapper folded state
|
wrapperFolded?: boolean; // Override wrapper folded state
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import type { InterviewProfile, InterviewStep } from "./types";
|
import type { InterviewProfile, InterviewStep } from "./types";
|
||||||
import type { LoopRuntimeState } from "./loopState";
|
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 {
|
export interface WizardState {
|
||||||
profile: InterviewProfile;
|
profile: InterviewProfile;
|
||||||
currentStepIndex: number;
|
currentStepIndex: number;
|
||||||
|
|
@ -10,6 +20,7 @@ export interface WizardState {
|
||||||
loopRuntimeStates: Map<string, LoopRuntimeState>; // loop step key -> runtime state
|
loopRuntimeStates: Map<string, LoopRuntimeState>; // loop step key -> runtime state
|
||||||
patches: Patch[]; // Collected patches to apply
|
patches: Patch[]; // Collected patches to apply
|
||||||
activeLoopPath: string[]; // Stack of loop keys representing current nesting level (e.g. ["items", "item_list"])
|
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 {
|
export interface Patch {
|
||||||
|
|
@ -38,6 +49,7 @@ export function createWizardState(profile: InterviewProfile): WizardState {
|
||||||
loopRuntimeStates: new Map(),
|
loopRuntimeStates: new Map(),
|
||||||
patches: [],
|
patches: [],
|
||||||
activeLoopPath: [], // Start at top level
|
activeLoopPath: [], // Start at top level
|
||||||
|
pendingEdgeAssignments: [], // Start with empty pending assignments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { VocabularyLoader } from "../vocab/VocabularyLoader";
|
||||||
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
|
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
|
||||||
import type { EdgeVocabulary } from "../vocab/types";
|
import type { EdgeVocabulary } from "../vocab/types";
|
||||||
import { parseGraphSchema, type GraphSchema } from "./graphSchema";
|
import { parseGraphSchema, type GraphSchema } from "./graphSchema";
|
||||||
|
import { convertRelLinksToEdges } from "../parser/parseRelLinks";
|
||||||
|
|
||||||
export interface BuildResult {
|
export interface BuildResult {
|
||||||
sectionsProcessed: number;
|
sectionsProcessed: number;
|
||||||
|
|
@ -32,6 +33,10 @@ export interface BuildResult {
|
||||||
unmappedLinksSkipped: number;
|
unmappedLinksSkipped: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BuildOptions {
|
||||||
|
pendingAssignments?: import("../interview/wizardState").PendingEdgeAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build semantic mapping blocks for all sections in a note.
|
* Build semantic mapping blocks for all sections in a note.
|
||||||
*/
|
*/
|
||||||
|
|
@ -40,11 +45,18 @@ export async function buildSemanticMappings(
|
||||||
file: TFile,
|
file: TFile,
|
||||||
settings: MindnetSettings,
|
settings: MindnetSettings,
|
||||||
allowOverwrite: boolean,
|
allowOverwrite: boolean,
|
||||||
plugin?: { ensureGraphSchemaLoaded?: () => Promise<GraphSchema | null> }
|
plugin?: { ensureGraphSchemaLoaded?: () => Promise<GraphSchema | null> },
|
||||||
|
options?: BuildOptions
|
||||||
): Promise<BuildResult> {
|
): Promise<BuildResult> {
|
||||||
const content = await app.vault.read(file);
|
let content = await app.vault.read(file);
|
||||||
const lines = content.split(/\r?\n/);
|
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
|
// Load vocabulary and schema if prompt mode
|
||||||
let vocabulary: EdgeVocabulary | null = null;
|
let vocabulary: EdgeVocabulary | null = null;
|
||||||
let graphSchema: GraphSchema | null = null;
|
let graphSchema: GraphSchema | null = null;
|
||||||
|
|
@ -86,6 +98,14 @@ export async function buildSemanticMappings(
|
||||||
// Split into sections
|
// Split into sections
|
||||||
const sections = splitIntoSections(content);
|
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 = {
|
const result: BuildResult = {
|
||||||
sectionsProcessed: 0,
|
sectionsProcessed: 0,
|
||||||
sectionsWithMappings: 0,
|
sectionsWithMappings: 0,
|
||||||
|
|
@ -119,6 +139,37 @@ export async function buildSemanticMappings(
|
||||||
settings.mappingWrapperTitle
|
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
|
// Remove wrapper block if exists
|
||||||
let sectionContentWithoutWrapper = section.content;
|
let sectionContentWithoutWrapper = section.content;
|
||||||
if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) {
|
if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) {
|
||||||
|
|
@ -134,14 +185,39 @@ export async function buildSemanticMappings(
|
||||||
|
|
||||||
if (settings.unassignedHandling === "prompt" && vocabulary) {
|
if (settings.unassignedHandling === "prompt" && vocabulary) {
|
||||||
// Prompt mode: interactive assignment
|
// Prompt mode: interactive assignment
|
||||||
|
// Pass the already-merged mappingState (with pending assignments) to worklist builder
|
||||||
const worklist = await buildSectionWorklist(
|
const worklist = await buildSectionWorklist(
|
||||||
app,
|
app,
|
||||||
section.content,
|
section.content,
|
||||||
section.heading,
|
section.heading,
|
||||||
settings.mappingWrapperCalloutType,
|
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
|
// Process each link in worklist
|
||||||
for (const item of worklist.items) {
|
for (const item of worklist.items) {
|
||||||
// Always prompt user (even for existing mappings)
|
// Always prompt user (even for existing mappings)
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,21 @@ export interface SectionWorklist {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build worklist for a section.
|
* Build worklist for a section.
|
||||||
|
* @param mappingState Optional pre-extracted mapping state (if provided, won't re-extract)
|
||||||
*/
|
*/
|
||||||
export async function buildSectionWorklist(
|
export async function buildSectionWorklist(
|
||||||
app: App,
|
app: App,
|
||||||
sectionContent: string,
|
sectionContent: string,
|
||||||
sectionHeading: string | null,
|
sectionHeading: string | null,
|
||||||
wrapperCalloutType: string,
|
wrapperCalloutType: string,
|
||||||
wrapperTitle: string
|
wrapperTitle: string,
|
||||||
|
mappingState?: import("./mappingExtractor").SectionMappingState
|
||||||
): Promise<SectionWorklist> {
|
): Promise<SectionWorklist> {
|
||||||
// Extract all wikilinks (deduplicated)
|
// Extract all wikilinks (deduplicated)
|
||||||
const links = extractWikilinks(sectionContent);
|
const links = extractWikilinks(sectionContent);
|
||||||
|
|
||||||
// Extract existing mappings
|
// Extract existing mappings (only if not provided)
|
||||||
const mappingState = extractExistingMappings(
|
const finalMappingState = mappingState || extractExistingMappings(
|
||||||
sectionContent,
|
sectionContent,
|
||||||
wrapperCalloutType,
|
wrapperCalloutType,
|
||||||
wrapperTitle
|
wrapperTitle
|
||||||
|
|
@ -57,8 +59,14 @@ export async function buildSectionWorklist(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current mapping
|
// Get current mapping - try both exact match and normalized match
|
||||||
const currentType = mappingState.existingMappings.get(link) || null;
|
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({
|
items.push({
|
||||||
link,
|
link,
|
||||||
|
|
|
||||||
76
src/parser/parseRelLinks.ts
Normal file
76
src/parser/parseRelLinks.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,10 @@ export interface MindnetSettings {
|
||||||
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
|
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
|
||||||
allowOverwriteExistingMappings: boolean; // default: false
|
allowOverwriteExistingMappings: boolean; // default: false
|
||||||
defaultNotesFolder: string; // default: "" (vault root)
|
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 = {
|
export const DEFAULT_SETTINGS: MindnetSettings = {
|
||||||
|
|
@ -55,6 +59,9 @@ export interface MindnetSettings {
|
||||||
unassignedHandling: "prompt",
|
unassignedHandling: "prompt",
|
||||||
allowOverwriteExistingMappings: false,
|
allowOverwriteExistingMappings: false,
|
||||||
defaultNotesFolder: "",
|
defaultNotesFolder: "",
|
||||||
|
inlineMicroEnabled: true,
|
||||||
|
inlineMaxAlternatives: 6,
|
||||||
|
inlineCancelBehavior: "keep_link",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
124
src/tests/interview/sectionKeyResolver.test.ts
Normal file
124
src/tests/interview/sectionKeyResolver.test.ts
Normal 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\.\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
src/tests/mapping/pendingAssignmentMerge.test.ts
Normal file
103
src/tests/mapping/pendingAssignmentMerge.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
341
src/ui/InlineEdgeTypeModal.ts
Normal file
341
src/ui/InlineEdgeTypeModal.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,12 @@ import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"
|
||||||
import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink";
|
import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink";
|
||||||
import { buildSemanticMappings, type BuildResult } from "../mapping/semanticMappingBuilder";
|
import { buildSemanticMappings, type BuildResult } from "../mapping/semanticMappingBuilder";
|
||||||
import type { MindnetSettings } from "../settings";
|
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 {
|
import {
|
||||||
type LoopRuntimeState,
|
type LoopRuntimeState,
|
||||||
createLoopState,
|
createLoopState,
|
||||||
|
|
@ -69,6 +75,8 @@ export class InterviewWizardModal extends Modal {
|
||||||
private previewMode: Map<string, boolean> = new Map();
|
private previewMode: Map<string, boolean> = new Map();
|
||||||
// Note index for entity picker (shared instance)
|
// Note index for entity picker (shared instance)
|
||||||
private noteIndex: NoteIndex | null = null;
|
private noteIndex: NoteIndex | null = null;
|
||||||
|
// Vocabulary and schema for inline micro suggester
|
||||||
|
private vocabulary: EdgeVocabulary | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
|
|
@ -469,8 +477,33 @@ export class InterviewWizardModal extends Modal {
|
||||||
new EntityPickerModal(
|
new EntityPickerModal(
|
||||||
app,
|
app,
|
||||||
this.noteIndex,
|
this.noteIndex,
|
||||||
(result: EntityPickerResult) => {
|
async (result: EntityPickerResult) => {
|
||||||
insertWikilinkIntoTextarea(textarea, result.basename);
|
// 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();
|
).open();
|
||||||
}
|
}
|
||||||
|
|
@ -1391,8 +1424,30 @@ export class InterviewWizardModal extends Modal {
|
||||||
new EntityPickerModal(
|
new EntityPickerModal(
|
||||||
app,
|
app,
|
||||||
this.noteIndex,
|
this.noteIndex,
|
||||||
(result: EntityPickerResult) => {
|
async (result: EntityPickerResult) => {
|
||||||
insertWikilinkIntoTextarea(textarea, result.basename);
|
// 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();
|
).open();
|
||||||
}
|
}
|
||||||
|
|
@ -2278,17 +2333,22 @@ export class InterviewWizardModal extends Modal {
|
||||||
}
|
}
|
||||||
this.applyPatches();
|
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:", {
|
console.log("[Mindnet] Checking edging mode:", {
|
||||||
profileKey: this.profile.key,
|
profileKey: this.profile.key,
|
||||||
edgingMode: this.profile.edging?.mode,
|
edgingMode: edgingMode,
|
||||||
hasEdging: !!this.profile.edging,
|
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");
|
console.log("[Mindnet] Starting post-run edging");
|
||||||
await this.runPostRunEdging();
|
await this.runPostRunEdging();
|
||||||
} else {
|
} 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 });
|
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).
|
* Run semantic mapping builder after interview finish (post_run mode).
|
||||||
*/
|
*/
|
||||||
|
|
@ -2348,13 +2516,16 @@ export class InterviewWizardModal extends Modal {
|
||||||
allowOverwriteExistingMappings: false,
|
allowOverwriteExistingMappings: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run semantic mapping builder
|
// Run semantic mapping builder with pending assignments
|
||||||
const result: BuildResult = await buildSemanticMappings(
|
const result: BuildResult = await buildSemanticMappings(
|
||||||
this.app,
|
this.app,
|
||||||
this.file,
|
this.file,
|
||||||
edgingSettings,
|
edgingSettings,
|
||||||
false, // allowOverwrite: false (respect existing)
|
false, // allowOverwrite: false (respect existing)
|
||||||
this.plugin
|
this.plugin,
|
||||||
|
{
|
||||||
|
pendingAssignments: this.state.pendingEdgeAssignments,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show summary notice
|
// Show summary notice
|
||||||
|
|
@ -2536,11 +2707,42 @@ export class InterviewWizardModal extends Modal {
|
||||||
new EntityPickerModal(
|
new EntityPickerModal(
|
||||||
this.app,
|
this.app,
|
||||||
this.noteIndex,
|
this.noteIndex,
|
||||||
(result: EntityPickerResult) => {
|
async (result: EntityPickerResult) => {
|
||||||
// Store basename in collected data
|
// Check if inline micro edging is enabled
|
||||||
this.state.collectedData.set(step.key, result.basename);
|
// 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
|
// Optionally store path for future use
|
||||||
this.state.collectedData.set(`${step.key}_path`, result.path);
|
this.state.collectedData.set(`${step.key}_path`, result.path);
|
||||||
|
|
||||||
this.renderStep();
|
this.renderStep();
|
||||||
}
|
}
|
||||||
).open();
|
).open();
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 8. Debug & Development
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,41 @@ export function applyWikiLink(
|
||||||
return { newText, newSelectionStart, newSelectionEnd };
|
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).
|
* Apply heading prefix to current line(s).
|
||||||
* Toggles heading if already present.
|
* Toggles heading if already present.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user