Add semantic mapping builder functionality 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 a new command to build semantic mapping blocks by section, including user prompts for overwriting existing mappings.
- Enhanced settings interface with options for mapping wrapper type, title, folded state, default edge type, unassigned handling, and overwrite permissions.
- Implemented virtualization in the EntityPickerModal for improved performance when displaying large sets of entries, including scroll handling and item rendering optimizations.
- Updated UI components to reflect new settings and functionalities, ensuring a cohesive user experience.
This commit is contained in:
Lars 2026-01-17 07:27:11 +01:00
parent 78e8216ab9
commit 58b6ffffed
18 changed files with 2148 additions and 7 deletions

View File

@ -18,6 +18,8 @@ import { slugify } from "./interview/slugify";
import { writeFrontmatter } from "./interview/writeFrontmatter"; import { writeFrontmatter } from "./interview/writeFrontmatter";
import { InterviewWizardModal, type WizardResult } from "./ui/InterviewWizardModal"; import { InterviewWizardModal, type WizardResult } from "./ui/InterviewWizardModal";
import { extractTargetFromAnchor } from "./interview/extractTargetFromAnchor"; import { extractTargetFromAnchor } from "./interview/extractTargetFromAnchor";
import { buildSemanticMappings } from "./mapping/semanticMappingBuilder";
import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal";
export default class MindnetCausalAssistantPlugin extends Plugin { export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings; settings: MindnetSettings;
@ -352,6 +354,57 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
} }
}, },
}); });
this.addCommand({
id: "mindnet-build-semantic-mappings",
name: "Mindnet: Build semantic mapping blocks (by section)",
callback: async () => {
try {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice("No active file");
return;
}
if (activeFile.extension !== "md") {
new Notice("Active file is not a markdown file");
return;
}
// Check if overwrite is needed
let allowOverwrite = false;
if (this.settings.allowOverwriteExistingMappings) {
const modal = new ConfirmOverwriteModal(this.app);
const result = await modal.show();
allowOverwrite = result.confirmed;
}
// Build mappings
const result = await buildSemanticMappings(
this.app,
activeFile,
this.settings,
allowOverwrite
);
// Show summary
const summary = [
`Sections: ${result.sectionsProcessed} processed, ${result.sectionsWithMappings} with mappings`,
`Links: ${result.totalLinks} total`,
`Mappings: ${result.existingMappingsKept} kept, ${result.newMappingsAssigned} new`,
];
if (result.unmappedLinksSkipped > 0) {
summary.push(`${result.unmappedLinksSkipped} unmapped skipped`);
}
new Notice(summary.join(" | "));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to build semantic mappings: ${msg}`);
console.error(e);
}
},
});
} }
private async createNoteFromProfileAndOpen( private async createNoteFromProfileAndOpen(

158
src/mapping/graphSchema.ts Normal file
View File

@ -0,0 +1,158 @@
/**
* Graph Schema loader and hint provider.
*/
export interface EdgeTypeHints {
typical: string[]; // Recommended edge types
prohibited: string[]; // Prohibited edge types
}
export interface GraphSchema {
// schema[sourceType][targetType] = hints
schema: Map<string, Map<string, EdgeTypeHints>>;
}
/**
* Parse graph_schema.md markdown content.
*/
export function parseGraphSchema(markdown: string): GraphSchema {
const schema = new Map<string, Map<string, EdgeTypeHints>>();
const lines = markdown.split(/\r?\n/);
let currentSource: string | null = null;
let inTable = false;
let headerSkipped = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
// Detect "## Source: `type`" pattern
const sourceMatch = line.match(/^##\s+Source:\s+`([^`]+)`/);
if (sourceMatch && sourceMatch[1]) {
currentSource = sourceMatch[1].trim();
inTable = false;
headerSkipped = false;
continue;
}
// Skip if no current source
if (!currentSource) continue;
// Detect table header separator (e.g., "| :--- | :--- | :--- |")
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
inTable = true;
headerSkipped = true; // Next non-separator line is header, skip it
continue;
}
// Process table rows
if (inTable && line.trim().startsWith("|")) {
if (headerSkipped) {
// Skip header row
headerSkipped = false;
continue;
}
// Parse table row: | targetType | typical | prohibited |
const cells = line.split("|").map((c) => c.trim()).filter((c) => c);
if (cells.length >= 2) {
const targetType = cells[0]?.replace(/`/g, "").trim() || "";
const typicalStr = cells[1] || "";
const prohibitedStr = cells[2] || "";
if (targetType) {
// Parse typical (comma-separated, may contain backticks)
const typical = parseEdgeTypeList(typicalStr);
// Parse prohibited (comma-separated, may contain backticks)
const prohibited = parseEdgeTypeList(prohibitedStr);
// Initialize source map if needed
if (!schema.has(currentSource)) {
schema.set(currentSource, new Map());
}
const sourceMap = schema.get(currentSource)!;
sourceMap.set(targetType, { typical, prohibited });
}
}
}
// Reset table state on section end (next ## Source or end of file)
if (line.startsWith("##") && !sourceMatch) {
inTable = false;
headerSkipped = false;
}
}
return { schema };
}
/**
* Parse comma-separated edge type list (may contain backticks).
*/
function parseEdgeTypeList(str: string): string[] {
if (!str || str.trim() === "-" || str.trim() === "") {
return [];
}
// Split by comma, remove backticks, trim
return str
.split(",")
.map((s) => s.replace(/`/g, "").trim())
.filter((s) => s.length > 0);
}
/**
* Get edge type hints for source/target combination with fallback order:
* exact -> target "any" -> source "any" -> both "any"
*/
export function getHints(
graphSchema: GraphSchema,
sourceType: string | null,
targetType: string | null
): EdgeTypeHints {
const empty: EdgeTypeHints = { typical: [], prohibited: [] };
if (!sourceType || !targetType) {
// Try "any" fallbacks
if (sourceType) {
return getHintsForSourceTarget(graphSchema, sourceType, "any") || empty;
}
if (targetType) {
return getHintsForSourceTarget(graphSchema, "any", targetType) || empty;
}
return getHintsForSourceTarget(graphSchema, "any", "any") || empty;
}
// Try exact match first
const exact = getHintsForSourceTarget(graphSchema, sourceType, targetType);
if (exact) return exact;
// Fallback: target "any"
const targetAny = getHintsForSourceTarget(graphSchema, sourceType, "any");
if (targetAny) return targetAny;
// Fallback: source "any"
const sourceAny = getHintsForSourceTarget(graphSchema, "any", targetType);
if (sourceAny) return sourceAny;
// Fallback: both "any"
return getHintsForSourceTarget(graphSchema, "any", "any") || empty;
}
/**
* Get hints for specific source/target combination.
*/
function getHintsForSourceTarget(
graphSchema: GraphSchema,
sourceType: string,
targetType: string
): EdgeTypeHints | null {
const sourceMap = graphSchema.schema.get(sourceType);
if (!sourceMap) return null;
const hints = sourceMap.get(targetType);
return hints || null;
}

View File

@ -0,0 +1,158 @@
/**
* Build semantic mapping blocks with edge groups.
*/
import type { LinkMapping } from "./mappingExtractor";
export interface MappingBuilderOptions {
wrapperCalloutType: string;
wrapperTitle: string;
wrapperFolded: boolean;
defaultEdgeType: string;
assignUnmapped: "none" | "defaultType" | "advisor" | "prompt";
}
/**
* Build mapping block for a section.
*/
export function buildMappingBlock(
links: string[],
existingMappings: Map<string, string>,
options: MappingBuilderOptions
): string | null {
if (links.length === 0) {
return null; // No links, no mapping block
}
// Group links by edgeType
const groups = new Map<string, string[]>();
for (const link of links) {
const edgeType = existingMappings.get(link);
if (edgeType) {
// Use existing mapping
if (!groups.has(edgeType)) {
groups.set(edgeType, []);
}
groups.get(edgeType)!.push(link);
} else {
// New link without mapping
if (options.assignUnmapped === "defaultType" && options.defaultEdgeType) {
const type = options.defaultEdgeType;
if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type)!.push(link);
} else if (options.assignUnmapped === "advisor") {
// TODO: Call suggestEdgeTypes (E1 engine stub)
// For now, use defaultType if available, otherwise skip
if (options.defaultEdgeType) {
const type = options.defaultEdgeType;
if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type)!.push(link);
}
// Otherwise skip (assignUnmapped="none" behavior)
}
// If assignUnmapped="none", skip unmapped links
}
}
if (groups.size === 0) {
return null; // No mapped links
}
// Build wrapper callout
const foldMarker = options.wrapperFolded ? "-" : "+";
const wrapperHeader = `> [!${options.wrapperCalloutType}]${foldMarker} ${options.wrapperTitle}`;
const blockLines: string[] = [wrapperHeader];
// Sort groups deterministically: existing types first (by insertion order), then alphabetically
const groupEntries = Array.from(groups.entries());
// Separate existing types from new types
const existingTypes = new Set(existingMappings.values());
const existingGroups: [string, string[]][] = [];
const newGroups: [string, string[]][] = [];
for (const [edgeType, links] of groupEntries) {
if (existingTypes.has(edgeType)) {
existingGroups.push([edgeType, links]);
} else {
newGroups.push([edgeType, links]);
}
}
// Sort existing groups by original order (first occurrence in existingMappings)
existingGroups.sort((a, b) => {
// Get first link with this type from existingMappings
let aOrder = Infinity;
let bOrder = Infinity;
let index = 0;
const aType = a?.[0] || "";
const bType = b?.[0] || "";
for (const [link, type] of existingMappings.entries()) {
if (type === aType && aOrder === Infinity) aOrder = index;
if (type === bType && bOrder === Infinity) bOrder = index;
index++;
}
return aOrder - bOrder;
});
// Sort new groups alphabetically
newGroups.sort((a, b) => a[0].localeCompare(b[0]));
// Combine: existing first, then new
const sortedGroups = [...existingGroups, ...newGroups];
// Build edge groups
for (let i = 0; i < sortedGroups.length; i++) {
const group = sortedGroups[i];
if (!group) continue;
const [edgeType, groupLinks] = group;
if (!edgeType || !groupLinks) continue;
// Add blank quoted line separator between groups (except before first)
if (i > 0) {
blockLines.push(">");
}
// Edge header
blockLines.push(`>> [!edge] ${edgeType}`);
// Links (sorted alphabetically for determinism)
const sortedLinks = [...groupLinks].sort();
for (const link of sortedLinks) {
blockLines.push(`>> [[${link}]]`);
}
}
// Add block-id marker for future detection
blockLines.push(`> ^map-${Date.now()}`);
return blockLines.join("\n");
}
/**
* Insert mapping block at end of section content.
*/
export function insertMappingBlock(
sectionContent: string,
mappingBlock: string
): string {
// Remove trailing whitespace
const trimmed = sectionContent.trimEnd();
// Ensure two newlines before mapping block
if (trimmed.endsWith("\n\n")) {
return trimmed + mappingBlock;
} else if (trimmed.endsWith("\n")) {
return trimmed + "\n" + mappingBlock;
} else {
return trimmed + "\n\n" + mappingBlock;
}
}

View File

@ -0,0 +1,159 @@
/**
* Extract existing edge mappings from a section and detect/remove wrapper blocks.
*/
import { parseEdgesFromCallouts } from "../parser/parseEdgesFromCallouts";
export interface LinkMapping {
link: string;
edgeType: string;
}
export interface SectionMappingState {
existingMappings: Map<string, string>; // link -> edgeType
wrapperBlockStartLine: number | null; // Line where wrapper block starts
wrapperBlockEndLine: number | null; // Line where wrapper block ends (exclusive)
}
/**
* Extract existing mappings from a section.
* Also detects the wrapper block location.
*/
export function extractExistingMappings(
sectionContent: string,
wrapperCalloutType: string,
wrapperTitle: string
): SectionMappingState {
const existingMappings = new Map<string, string>();
const lines = sectionContent.split(/\r?\n/);
// Parse edges from callouts
const edges = parseEdgesFromCallouts(sectionContent);
// Build mapping: link -> edgeType
for (const edge of edges) {
for (const target of edge.targets) {
// If link already mapped, keep first occurrence (or could merge, but simpler: first wins)
if (!existingMappings.has(target)) {
existingMappings.set(target, edge.rawType);
}
}
}
// Detect wrapper block
const wrapperBlock = detectWrapperBlock(lines, wrapperCalloutType, wrapperTitle);
return {
existingMappings,
wrapperBlockStartLine: wrapperBlock?.startLine ?? null,
wrapperBlockEndLine: wrapperBlock?.endLine ?? null,
};
}
interface WrapperBlockLocation {
startLine: number;
endLine: number;
}
/**
* Detect wrapper callout block in lines.
* Looks for: > [!<calloutType>] <title> or > [!<calloutType>]- <title>
* Also checks for block-id marker like ^map-...
*/
function detectWrapperBlock(
lines: string[],
calloutType: string,
title: string
): WrapperBlockLocation | null {
const calloutTypeLower = calloutType.toLowerCase();
const titleLower = title.toLowerCase();
// Pattern 1: > [!<calloutType>] <title> or > [!<calloutType>]- <title> or > [!<calloutType>]+ <title>
const calloutHeaderRegex = new RegExp(
`^\\s*>\\s*\\[!${calloutTypeLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\s*[+-]?\\s*(.+)$`,
"i"
);
let wrapperStart: number | null = null;
let wrapperEnd: number | null = null;
let inWrapper = false;
let quoteLevel = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
const trimmed = line.trim();
const match = line.match(calloutHeaderRegex);
if (match && match[1]) {
const headerTitle = match[1].trim().toLowerCase();
// Check if title matches (case-insensitive, partial match for flexibility)
if (headerTitle.includes(titleLower) || titleLower.includes(headerTitle)) {
wrapperStart = i;
inWrapper = true;
quoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length || 0);
continue;
}
}
if (inWrapper) {
// Check for block-id marker (^map-...)
if (trimmed.match(/^\^map-/)) {
// Found end marker, wrapper ends after this line
wrapperEnd = i + 1;
break;
}
// Check if we're still in the callout (quote level)
const currentQuoteLevel = (line.match(/^\s*(>+)/)?.[1]?.length || 0);
// If quote level drops below wrapper level and line is not blank, end wrapper
if (trimmed !== "" && currentQuoteLevel < quoteLevel) {
wrapperEnd = i;
break;
}
// If we hit a heading (not in quotes), end wrapper
if (!line.startsWith(">") && trimmed.match(/^#{1,6}\s+/)) {
wrapperEnd = i;
break;
}
}
}
// If we're still in wrapper at end, close it
if (inWrapper && wrapperStart !== null && wrapperEnd === null) {
wrapperEnd = lines.length;
}
if (wrapperStart !== null && wrapperEnd !== null) {
return {
startLine: wrapperStart,
endLine: wrapperEnd,
};
}
return null;
}
/**
* Remove wrapper block from section content.
*/
export function removeWrapperBlock(
sectionContent: string,
wrapperStartLine: number,
wrapperEndLine: number
): string {
const lines = sectionContent.split(/\r?\n/);
// Remove lines in wrapper block range
const before = lines.slice(0, wrapperStartLine);
const after = lines.slice(wrapperEndLine);
// Join and clean up trailing/leading whitespace
const result = [...before, ...after].join("\n");
// Remove excessive blank lines at the end
return result.replace(/\n{3,}$/, "\n\n");
}

