/** * 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, plugin?: { ensureGraphSchemaLoaded?: () => Promise } ): Promise { 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) if (plugin && plugin.ensureGraphSchemaLoaded) { // Use plugin's ensureGraphSchemaLoaded for caching and live reload graphSchema = await plugin.ensureGraphSchemaLoaded(); } else { // Fallback: direct load (no caching) 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(); 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"); }