mindnet_obsidian/src/mapping/semanticMappingBuilder.ts
Lars 1f9211cbc5
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Add graph schema loading and validation functionality
- Introduced methods for loading and reloading graph schema, including debounced reload on file changes.
- Enhanced the semantic mapping builder to utilize the new graph schema loading mechanism for improved caching and live updates.
- Added a validation button in the settings interface to check the existence and validity of the graph schema file, providing user feedback on the schema status.
- Improved error handling for graph schema loading to ensure clear notifications for users in case of issues.
2026-01-17 07:42:17 +01:00

278 lines
8.9 KiB
TypeScript

/**
* 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<GraphSchema | null> }
): 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)
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<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");
}