View File

@ -0,0 +1,77 @@
/**
* Schema helper for computing typical/prohibited edge types.
*/
import type { EdgeVocabulary } from "../vocab/types";
import type { GraphSchema } from "./graphSchema";
import { getHints } from "./graphSchema";
export interface EdgeTypeSuggestion {
typical: string[]; // Recommended edge types
prohibited: string[]; // Prohibited edge types
}
/**
* Compute edge type suggestions based on source and target types.
* Uses GraphSchema if provided, otherwise returns empty arrays.
*/
export function computeEdgeSuggestions(
vocabulary: EdgeVocabulary,
sourceType: string | null,
targetType: string | null,
graphSchema: GraphSchema | null = null
): EdgeTypeSuggestion {
if (graphSchema) {
return getHints(graphSchema, sourceType, targetType);
}
// No schema available, return empty
return {
typical: [],
prohibited: [],
};
}
/**
* Get all available edge types from vocabulary (canonical + aliases).
*/
export function getAllEdgeTypes(vocabulary: EdgeVocabulary): Array<{
canonical: string;
aliases: string[];
displayName: string; // First alias or canonical
}> {
const result: Array<{
canonical: string;
aliases: string[];
displayName: string;
}> = [];
for (const [canonical, entry] of vocabulary.byCanonical.entries()) {
const firstAlias = entry.aliases.length > 0 ? entry.aliases[0] : null;
const displayName = firstAlias || canonical;
result.push({
canonical,
aliases: entry.aliases,
displayName,
});
}
// Sort by display name
result.sort((a, b) => a.displayName.localeCompare(b.displayName));
return result;
}
/**
* Group edge types by category (if available).
* MVP: Returns single "All" category.
*/
export function groupEdgeTypesByCategory(
edgeTypes: Array<{ canonical: string; aliases: string[]; displayName: string }>
): Map<string, Array<{ canonical: string; aliases: string[]; displayName: string }>> {
// TODO: Implement category grouping from schema
// For MVP, return single "All" category
const grouped = new Map<string, Array<{ canonical: string; aliases: string[]; displayName: string }>>();
grouped.set("All", edgeTypes);
return grouped;
}

