diff --git a/src/main.ts b/src/main.ts index 6b30d4c..0024e90 100644 --- a/src/main.ts +++ b/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( diff --git a/src/mapping/graphSchema.ts b/src/mapping/graphSchema.ts new file mode 100644 index 0000000..bd029b5 --- /dev/null +++ b/src/mapping/graphSchema.ts @@ -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>; +} + +/** + * Parse graph_schema.md markdown content. + */ +export function parseGraphSchema(markdown: string): GraphSchema { + const schema = new Map>(); + 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; +} diff --git a/src/mapping/mappingBuilder.ts b/src/mapping/mappingBuilder.ts new file mode 100644 index 0000000..0941e33 --- /dev/null +++ b/src/mapping/mappingBuilder.ts @@ -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, + options: MappingBuilderOptions +): string | null { + if (links.length === 0) { + return null; // No links, no mapping block + } + + // Group links by edgeType + const groups = new Map(); + + 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; + } +} diff --git a/src/mapping/mappingExtractor.ts b/src/mapping/mappingExtractor.ts new file mode 100644 index 0000000..229608f --- /dev/null +++ b/src/mapping/mappingExtractor.ts @@ -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; // 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(); + 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: > [!] 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"); +} diff --git a/src/mapping/schemaHelper.ts b/src/mapping/schemaHelper.ts new file mode 100644 index 0000000..9448aa6 --- /dev/null +++ b/src/mapping/schemaHelper.ts @@ -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; +} diff --git a/src/mapping/sectionParser.ts b/src/mapping/sectionParser.ts new file mode 100644 index 0000000..198a7a5 --- /dev/null +++ b/src/mapping/sectionParser.ts @@ -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 +} diff --git a/src/mapping/semanticMappingBuilder.ts b/src/mapping/semanticMappingBuilder.ts new file mode 100644 index 0000000..497cd09 --- /dev/null +++ b/src/mapping/semanticMappingBuilder.ts @@ -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"); +} diff --git a/src/mapping/worklistBuilder.ts b/src/mapping/worklistBuilder.ts new file mode 100644 index 0000000..d5b03c4 --- /dev/null +++ b/src/mapping/worklistBuilder.ts @@ -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; +} diff --git a/src/settings.ts b/src/settings.ts index 0449ec1..b75256d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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, }; /** diff --git a/src/tests/mapping/graphSchema.test.ts b/src/tests/mapping/graphSchema.test.ts new file mode 100644 index 0000000..f9f579d --- /dev/null +++ b/src/tests/mapping/graphSchema.test.ts @@ -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"); + }); +}); diff --git a/src/tests/mapping/mappingBuilder.test.ts b/src/tests/mapping/mappingBuilder.test.ts new file mode 100644 index 0000000..0d814e0 --- /dev/null +++ b/src/tests/mapping/mappingBuilder.test.ts @@ -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); + }); +}); diff --git a/src/tests/mapping/mappingExtractor.test.ts b/src/tests/mapping/mappingExtractor.test.ts new file mode 100644 index 0000000..1a1c573 --- /dev/null +++ b/src/tests/mapping/mappingExtractor.test.ts @@ -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"); + }); +}); diff --git a/src/tests/mapping/sectionParser.test.ts b/src/tests/mapping/sectionParser.test.ts new file mode 100644 index 0000000..b100aea --- /dev/null +++ b/src/tests/mapping/sectionParser.test.ts @@ -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); + }); +}); diff --git a/src/ui/ConfirmOverwriteModal.ts b/src/ui/ConfirmOverwriteModal.ts new file mode 100644 index 0000000..bc70463 --- /dev/null +++ b/src/ui/ConfirmOverwriteModal.ts @@ -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(); + }); + } +} diff --git a/src/ui/EdgeTypeChooserModal.ts b/src/ui/EdgeTypeChooserModal.ts new file mode 100644 index 0000000..56bed42 --- /dev/null +++ b/src/ui/EdgeTypeChooserModal.ts @@ -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(); + }); + } +} diff --git a/src/ui/EntityPickerModal.ts b/src/ui/EntityPickerModal.ts index 26a78f8..742c1c1 100644 --- a/src/ui/EntityPickerModal.ts +++ b/src/ui/EntityPickerModal.ts @@ -53,6 +53,13 @@ export class EntityPickerModal extends Modal { // Folder tree state 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, @@ -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; - this.refresh(); + // 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,15 +224,59 @@ 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) { @@ -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.`; } } diff --git a/src/ui/LinkPromptModal.ts b/src/ui/LinkPromptModal.ts new file mode 100644 index 0000000..6325d2f --- /dev/null +++ b/src/ui/LinkPromptModal.ts @@ -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(); + }); + } +} diff --git a/src/ui/MindnetSettingTab.ts b/src/ui/MindnetSettingTab.ts index 3edccd9..947c59e 100644 --- a/src/ui/MindnetSettingTab.ts +++ b/src/ui/MindnetSettingTab.ts @@ -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(); + }) + ); } }