Add semantic mapping builder functionality and settings
- 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:
parent
78e8216ab9
commit
58b6ffffed
53
src/main.ts
53
src/main.ts
|
|
@ -18,6 +18,8 @@ import { slugify } from "./interview/slugify";
|
|||
import { writeFrontmatter } from "./interview/writeFrontmatter";
|
||||
import { InterviewWizardModal, type WizardResult } from "./ui/InterviewWizardModal";
|
||||
import { extractTargetFromAnchor } from "./interview/extractTargetFromAnchor";
|
||||
import { buildSemanticMappings } from "./mapping/semanticMappingBuilder";
|
||||
import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal";
|
||||
|
||||
export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||
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(
|
||||
|
|
|
|||
158
src/mapping/graphSchema.ts
Normal file
158
src/mapping/graphSchema.ts
Normal 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;
|
||||
}
|
||||
158
src/mapping/mappingBuilder.ts
Normal file
158
src/mapping/mappingBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
159
src/mapping/mappingExtractor.ts
Normal file
159
src/mapping/mappingExtractor.ts
Normal 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");
|
||||
}
|
||||
77
src/mapping/schemaHelper.ts
Normal file
77
src/mapping/schemaHelper.ts
Normal 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;
|
||||
}
|
||||
110
src/mapping/sectionParser.ts
Normal file
110
src/mapping/sectionParser.ts
Normal 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
|
||||
}
|
||||
270
src/mapping/semanticMappingBuilder.ts
Normal file
270
src/mapping/semanticMappingBuilder.ts
Normal 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");
|
||||
}
|
||||
88
src/mapping/worklistBuilder.ts
Normal file
88
src/mapping/worklistBuilder.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ export interface MindnetSettings {
|
|||
interviewConfigPath: string; // vault-relativ
|
||||
autoStartInterviewOnCreate: 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 = {
|
||||
|
|
@ -20,6 +27,12 @@ export interface MindnetSettings {
|
|||
interviewConfigPath: "_system/dictionary/interview_config.yaml",
|
||||
autoStartInterviewOnCreate: false,
|
||||
interceptUnresolvedLinkClicks: true,
|
||||
mappingWrapperCalloutType: "abstract",
|
||||
mappingWrapperTitle: "🕸️ Semantic Mapping",
|
||||
mappingWrapperFolded: true,
|
||||
defaultEdgeType: "",
|
||||
unassignedHandling: "prompt",
|
||||
allowOverwriteExistingMappings: false,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
97
src/tests/mapping/graphSchema.test.ts
Normal file
97
src/tests/mapping/graphSchema.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
126
src/tests/mapping/mappingBuilder.test.ts
Normal file
126
src/tests/mapping/mappingBuilder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
src/tests/mapping/mappingExtractor.test.ts
Normal file
67
src/tests/mapping/mappingExtractor.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
81
src/tests/mapping/sectionParser.test.ts
Normal file
81
src/tests/mapping/sectionParser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
src/ui/ConfirmOverwriteModal.ts
Normal file
70
src/ui/ConfirmOverwriteModal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
182
src/ui/EdgeTypeChooserModal.ts
Normal file
182
src/ui/EdgeTypeChooserModal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,13 @@ export class EntityPickerModal extends Modal {
|
|||
private expandedFolders: Set<string> = new Set();
|
||||
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(
|
||||
app: App,
|
||||
noteIndex: NoteIndex,
|
||||
|
|
@ -103,9 +110,17 @@ export class EntityPickerModal extends Modal {
|
|||
this.searchInputEl = text.inputEl;
|
||||
text.setPlaceholder("Search notes...");
|
||||
text.setValue(this.searchQuery);
|
||||
// Debounce search for performance
|
||||
let searchTimeout: number | null = null;
|
||||
text.onChange((value) => {
|
||||
this.searchQuery = value;
|
||||
// Debounce search updates (300ms)
|
||||
if (searchTimeout !== null) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
this.refresh();
|
||||
}, 300);
|
||||
});
|
||||
text.inputEl.style.width = "100%";
|
||||
});
|
||||
|
|
@ -168,8 +183,14 @@ export class EntityPickerModal extends Modal {
|
|||
rightPane.style.borderRadius = "4px";
|
||||
rightPane.style.padding = "0.75em";
|
||||
rightPane.style.overflowY = "auto";
|
||||
rightPane.style.position = "relative";
|
||||
this.resultsContainer = rightPane;
|
||||
|
||||
// Add scroll listener for virtualization
|
||||
rightPane.addEventListener("scroll", () => {
|
||||
this.handleScroll();
|
||||
}, { passive: true });
|
||||
|
||||
// Type filter popover (hidden by default)
|
||||
this.typeFilterContainer = contentEl.createEl("div", {
|
||||
cls: "entity-picker-type-filter",
|
||||
|
|
@ -203,16 +224,60 @@ export class EntityPickerModal extends Modal {
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset virtualization when filters/search change
|
||||
this.visibleStartIndex = 0;
|
||||
this.visibleEndIndex = this.itemsPerPage;
|
||||
|
||||
// Render folder tree
|
||||
this.renderFolderTree();
|
||||
|
||||
// Render results
|
||||
// Render results (with virtualization) - this filters/sorts ALL entries
|
||||
this.renderResults();
|
||||
|
||||
// Render type filter
|
||||
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 {
|
||||
if (!this.folderTreeContainer || !this.folderTree) {
|
||||
return;
|
||||
|
|
@ -296,8 +361,6 @@ export class EntityPickerModal extends Modal {
|
|||
return;
|
||||
}
|
||||
|
||||
this.resultsContainer.empty();
|
||||
|
||||
// Get all entries
|
||||
let entries = this.noteIndex.getAllEntries();
|
||||
|
||||
|
|
@ -317,6 +380,9 @@ export class EntityPickerModal extends Modal {
|
|||
};
|
||||
entries = sortNotes(entries, sortOptions);
|
||||
|
||||
// Cache filtered entries for virtualization
|
||||
this.allFilteredEntries = entries;
|
||||
|
||||
// Group if needed
|
||||
if (this.sortMode === "type") {
|
||||
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 {
|
||||
if (!this.resultsContainer) {
|
||||
return;
|
||||
|
|
@ -345,7 +421,22 @@ export class EntityPickerModal extends Modal {
|
|||
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", {
|
||||
cls: "entity-picker-item",
|
||||
});
|
||||
|
|
@ -388,6 +479,26 @@ export class EntityPickerModal extends Modal {
|
|||
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 {
|
||||
|
|
@ -406,6 +517,12 @@ export class EntityPickerModal extends Modal {
|
|||
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) {
|
||||
// Group header
|
||||
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.textContent = `${group.groupLabel} (${group.notes.length})`;
|
||||
|
||||
// Group items
|
||||
for (const entry of group.notes) {
|
||||
// Group items (with virtualization if too many)
|
||||
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", {
|
||||
cls: "entity-picker-item",
|
||||
});
|
||||
|
|
@ -456,6 +584,37 @@ export class EntityPickerModal extends Modal {
|
|||
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
183
src/ui/LinkPromptModal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -193,5 +193,95 @@ export class MindnetSettingTab extends PluginSettingTab {
|
|||
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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user