View File

@ -0,0 +1,110 @@
/**
* Section parser: Split markdown by headings and extract wikilinks per section.
*/
export interface NoteSection {
heading: string | null; // null for content before first heading
headingLevel: number; // 0 for content before first heading
content: string; // Full content of section (including heading)
startLine: number; // Line index where section starts
endLine: number; // Line index where section ends (exclusive)
links: string[]; // Deduplicated wikilinks found in this section
}
/**
* Split markdown content into sections by headings.
*/
export function splitIntoSections(markdown: string): NoteSection[] {
const lines = markdown.split(/\r?\n/);
const sections: NoteSection[] = [];
let currentSection: NoteSection | null = null;
let currentContent: string[] = [];
let currentStartLine = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
// Check if line is a heading
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
// Save previous section if exists
if (currentSection !== null || currentContent.length > 0) {
const content = currentContent.join("\n");
const links = extractWikilinks(content);
sections.push({
heading: currentSection?.heading || null,
headingLevel: currentSection?.headingLevel || 0,
content: content,
startLine: currentStartLine,
endLine: i,
links: links,
});
}
// Start new section
const headingLevel = (headingMatch[1]?.length || 0);
const headingText = (headingMatch[2]?.trim() || "");
currentSection = {
heading: headingText,
headingLevel: headingLevel,
content: line,
startLine: i,
endLine: i + 1,
links: [],
};
currentContent = [line];
currentStartLine = i;
} else {
// Add line to current section
if (currentSection) {
currentContent.push(line);
currentSection.content = currentContent.join("\n");
currentSection.endLine = i + 1;
} else {
// Content before first heading
currentContent.push(line);
}
}
}
// Save last section
if (currentSection || currentContent.length > 0) {
const content = currentContent.join("\n");
const links = extractWikilinks(content);
sections.push({
heading: currentSection?.heading || null,
headingLevel: currentSection?.headingLevel || 0,
content: content,
startLine: currentStartLine,
endLine: lines.length,
links: links,
});
}
return sections;
}
/**
* Extract all wikilinks from markdown text (deduplicated).
*/
export function extractWikilinks(markdown: string): string[] {
const links = new Set<string>();
const wikilinkRegex = /\[\[([^\]]+?)\]\]/g;
let match: RegExpExecArray | null;
while ((match = wikilinkRegex.exec(markdown)) !== null) {
if (match[1]) {
const link = match[1].trim();
if (link) {
links.add(link);
}
}
}
return Array.from(links).sort(); // Deterministic ordering
}

View File

@ -0,0 +1,270 @@
/**
* Semantic Mapping Builder: Build mapping blocks for sections.
*/
import { App, TFile, Notice } from "obsidian";
import type { MindnetSettings } from "../settings";
import { splitIntoSections, type NoteSection } from "./sectionParser";
import { parseEdgesFromCallouts } from "../parser/parseEdgesFromCallouts";
import {
extractExistingMappings,
removeWrapperBlock,
type SectionMappingState,
} from "./mappingExtractor";
import {
buildMappingBlock,
insertMappingBlock,
type MappingBuilderOptions,
} from "./mappingBuilder";
import { buildSectionWorklist, getSourceType } from "./worklistBuilder";
import { LinkPromptModal, type LinkPromptDecision } from "../ui/LinkPromptModal";
import { VocabularyLoader } from "../vocab/VocabularyLoader";
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
import type { EdgeVocabulary } from "../vocab/types";
import { parseGraphSchema, type GraphSchema } from "./graphSchema";
export interface BuildResult {
sectionsProcessed: number;
sectionsWithMappings: number;
totalLinks: number;
existingMappingsKept: number;
newMappingsAssigned: number;
unmappedLinksSkipped: number;
}
/**
* Build semantic mapping blocks for all sections in a note.
*/
export async function buildSemanticMappings(
app: App,
file: TFile,
settings: MindnetSettings,
allowOverwrite: boolean
): Promise<BuildResult> {
const content = await app.vault.read(file);
const lines = content.split(/\r?\n/);
// Load vocabulary and schema if prompt mode
let vocabulary: EdgeVocabulary | null = null;
let graphSchema: GraphSchema | null = null;
if (settings.unassignedHandling === "prompt") {
try {
const vocabText = await VocabularyLoader.loadText(app, settings.edgeVocabularyPath);
vocabulary = parseEdgeVocabulary(vocabText);
} catch (e) {
new Notice(`Failed to load edge vocabulary: ${e instanceof Error ? e.message : String(e)}`);
return {
sectionsProcessed: 0,
sectionsWithMappings: 0,
totalLinks: 0,
existingMappingsKept: 0,
newMappingsAssigned: 0,
unmappedLinksSkipped: 0,
};
}
// Load graph schema (optional, continue if not found)
try {
const schemaText = await VocabularyLoader.loadText(app, settings.graphSchemaPath);
graphSchema = parseGraphSchema(schemaText);
} catch (e) {
console.warn(`Graph schema not available: ${e instanceof Error ? e.message : String(e)}`);
// Continue without schema
}
}
// Get source type
const sourceType = getSourceType(app, file);
// Split into sections
const sections = splitIntoSections(content);
const result: BuildResult = {
sectionsProcessed: 0,
sectionsWithMappings: 0,
totalLinks: 0,
existingMappingsKept: 0,
newMappingsAssigned: 0,
unmappedLinksSkipped: 0,
};
// Process sections in reverse order (to preserve line indices when modifying)
const modifiedSections: Array<{ section: NoteSection; newContent: string }> = [];
for (const section of sections) {
result.sectionsProcessed++;
if (section.links.length === 0) {
// No links, skip
modifiedSections.push({
section,
newContent: section.content,
});
continue;
}
result.totalLinks += section.links.length;
// Extract existing mappings
const mappingState = extractExistingMappings(
section.content,
settings.mappingWrapperCalloutType,
settings.mappingWrapperTitle
);
// Remove wrapper block if exists
let sectionContentWithoutWrapper = section.content;
if (mappingState.wrapperBlockStartLine !== null && mappingState.wrapperBlockEndLine !== null) {
sectionContentWithoutWrapper = removeWrapperBlock(
section.content,
mappingState.wrapperBlockStartLine,
mappingState.wrapperBlockEndLine
);
}
// Determine final mappings based on mode
const mappingsToUse = new Map<string, string>();
if (settings.unassignedHandling === "prompt" && vocabulary) {
// Prompt mode: interactive assignment
const worklist = await buildSectionWorklist(
app,
section.content,
section.heading,
settings.mappingWrapperCalloutType,
settings.mappingWrapperTitle
);
// Process each link in worklist
for (const item of worklist.items) {
// Always prompt user (even for existing mappings)
// Keep is the default option if currentType exists
const prompt = new LinkPromptModal(app, item, vocabulary, sourceType, graphSchema);
const decision = await prompt.show();
// Apply decision
if (decision.action === "keep") {
mappingsToUse.set(item.link, decision.edgeType);
if (mappingState.existingMappings.has(item.link)) {
result.existingMappingsKept++;
}
} else if (decision.action === "change") {
// Use canonical type (alias is for display only)
mappingsToUse.set(item.link, decision.edgeType);
if (mappingState.existingMappings.has(item.link)) {
// Changed existing
} else {
result.newMappingsAssigned++;
}
} else {
// Skip
if (!mappingState.existingMappings.has(item.link)) {
result.unmappedLinksSkipped++;
}
}
}
} else {
// Silent modes: none/defaultType/advisor
// Always include existing mappings
for (const [link, edgeType] of mappingState.existingMappings.entries()) {
if (section.links.includes(link)) {
mappingsToUse.set(link, edgeType);
result.existingMappingsKept++;
}
}
// Handle unmapped based on mode
for (const link of section.links) {
if (!mappingsToUse.has(link)) {
if (settings.unassignedHandling === "defaultType" && settings.defaultEdgeType) {
mappingsToUse.set(link, settings.defaultEdgeType);
result.newMappingsAssigned++;
} else if (settings.unassignedHandling === "advisor" && settings.defaultEdgeType) {
// TODO: Call advisor (E1 engine stub)
mappingsToUse.set(link, settings.defaultEdgeType);
result.newMappingsAssigned++;
} else {
result.unmappedLinksSkipped++;
}
}
}
}
// Build new mapping block
const builderOptions: MappingBuilderOptions = {
wrapperCalloutType: settings.mappingWrapperCalloutType,
wrapperTitle: settings.mappingWrapperTitle,
wrapperFolded: settings.mappingWrapperFolded,
defaultEdgeType: settings.defaultEdgeType,
assignUnmapped: "none", // Already handled above, so set to "none" to avoid double-processing
};
// Build mapping block
const mappingBlock = buildMappingBlock(section.links, mappingsToUse, builderOptions);
if (mappingBlock) {
result.sectionsWithMappings++;
// Insert mapping block at end of section
const newContent = insertMappingBlock(sectionContentWithoutWrapper, mappingBlock);
modifiedSections.push({
section,
newContent,
});
} else {
// No mapping block needed
modifiedSections.push({
section,
newContent: sectionContentWithoutWrapper,
});
}
}
// Rebuild full content
const newContent = rebuildContentFromSections(lines, modifiedSections);
// Write back to file
await app.vault.modify(file, newContent);
return result;
}
/**
* Rebuild content from modified sections.
* Preserves original line structure where possible.
*/
function rebuildContentFromSections(
originalLines: string[],
modifiedSections: Array<{ section: NoteSection; newContent: string }>
): string {
// Sort sections by startLine (ascending)
const sorted = [...modifiedSections].sort((a, b) => a.section.startLine - b.section.startLine);
const result: string[] = [];
let lastEndLine = 0;
for (const { section, newContent } of sorted) {
// Add any content between last section and this one
if (section.startLine > lastEndLine) {
const gap = originalLines.slice(lastEndLine, section.startLine).join("\n");
if (gap) {
result.push(gap);
}
}
// Add modified section content
result.push(newContent);
lastEndLine = section.endLine;
}
// Add any remaining content after last section
if (lastEndLine < originalLines.length) {
const remaining = originalLines.slice(lastEndLine).join("\n");
if (remaining) {
result.push(remaining);
}
}
return result.join("\n");
}

