- 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.
278 lines
8.9 KiB
TypeScript
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");
|
|
}
|