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 { 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
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
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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 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
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();
|
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