View File

@ -0,0 +1,88 @@
/**
* Build worklist of links for prompt mode.
*/
import { App, TFile } from "obsidian";
import type { EdgeVocabulary } from "../vocab/types";
import { extractWikilinks } from "./sectionParser";
import { extractExistingMappings } from "./mappingExtractor";
export interface LinkWorkItem {
link: string; // Wikilink basename
targetType: string | null; // Note type from metadataCache/frontmatter
currentType: string | null; // Existing edge type mapping, if any
}
export interface SectionWorklist {
sectionHeading: string | null;
items: LinkWorkItem[];
}
/**
* Build worklist for a section.
*/
export async function buildSectionWorklist(
app: App,
sectionContent: string,
sectionHeading: string | null,
wrapperCalloutType: string,
wrapperTitle: string
): Promise<SectionWorklist> {
// Extract all wikilinks (deduplicated)
const links = extractWikilinks(sectionContent);
// Extract existing mappings
const mappingState = extractExistingMappings(
sectionContent,
wrapperCalloutType,
wrapperTitle
);
// Build worklist items
const items: LinkWorkItem[] = [];
for (const link of links) {
// Get target file
const targetFile = app.metadataCache.getFirstLinkpathDest(link, "");
// Get target type from frontmatter
let targetType: string | null = null;
if (targetFile) {
const cache = app.metadataCache.getFileCache(targetFile);
if (cache?.frontmatter) {
targetType = cache.frontmatter.type || cache.frontmatter.noteType || null;
if (targetType && typeof targetType !== "string") {
targetType = String(targetType);
}
}
}
// Get current mapping
const currentType = mappingState.existingMappings.get(link) || null;
items.push({
link,
targetType,
currentType,
});
}
return {
sectionHeading,
items,
};
}
/**
* Get source type from current file.
*/
export function getSourceType(app: App, file: TFile): string | null {
const cache = app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
const type = cache.frontmatter.type || cache.frontmatter.noteType || null;
if (type && typeof type === "string") {
return type;
}
}
return null;
}

View File

@ -8,6 +8,13 @@ export interface MindnetSettings {
interviewConfigPath: string; // vault-relativ interviewConfigPath: string; // vault-relativ
autoStartInterviewOnCreate: boolean; autoStartInterviewOnCreate: boolean;
interceptUnresolvedLinkClicks: boolean; interceptUnresolvedLinkClicks: boolean;
// Semantic mapping builder settings
mappingWrapperCalloutType: string; // default: "abstract"
mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping"
mappingWrapperFolded: boolean; // default: true
defaultEdgeType: string; // default: ""
unassignedHandling: "prompt" | "none" | "defaultType" | "advisor"; // default: "prompt"
allowOverwriteExistingMappings: boolean; // default: false
} }
export const DEFAULT_SETTINGS: MindnetSettings = { export const DEFAULT_SETTINGS: MindnetSettings = {
@ -20,6 +27,12 @@ export interface MindnetSettings {
interviewConfigPath: "_system/dictionary/interview_config.yaml", interviewConfigPath: "_system/dictionary/interview_config.yaml",
autoStartInterviewOnCreate: false, autoStartInterviewOnCreate: false,
interceptUnresolvedLinkClicks: true, interceptUnresolvedLinkClicks: true,
mappingWrapperCalloutType: "abstract",
mappingWrapperTitle: "🕸️ Semantic Mapping",
mappingWrapperFolded: true,
defaultEdgeType: "",
unassignedHandling: "prompt",
allowOverwriteExistingMappings: false,
}; };
/** /**

View File

@ -0,0 +1,97 @@
/**
* Unit tests for GraphSchema loader.
*/
import { describe, it, expect } from "vitest";
import { parseGraphSchema, getHints } from "../../mapping/graphSchema";
const fixtureSchema = `---
id: graph_schema
title: Graph Topology & Preferences (Atomic)
---
## Source: \`person\`
| Target-Note-type | Typical Edge-Types | Prohibited Edge-Types |
| :--- | :--- | :--- |
| \`skill\` | \`experienced_in\`, \`expert_for\` | \`caused_by\`, \`solves\` |
| \`any\` | \`related_to\` | - |
## Source: \`skill\`
| Target-Note-type | Typical Edge-Types | Prohibited Edge-Types |
| :--- | :--- | :--- |
| \`person\` | \`mastered_by\` | \`consists_of\` |
| \`any\` | \`references\`, \`related_to\` | - |
## Source: \`any\`
| Target-Note-type | Typical Edge-Types | Prohibited Edge-Types |
| :--- | :--- | :--- |
| \`any\` | \`related_to\`, \`references\` | - |
`;
describe("parseGraphSchema", () => {
it("should parse schema with multiple sources", () => {
const schema = parseGraphSchema(fixtureSchema);
expect(schema.schema.has("person")).toBe(true);
expect(schema.schema.has("skill")).toBe(true);
expect(schema.schema.has("any")).toBe(true);
});
it("should parse typical and prohibited edge types", () => {
const schema = parseGraphSchema(fixtureSchema);
const personMap = schema.schema.get("person");
expect(personMap).toBeDefined();
const skillHints = personMap?.get("skill");
expect(skillHints?.typical).toContain("experienced_in");
expect(skillHints?.typical).toContain("expert_for");
expect(skillHints?.prohibited).toContain("caused_by");
expect(skillHints?.prohibited).toContain("solves");
});
it("should handle any fallback", () => {
const schema = parseGraphSchema(fixtureSchema);
const personMap = schema.schema.get("person");
const anyHints = personMap?.get("any");
expect(anyHints?.typical).toContain("related_to");
expect(anyHints?.prohibited).toHaveLength(0);
});
});
describe("getHints", () => {
it("should return exact match", () => {
const schema = parseGraphSchema(fixtureSchema);
const hints = getHints(schema, "person", "skill");
expect(hints.typical).toContain("experienced_in");
expect(hints.prohibited).toContain("caused_by");
});
it("should fallback to target any", () => {
const schema = parseGraphSchema(fixtureSchema);
const hints = getHints(schema, "person", "unknown_type");
expect(hints.typical).toContain("related_to");
});
it("should fallback to source any", () => {
const schema = parseGraphSchema(fixtureSchema);
const hints = getHints(schema, "unknown_source", "person");
// Should fallback to "any" -> "any"
expect(hints.typical).toContain("related_to");
});
it("should fallback to both any", () => {
const schema = parseGraphSchema(fixtureSchema);
const hints = getHints(schema, null, null);
expect(hints.typical).toContain("related_to");
});
});

View File

@ -0,0 +1,126 @@
/**
* Unit tests for mapping builder.
*/
import { describe, it, expect } from "vitest";
import { buildMappingBlock, insertMappingBlock } from "../../mapping/mappingBuilder";
import type { MappingBuilderOptions } from "../../mapping/mappingBuilder";
const defaultOptions: MappingBuilderOptions = {
wrapperCalloutType: "abstract",
wrapperTitle: "🕸️ Semantic Mapping",
wrapperFolded: true,
defaultEdgeType: "",
assignUnmapped: "none",
};
describe("buildMappingBlock", () => {
it("should return null if no links", () => {
const result = buildMappingBlock([], new Map(), defaultOptions);
expect(result).toBeNull();
});
it("should group links by edgeType", () => {
const links = ["Link1", "Link2", "Link3"];
const mappings = new Map<string, string>([
["Link1", "causes"],
["Link2", "causes"],
["Link3", "enables"],
]);
const result = buildMappingBlock(links, mappings, defaultOptions);
expect(result).not.toBeNull();
expect(result).toContain(">> [!edge] causes");
expect(result).toContain(">> [!edge] enables");
expect(result).toContain(">> [[Link1]]");
expect(result).toContain(">> [[Link2]]");
expect(result).toContain(">> [[Link3]]");
});
it("should separate groups with blank quoted line", () => {
const links = ["Link1", "Link2"];
const mappings = new Map<string, string>([
["Link1", "causes"],
["Link2", "enables"],
]);
const result = buildMappingBlock(links, mappings, defaultOptions);
expect(result).not.toBeNull();
// Check for separator: ">" between groups
const lines = result!.split("\n");
const causesIndex = lines.findIndex((l) => l.includes("[!edge] causes"));
const separatorIndex = causesIndex + 1;
const enablesIndex = lines.findIndex((l) => l.includes("[!edge] enables"));
expect(separatorIndex).toBeLessThan(enablesIndex);
expect(lines[separatorIndex]).toBe(">");
});
it("should skip unmapped links when assignUnmapped is 'none'", () => {
const links = ["Link1", "Link2"];
const mappings = new Map<string, string>([
["Link1", "causes"],
// Link2 has no mapping
]);
const result = buildMappingBlock(links, mappings, defaultOptions);
expect(result).toContain("Link1");
expect(result).not.toContain("Link2");
});
it("should assign defaultType when assignUnmapped is 'defaultType'", () => {
const links = ["Link1", "Link2"];
const mappings = new Map<string, string>([
["Link1", "causes"],
// Link2 has no mapping
]);
const options: MappingBuilderOptions = {
...defaultOptions,
assignUnmapped: "defaultType",
defaultEdgeType: "enables",
};
const result = buildMappingBlock(links, mappings, options);
expect(result).toContain("Link1");
expect(result).toContain("Link2");
expect(result).toContain(">> [!edge] enables");
expect(result).toContain(">> [[Link2]]");
});
});
describe("insertMappingBlock", () => {
it("should insert block with two newlines", () => {
const sectionContent = `# Section
Content here.`;
const mappingBlock = `> [!abstract] 🕸️ Semantic Mapping
>> [!edge] causes
>> [[Link1]]`;
const result = insertMappingBlock(sectionContent, mappingBlock);
expect(result).toContain("Content here.");
expect(result).toContain("🕸️ Semantic Mapping");
// Should have two newlines between
expect(result).toMatch(/Content here\.\n\n> \[!abstract\]/);
});
it("should handle content ending with newlines", () => {
const sectionContent = `# Section
Content here.
`;
const mappingBlock = `> [!abstract] 🕸️ Semantic Mapping`;
const result = insertMappingBlock(sectionContent, mappingBlock);
// Should not add excessive newlines
expect(result.split(/\n\n\n/)).toHaveLength(1);
});
});

View File

@ -0,0 +1,67 @@
/**
* Unit tests for mapping extractor.
*/
import { describe, it, expect } from "vitest";
import { extractExistingMappings, removeWrapperBlock } from "../../mapping/mappingExtractor";
describe("extractExistingMappings", () => {
it("should extract mappings from edge callouts", () => {
const sectionContent = `# Section
Content with [[Link1]] and [[Link2]].
> [!abstract] 🕸 Semantic Mapping
>> [!edge] causes
>> [[Link1]]
>> [!edge] enables
>> [[Link2]]
`;
const state = extractExistingMappings(sectionContent, "abstract", "🕸️ Semantic Mapping");
expect(state.existingMappings.get("Link1")).toBe("causes");
expect(state.existingMappings.get("Link2")).toBe("enables");
expect(state.wrapperBlockStartLine).not.toBeNull();
});
it("should detect wrapper block location", () => {
const sectionContent = `# Section
Content.
> [!abstract]- 🕸 Semantic Mapping
>> [!edge] causes
>> [[Link1]]
> ^map-123
`;
const state = extractExistingMappings(sectionContent, "abstract", "🕸️ Semantic Mapping");
expect(state.wrapperBlockStartLine).toBe(3);
expect(state.wrapperBlockEndLine).toBe(7); // After block-id marker
});
});
describe("removeWrapperBlock", () => {
it("should remove wrapper block from content", () => {
const content = `# Section
Content before.
> [!abstract] 🕸 Semantic Mapping
>> [!edge] causes
>> [[Link1]]
> ^map-123
Content after.
`;
const result = removeWrapperBlock(content, 3, 7);
expect(result).toContain("Content before.");
expect(result).toContain("Content after.");
expect(result).not.toContain("🕸️ Semantic Mapping");
expect(result).not.toContain("[!edge] causes");
});
});

View File

@ -0,0 +1,81 @@
/**
* Unit tests for section parser.
*/
import { describe, it, expect } from "vitest";
import { splitIntoSections, extractWikilinks } from "../../mapping/sectionParser";
describe("splitIntoSections", () => {
it("should split note with multiple headings", () => {
const markdown = `# Heading 1
Content with [[Link1]] and [[Link2]].
## Heading 2
More content with [[Link3]].
### Heading 3
Even more content.
`;
const sections = splitIntoSections(markdown);
expect(sections).toHaveLength(4);
expect(sections[0]?.heading).toBeNull();
expect(sections[0]?.headingLevel).toBe(0);
expect(sections[1]?.heading).toBe("Heading 1");
expect(sections[1]?.headingLevel).toBe(1);
expect(sections[2]?.heading).toBe("Heading 2");
expect(sections[2]?.headingLevel).toBe(2);
expect(sections[3]?.heading).toBe("Heading 3");
expect(sections[3]?.headingLevel).toBe(3);
});
it("should extract links per section", () => {
const markdown = `# Section 1
Content with [[Link1]] and [[Link2]].
## Section 2
More content with [[Link3]].
`;
const sections = splitIntoSections(markdown);
expect(sections[1]?.links).toContain("Link1");
expect(sections[1]?.links).toContain("Link2");
expect(sections[2]?.links).toContain("Link3");
expect(sections[2]?.links).not.toContain("Link1");
});
it("should handle note without headings", () => {
const markdown = `Content with [[Link1]] and [[Link2]].`;
const sections = splitIntoSections(markdown);
expect(sections).toHaveLength(1);
expect(sections[0]?.heading).toBeNull();
expect(sections[0]?.links).toContain("Link1");
expect(sections[0]?.links).toContain("Link2");
});
});
describe("extractWikilinks", () => {
it("should extract and deduplicate wikilinks", () => {
const markdown = `Content with [[Link1]], [[Link2]], and [[Link1]] again.`;
const links = extractWikilinks(markdown);
expect(links).toHaveLength(2);
expect(links).toContain("Link1");
expect(links).toContain("Link2");
});
it("should handle empty content", () => {
const links = extractWikilinks("");
expect(links).toHaveLength(0);
});
});

View File

@ -0,0 +1,70 @@
/**
* Confirmation modal for overwriting existing mappings.
*/
import { Modal } from "obsidian";
export interface ConfirmOverwriteResult {
confirmed: boolean;
}
export class ConfirmOverwriteModal extends Modal {
private result: ConfirmOverwriteResult = { confirmed: false };
private resolve: ((result: ConfirmOverwriteResult) => void) | null = null;
constructor(app: any) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("confirm-overwrite-modal");
contentEl.createEl("h2", { text: "Overwrite existing mappings?" });
const message = contentEl.createEl("p", {
text: "This will rebuild all mapping blocks and may change existing edge type assignments. Continue?",
});
message.style.marginBottom = "1em";
const buttonContainer = contentEl.createEl("div");
buttonContainer.style.display = "flex";
buttonContainer.style.gap = "0.5em";
buttonContainer.style.justifyContent = "flex-end";
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" });
cancelBtn.onclick = () => {
this.result.confirmed = false;
this.close();
};
const confirmBtn = buttonContainer.createEl("button", {
text: "Yes, overwrite",
cls: "mod-cta",
});
confirmBtn.onclick = () => {
this.result.confirmed = true;
this.close();
};
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
if (this.resolve) {
this.resolve(this.result);
}
}
/**
* Show modal and return promise that resolves with user's choice.
*/
async show(): Promise<ConfirmOverwriteResult> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
}

View File

@ -0,0 +1,182 @@
/**
* Modal for choosing an edge type.
*/
import { Modal, Setting } from "obsidian";
import type { EdgeVocabulary } from "../vocab/types";
import type { GraphSchema } from "../mapping/graphSchema";
import { getAllEdgeTypes, groupEdgeTypesByCategory, computeEdgeSuggestions } from "../mapping/schemaHelper";
export interface EdgeTypeChoice {
edgeType: string; // Canonical edge type
alias?: string; // Selected alias if multiple aliases exist
}
export class EdgeTypeChooserModal extends Modal {
private result: EdgeTypeChoice | null = null;
private resolve: ((result: EdgeTypeChoice | null) => void) | null = null;
private vocabulary: EdgeVocabulary;
private sourceType: string | null;
private targetType: string | null;
private graphSchema: GraphSchema | null;
private selectedCanonical: string | null = null;
constructor(
app: any,
vocabulary: EdgeVocabulary,
sourceType: string | null,
targetType: string | null,
graphSchema: GraphSchema | null = null
) {
super(app);
this.vocabulary = vocabulary;
this.sourceType = sourceType;
this.targetType = targetType;
this.graphSchema = graphSchema;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("edge-type-chooser-modal");
contentEl.createEl("h2", { text: "Choose edge type" });
// Get suggestions
const suggestions = computeEdgeSuggestions(
this.vocabulary,
this.sourceType,
this.targetType,
this.graphSchema
);
const allEdgeTypes = getAllEdgeTypes(this.vocabulary);
const grouped = groupEdgeTypesByCategory(allEdgeTypes);
// Recommended section (if any)
if (suggestions.typical.length > 0) {
contentEl.createEl("h3", { text: "⭐ Recommended (schema)" });
const recommendedContainer = contentEl.createEl("div", { cls: "edge-type-list" });
for (const canonical of suggestions.typical) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue;
const displayName = entry.aliases.length > 0 ? entry.aliases[0] : canonical;
const btn = recommendedContainer.createEl("button", {
text: `${displayName}`,
cls: "mod-cta",
});
btn.onclick = () => {
this.selectEdgeType(canonical, entry.aliases);
};
}
}
// All categories
contentEl.createEl("h3", { text: "📂 All categories" });
for (const [category, types] of grouped.entries()) {
if (category !== "All") {
contentEl.createEl("h4", { text: category });
}
const container = contentEl.createEl("div", { cls: "edge-type-list" });
for (const { canonical, aliases, displayName } of types) {
const isProhibited = suggestions.prohibited.includes(canonical);
const isTypical = suggestions.typical.includes(canonical);
let prefix = "→";
if (isTypical) prefix = "⭐";
if (isProhibited) prefix = "🚫";
const btn = container.createEl("button", {
text: `${prefix} ${displayName}`,
});
if (isProhibited) {
btn.addClass("prohibited");
}
btn.onclick = () => {
this.selectEdgeType(canonical, aliases);
};
}
}
// Cancel button
const buttonContainer = contentEl.createEl("div");
buttonContainer.style.display = "flex";
buttonContainer.style.justifyContent = "flex-end";
buttonContainer.style.marginTop = "1em";
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel" });
cancelBtn.onclick = () => {
this.result = null;
this.close();
};
}
private selectEdgeType(canonical: string, aliases: string[]): void {
if (aliases.length === 0) {
// No aliases, use canonical directly
this.result = { edgeType: canonical };
this.close();
} else if (aliases.length === 1) {
// Single alias, use it
this.result = { edgeType: canonical, alias: aliases[0] };
this.close();
} else {
// Multiple aliases, show alias chooser
this.selectedCanonical = canonical;
this.showAliasChooser(aliases);
}
}
private showAliasChooser(aliases: string[]): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Choose alias" });
contentEl.createEl("p", { text: `Multiple aliases available for ${this.selectedCanonical}` });
const container = contentEl.createEl("div", { cls: "alias-list" });
for (const alias of aliases) {
const btn = container.createEl("button", {
text: alias,
cls: "mod-cta",
});
btn.onclick = () => {
if (this.selectedCanonical) {
this.result = { edgeType: this.selectedCanonical, alias };
this.close();
}
};
}
const backBtn = container.createEl("button", { text: "← Back" });
backBtn.onclick = () => {
this.onOpen(); // Reopen main chooser
};
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
if (this.resolve) {
this.resolve(this.result);
}
}
/**
* Show modal and return promise that resolves with user's choice.
*/
async show(): Promise<EdgeTypeChoice | null> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
}

View File

@ -54,6 +54,13 @@ export class EntityPickerModal extends Modal {
private expandedFolders: Set<string> = new Set(); private expandedFolders: Set<string> = new Set();
private folderTree: FolderTreeNode | null = null; private folderTree: FolderTreeNode | null = null;
// Virtualization state for performance
private visibleStartIndex: number = 0;
private visibleEndIndex: number = 100; // Initial visible items
private itemsPerPage: number = 100; // Items to render at once
private allFilteredEntries: NoteIndexEntry[] = []; // Cache filtered results
private renderScheduled: boolean = false;
constructor( constructor(
app: App, app: App,
noteIndex: NoteIndex, noteIndex: NoteIndex,
@ -103,9 +110,17 @@ export class EntityPickerModal extends Modal {
this.searchInputEl = text.inputEl; this.searchInputEl = text.inputEl;
text.setPlaceholder("Search notes..."); text.setPlaceholder("Search notes...");
text.setValue(this.searchQuery); text.setValue(this.searchQuery);
// Debounce search for performance
let searchTimeout: number | null = null;
text.onChange((value) => { text.onChange((value) => {
this.searchQuery = value; this.searchQuery = value;
this.refresh(); // Debounce search updates (300ms)
if (searchTimeout !== null) {
clearTimeout(searchTimeout);
}
searchTimeout = window.setTimeout(() => {
this.refresh();
}, 300);
}); });
text.inputEl.style.width = "100%"; text.inputEl.style.width = "100%";
}); });
@ -168,8 +183,14 @@ export class EntityPickerModal extends Modal {
rightPane.style.borderRadius = "4px"; rightPane.style.borderRadius = "4px";
rightPane.style.padding = "0.75em"; rightPane.style.padding = "0.75em";
rightPane.style.overflowY = "auto"; rightPane.style.overflowY = "auto";
rightPane.style.position = "relative";
this.resultsContainer = rightPane; this.resultsContainer = rightPane;
// Add scroll listener for virtualization
rightPane.addEventListener("scroll", () => {
this.handleScroll();
}, { passive: true });
// Type filter popover (hidden by default) // Type filter popover (hidden by default)
this.typeFilterContainer = contentEl.createEl("div", { this.typeFilterContainer = contentEl.createEl("div", {
cls: "entity-picker-type-filter", cls: "entity-picker-type-filter",
@ -203,16 +224,60 @@ export class EntityPickerModal extends Modal {
return; return;
} }
// Reset virtualization when filters/search change
this.visibleStartIndex = 0;
this.visibleEndIndex = this.itemsPerPage;
// Render folder tree // Render folder tree
this.renderFolderTree(); this.renderFolderTree();
// Render results // Render results (with virtualization) - this filters/sorts ALL entries
this.renderResults(); this.renderResults();
// Render type filter // Render type filter
this.renderTypeFilter(); this.renderTypeFilter();
} }
private handleScroll(): void {
if (!this.resultsContainer || this.allFilteredEntries.length === 0) {
return;
}
// Throttle scroll handling
if (this.renderScheduled) {
return;
}
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderScheduled = false;
const container = this.resultsContainer!;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
// Calculate visible range
const itemHeight = 60; // Approximate height per item (including padding)
const buffer = 5; // Render 5 extra items above/below
const newStartIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const newEndIndex = Math.min(
this.allFilteredEntries.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer
);
// Only re-render if visible range changed significantly
if (Math.abs(newStartIndex - this.visibleStartIndex) > 10 ||
Math.abs(newEndIndex - this.visibleEndIndex) > 10) {
this.visibleStartIndex = newStartIndex;
this.visibleEndIndex = newEndIndex;
// Only re-render the visible items, don't re-filter/re-sort
this.renderVisibleItems();
}
});
}
private renderFolderTree(): void { private renderFolderTree(): void {
if (!this.folderTreeContainer || !this.folderTree) { if (!this.folderTreeContainer || !this.folderTree) {
return; return;
@ -296,8 +361,6 @@ export class EntityPickerModal extends Modal {
return; return;
} }
this.resultsContainer.empty();
// Get all entries // Get all entries
let entries = this.noteIndex.getAllEntries(); let entries = this.noteIndex.getAllEntries();
@ -317,6 +380,9 @@ export class EntityPickerModal extends Modal {
}; };
entries = sortNotes(entries, sortOptions); entries = sortNotes(entries, sortOptions);
// Cache filtered entries for virtualization
this.allFilteredEntries = entries;
// Group if needed // Group if needed
if (this.sortMode === "type") { if (this.sortMode === "type") {
const groups = groupByType(entries, this.searchQuery); const groups = groupByType(entries, this.searchQuery);
@ -329,6 +395,16 @@ export class EntityPickerModal extends Modal {
} }
} }
private renderVisibleItems(): void {
// Re-render only visible items from cached filtered entries (for scroll updates)
if (!this.resultsContainer || this.allFilteredEntries.length === 0) {
return;
}
// Use the same logic as renderFlatResults but with cached entries
this.renderFlatResults(this.allFilteredEntries);
}
private renderFlatResults(entries: NoteIndexEntry[]): void { private renderFlatResults(entries: NoteIndexEntry[]): void {
if (!this.resultsContainer) { if (!this.resultsContainer) {
return; return;
@ -345,7 +421,22 @@ export class EntityPickerModal extends Modal {
return; return;
} }
for (const entry of entries) { // Virtualization: only render visible items
const visibleEntries = entries.slice(this.visibleStartIndex, this.visibleEndIndex);
const itemHeight = 60; // Approximate height per item
const offsetY = this.visibleStartIndex * itemHeight;
// Clear container
this.resultsContainer.empty();
// Add spacer for items before visible range
if (this.visibleStartIndex > 0) {
const topSpacer = this.resultsContainer.createEl("div");
topSpacer.style.height = `${offsetY}px`;
}
// Render visible items
for (const entry of visibleEntries) {
const itemEl = this.resultsContainer.createEl("div", { const itemEl = this.resultsContainer.createEl("div", {
cls: "entity-picker-item", cls: "entity-picker-item",
}); });
@ -388,6 +479,26 @@ export class EntityPickerModal extends Modal {
this.selectEntry(entry); this.selectEntry(entry);
}; };
} }
// Add spacer for items after visible range
const remainingItems = entries.length - this.visibleEndIndex;
if (remainingItems > 0) {
const bottomSpacer = this.resultsContainer.createEl("div");
bottomSpacer.style.height = `${remainingItems * itemHeight}px`;
}
// Show count if many items
if (entries.length > this.itemsPerPage) {
const countEl = this.resultsContainer.createEl("div", {
cls: "entity-picker-count",
});
countEl.style.padding = "0.5em";
countEl.style.textAlign = "center";
countEl.style.fontSize = "0.85em";
countEl.style.color = "var(--text-muted)";
countEl.style.borderTop = "1px solid var(--background-modifier-border)";
countEl.textContent = `Showing ${this.visibleStartIndex + 1}-${Math.min(this.visibleEndIndex, entries.length)} of ${entries.length} notes`;
}
} }
private renderGroupedResults(groups: GroupedNoteResult[]): void { private renderGroupedResults(groups: GroupedNoteResult[]): void {
@ -406,6 +517,12 @@ export class EntityPickerModal extends Modal {
return; return;
} }
// For grouped results, render all groups but virtualize items within groups
// This is simpler than full virtualization across groups
let itemIndex = 0;
let renderedItems = 0;
const maxItemsToRender = this.itemsPerPage * 2; // Allow more for grouped view
for (const group of groups) { for (const group of groups) {
// Group header // Group header
const groupHeader = this.resultsContainer.createEl("div", { const groupHeader = this.resultsContainer.createEl("div", {
@ -418,8 +535,19 @@ export class EntityPickerModal extends Modal {
groupHeader.style.borderBottom = "1px solid var(--background-modifier-border)"; groupHeader.style.borderBottom = "1px solid var(--background-modifier-border)";
groupHeader.textContent = `${group.groupLabel} (${group.notes.length})`; groupHeader.textContent = `${group.groupLabel} (${group.notes.length})`;
// Group items // Group items (with virtualization if too many)
for (const entry of group.notes) { const groupItemsToRender = group.notes.length > 50
? group.notes.slice(0, 50)
: group.notes;
for (const entry of groupItemsToRender) {
itemIndex++;
// Skip if we've rendered enough items
if (renderedItems >= maxItemsToRender) {
break;
}
renderedItems++;
const itemEl = this.resultsContainer.createEl("div", { const itemEl = this.resultsContainer.createEl("div", {
cls: "entity-picker-item", cls: "entity-picker-item",
}); });
@ -456,6 +584,37 @@ export class EntityPickerModal extends Modal {
this.selectEntry(entry); this.selectEntry(entry);
}; };
} }
// Show "more items" indicator if truncated
if (group.notes.length > 50) {
const moreEl = this.resultsContainer.createEl("div", {
cls: "entity-picker-more",
});
moreEl.style.padding = "0.5em";
moreEl.style.textAlign = "center";
moreEl.style.fontSize = "0.85em";
moreEl.style.color = "var(--text-muted)";
moreEl.style.fontStyle = "italic";
moreEl.textContent = `... and ${group.notes.length - 50} more in this group`;
}
if (renderedItems >= maxItemsToRender) {
break;
}
}
// Show total count if many items
const totalItems = groups.reduce((sum, g) => sum + g.notes.length, 0);
if (totalItems > maxItemsToRender) {
const countEl = this.resultsContainer.createEl("div", {
cls: "entity-picker-count",
});
countEl.style.padding = "0.5em";
countEl.style.textAlign = "center";
countEl.style.fontSize = "0.85em";
countEl.style.color = "var(--text-muted)";
countEl.style.borderTop = "1px solid var(--background-modifier-border)";
countEl.textContent = `Showing first ${maxItemsToRender} of ${totalItems} notes. Use search/filters to narrow results.`;
} }
} }

183
src/ui/LinkPromptModal.ts Normal file
View File

@ -0,0 +1,183 @@
/**
* Modal for prompting edge type assignment for a single link.
*/
import { Modal } from "obsidian";
import type { LinkWorkItem } from "../mapping/worklistBuilder";
import type { EdgeVocabulary } from "../vocab/types";
import type { GraphSchema } from "../mapping/graphSchema";
import { EdgeTypeChooserModal, type EdgeTypeChoice } from "./EdgeTypeChooserModal";
import { computeEdgeSuggestions } from "../mapping/schemaHelper";
export type LinkPromptDecision =
| { action: "keep"; edgeType: string }
| { action: "change"; edgeType: string; alias?: string }
| { action: "skip" };
export class LinkPromptModal extends Modal {
private item: LinkWorkItem;
private vocabulary: EdgeVocabulary;
private sourceType: string | null;
private graphSchema: GraphSchema | null;
private result: LinkPromptDecision | null = null;
private resolve: ((result: LinkPromptDecision) => void) | null = null;
constructor(
app: any,
item: LinkWorkItem,
vocabulary: EdgeVocabulary,
sourceType: string | null,
graphSchema: GraphSchema | null = null
) {
super(app);
this.item = item;
this.vocabulary = vocabulary;
this.sourceType = sourceType;
this.graphSchema = graphSchema;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("link-prompt-modal");
// Link info
const linkInfo = contentEl.createEl("div", { cls: "link-info" });
linkInfo.createEl("h2", { text: `Link: [[${this.item.link}]]` });
if (this.item.targetType) {
linkInfo.createEl("p", { text: `Target type: ${this.item.targetType}` });
}
// Current mapping (if exists)
if (this.item.currentType) {
const currentInfo = linkInfo.createEl("p", { cls: "current-mapping" });
currentInfo.textContent = `Current edge type: ${this.item.currentType}`;
// Check if current type is prohibited
if (this.graphSchema) {
const suggestions = computeEdgeSuggestions(
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
if (suggestions.prohibited.includes(this.item.currentType)) {
const warning = linkInfo.createEl("span", {
text: " ⚠️ Prohibited",
cls: "prohibited-warning",
});
warning.style.color = "var(--text-error)";
warning.style.fontWeight = "bold";
}
}
}
// Action buttons
const buttonContainer = contentEl.createEl("div", { cls: "action-buttons" });
buttonContainer.style.display = "flex";
buttonContainer.style.flexDirection = "column";
buttonContainer.style.gap = "0.5em";
buttonContainer.style.marginTop = "1em";
if (this.item.currentType) {
// Keep current
const keepBtn = buttonContainer.createEl("button", {
text: `✅ Keep (${this.item.currentType})`,
cls: "mod-cta",
});
keepBtn.onclick = () => {
this.result = { action: "keep", edgeType: this.item.currentType! };
this.close();
};
// Change type
const changeBtn = buttonContainer.createEl("button", {
text: "Change type...",
});
changeBtn.onclick = async () => {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const choice = await chooser.show();
if (choice) {
this.result = {
action: "change",
edgeType: choice.edgeType,
alias: choice.alias,
};
this.close();
}
};
// Skip
const skipBtn = buttonContainer.createEl("button", {
text: "Skip link",
});
skipBtn.onclick = () => {
this.result = { action: "skip" };
this.close();
};
} else {
// No current mapping
// Skip
const skipBtn = buttonContainer.createEl("button", {
text: "⏩ Skip link",
});
skipBtn.onclick = () => {
this.result = { action: "skip" };
this.close();
};
// Choose type
const chooseBtn = buttonContainer.createEl("button", {
text: "Choose type...",
cls: "mod-cta",
});
chooseBtn.onclick = async () => {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const choice = await chooser.show();
if (choice) {
this.result = {
action: "change",
edgeType: choice.edgeType,
alias: choice.alias,
};
this.close();
}
};
}
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
if (this.resolve && this.result) {
this.resolve(this.result);
} else if (this.resolve) {
// User closed without decision, treat as skip
this.resolve({ action: "skip" });
}
}
/**
* Show modal and return promise that resolves with user's decision.
*/
async show(): Promise<LinkPromptDecision> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
}

View File

@ -193,5 +193,95 @@ export class MindnetSettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
}) })
); );
// Semantic Mapping Builder section
containerEl.createEl("h2", { text: "Semantic Mapping Builder" });
// Wrapper callout type
new Setting(containerEl)
.setName("Mapping wrapper callout type")
.setDesc("Callout type for the mapping wrapper (e.g., 'abstract', 'info', 'note')")
.addText((text) =>
text
.setPlaceholder("abstract")
.setValue(this.plugin.settings.mappingWrapperCalloutType)
.onChange(async (value) => {
this.plugin.settings.mappingWrapperCalloutType = value || "abstract";
await this.plugin.saveSettings();
})
);
// Wrapper title
new Setting(containerEl)
.setName("Mapping wrapper title")
.setDesc("Title text for the mapping wrapper callout")
.addText((text) =>
text
.setPlaceholder("🕸️ Semantic Mapping")
.setValue(this.plugin.settings.mappingWrapperTitle)
.onChange(async (value) => {
this.plugin.settings.mappingWrapperTitle = value || "🕸️ Semantic Mapping";
await this.plugin.saveSettings();
})
);
// Wrapper folded
new Setting(containerEl)
.setName("Mapping wrapper folded")
.setDesc("Start with mapping wrapper callout folded (collapsed)")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.mappingWrapperFolded)
.onChange(async (value) => {
this.plugin.settings.mappingWrapperFolded = value;
await this.plugin.saveSettings();
})
);
// Default edge type
new Setting(containerEl)
.setName("Default edge type")
.setDesc("Default edge type for unmapped links (if unassignedHandling is 'defaultType')")
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.defaultEdgeType)
.onChange(async (value) => {
this.plugin.settings.defaultEdgeType = value;
await this.plugin.saveSettings();
})
);
// Unassigned handling
new Setting(containerEl)
.setName("Unassigned handling")
.setDesc("How to handle links without existing mappings: prompt (interactive), none, defaultType, or advisor")
.addDropdown((dropdown) =>
dropdown
.addOption("prompt", "Prompt (interactive)")
.addOption("none", "None (skip unmapped)")
.addOption("defaultType", "Use default edge type")
.addOption("advisor", "Use advisor (future: E1 engine)")
.setValue(this.plugin.settings.unassignedHandling)
.onChange(async (value) => {
if (value === "prompt" || value === "none" || value === "defaultType" || value === "advisor") {
this.plugin.settings.unassignedHandling = value;
await this.plugin.saveSettings();
}
})
);
// Allow overwrite existing mappings
new Setting(containerEl)
.setName("Allow overwrite existing mappings")
.setDesc("If enabled, will prompt to confirm before overwriting existing edge type assignments")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.allowOverwriteExistingMappings)
.onChange(async (value) => {
this.plugin.settings.allowOverwriteExistingMappings = value;
await this.plugin.saveSettings();
})
);
} }
} }