mindnet_obsidian/src/main.ts

746 lines
26 KiB
TypeScript
Raw Normal View History

import { Notice, Plugin, TFile } from "obsidian";
import { DEFAULT_SETTINGS, type MindnetSettings, normalizeVaultPath } from "./settings";
import { VocabularyLoader } from "./vocab/VocabularyLoader";
import { parseEdgeVocabulary } from "./vocab/parseEdgeVocabulary";
import { Vocabulary } from "./vocab/Vocabulary";
import { LintEngine } from "./lint/LintEngine";
import { MindnetSettingTab } from "./ui/MindnetSettingTab";
import { exportGraph } from "./export/exportGraph";
import { buildGraph } from "./graph/GraphBuilder";
import { buildIndex } from "./graph/GraphIndex";
import { traverseForward, traverseBackward, type Path } from "./graph/traverse";
import { renderChainReport } from "./graph/renderChainReport";
import { extractFrontmatterId } from "./parser/parseFrontmatter";
import { InterviewConfigLoader } from "./interview/InterviewConfigLoader";
import type { InterviewConfig } from "./interview/types";
import { ProfileSelectionModal } from "./ui/ProfileSelectionModal";
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";
import { GraphSchemaLoader } from "./schema/GraphSchemaLoader";
import type { GraphSchema } from "./mapping/graphSchema";
export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings;
private vocabulary: Vocabulary | null = null;
private reloadDebounceTimer: number | null = null;
private interviewConfig: InterviewConfig | null = null;
private interviewConfigReloadDebounceTimer: number | null = null;
private graphSchema: GraphSchema | null = null;
private graphSchemaReloadDebounceTimer: number | null = null;
async onload(): Promise<void> {
await this.loadSettings();
// Add settings tab
this.addSettingTab(new MindnetSettingTab(this.app, this));
// Register click handler for unresolved links
this.registerDomEvent(document, "click", async (evt: MouseEvent) => {
if (!this.settings.interceptUnresolvedLinkClicks) {
return;
}
const target = evt.target as HTMLElement;
if (!target) return;
// Find closest unresolved internal link
const anchor = target.closest("a.internal-link.is-unresolved");
if (!anchor || !(anchor instanceof HTMLElement)) {
return;
}
// Prevent default link behavior
evt.preventDefault();
evt.stopPropagation();
// Extract target basename (preserves spaces and case)
const basename = extractTargetFromAnchor(anchor);
if (!basename) {
return;
}
// Use basename directly as title (Option 1: exact match)
const title = basename;
// Load interview config and open profile selection
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
new Notice("Interview config not available");
return;
}
if (config.profiles.length === 0) {
new Notice("No profiles available in interview config");
return;
}
// Open profile selection modal
new ProfileSelectionModal(
this.app,
config,
async (result) => {
await this.createNoteFromProfileAndOpen(result, basename);
},
title
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to handle link click: ${msg}`);
console.error(e);
}
});
// Register live reload for edge vocabulary file
this.registerEvent(
this.app.vault.on("modify", async (file: TFile) => {
const normalizedFilePath = normalizeVaultPath(file.path);
const normalizedVocabPath = normalizeVaultPath(this.settings.edgeVocabularyPath);
// Check if modified file matches vocabulary path (exact match or ends with)
if (normalizedFilePath === normalizedVocabPath ||
normalizedFilePath === `/${normalizedVocabPath}` ||
normalizedFilePath.endsWith(`/${normalizedVocabPath}`)) {
// Debounce reload to avoid multiple rapid reloads
if (this.reloadDebounceTimer !== null) {
window.clearTimeout(this.reloadDebounceTimer);
}
this.reloadDebounceTimer = window.setTimeout(async () => {
await this.reloadVocabulary();
this.reloadDebounceTimer = null;
}, 200);
}
// Check if modified file matches interview config path
const normalizedInterviewConfigPath = normalizeVaultPath(this.settings.interviewConfigPath);
if (normalizedFilePath === normalizedInterviewConfigPath ||
normalizedFilePath === `/${normalizedInterviewConfigPath}` ||
normalizedFilePath.endsWith(`/${normalizedInterviewConfigPath}`)) {
// Debounce reload
if (this.interviewConfigReloadDebounceTimer !== null) {
window.clearTimeout(this.interviewConfigReloadDebounceTimer);
}
this.interviewConfigReloadDebounceTimer = window.setTimeout(async () => {
await this.reloadInterviewConfig();
this.interviewConfigReloadDebounceTimer = null;
}, 200);
}
// Check if modified file matches graph schema path
const normalizedGraphSchemaPath = normalizeVaultPath(this.settings.graphSchemaPath);
if (normalizedFilePath === normalizedGraphSchemaPath ||
normalizedFilePath === `/${normalizedGraphSchemaPath}` ||
normalizedFilePath.endsWith(`/${normalizedGraphSchemaPath}`)) {
// Debounce reload
if (this.graphSchemaReloadDebounceTimer !== null) {
window.clearTimeout(this.graphSchemaReloadDebounceTimer);
}
this.graphSchemaReloadDebounceTimer = window.setTimeout(async () => {
await this.reloadGraphSchema();
this.graphSchemaReloadDebounceTimer = null;
}, 200);
}
})
);
this.addCommand({
id: "mindnet-reload-edge-vocabulary",
name: "Mindnet: Reload edge vocabulary",
callback: async () => {
await this.reloadVocabulary();
},
});
this.addCommand({
id: "mindnet-validate-current-note",
name: "Mindnet: Validate current note",
callback: async () => {
try {
const vocabulary = await this.ensureVocabularyLoaded();
if (!vocabulary) {
return;
}
const findings = await LintEngine.lintCurrentNote(
this.app,
vocabulary,
{ showCanonicalHints: this.settings.showCanonicalHints }
);
// Count findings by severity
const errorCount = findings.filter(f => f.severity === "ERROR").length;
const warnCount = findings.filter(f => f.severity === "WARN").length;
const infoCount = findings.filter(f => f.severity === "INFO").length;
// Show summary notice
new Notice(`Lint: ${errorCount} errors, ${warnCount} warnings, ${infoCount} info`);
// Log findings to console
console.log("=== Lint Findings ===");
for (const finding of findings) {
console.log(`[${finding.severity}] ${finding.ruleId}: ${finding.message} (${finding.filePath}:${finding.lineStart})`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to validate note: ${msg}`);
console.error(e);
}
},
});
this.addCommand({
id: "mindnet-export-graph",
name: "Mindnet: Export graph",
callback: async () => {
try {
const vocabulary = await this.ensureVocabularyLoaded();
if (!vocabulary) {
return;
}
const outputPath = "_system/exports/graph_export.json";
await exportGraph(this.app, vocabulary, outputPath);
new Notice(`Graph exported to ${outputPath}`);
console.log(`Graph exported: ${outputPath}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to export graph: ${msg}`);
console.error(e);
}
},
});
this.addCommand({
id: "mindnet-show-chains-from-current-note",
name: "Mindnet: Show chains from current note",
callback: async () => {
try {
const vocabulary = await this.ensureVocabularyLoaded();
if (!vocabulary) {
return;
}
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;
}
// Extract start ID from frontmatter
const content = await this.app.vault.read(activeFile);
const startId = extractFrontmatterId(content);
if (!startId) {
new Notice("Current note has no frontmatter ID. Add 'id: <value>' to frontmatter.");
return;
}
// Build graph
const graph = await buildGraph(this.app, vocabulary);
// Get start node meta
const startMeta = graph.idToMeta.get(startId);
if (!startMeta) {
new Notice(`Start node ID '${startId}' not found in graph`);
return;
}
// Build index
const index = buildIndex(graph.edges);
// Run traversal
let allPaths: Path[] = [];
if (this.settings.chainDirection === "forward" || this.settings.chainDirection === "both") {
const forwardPaths = traverseForward(
index,
startId,
this.settings.maxHops,
200
);
allPaths = [...allPaths, ...forwardPaths];
}
if (this.settings.chainDirection === "backward" || this.settings.chainDirection === "both") {
const backwardPaths = traverseBackward(
index,
startId,
this.settings.maxHops,
200
);
allPaths = [...allPaths, ...backwardPaths];
}
// Render report
const report = renderChainReport({
startId,
startMeta,
paths: allPaths,
direction: this.settings.chainDirection,
maxHops: this.settings.maxHops,
maxPaths: 200,
warnings: graph.warnings,
idToMeta: graph.idToMeta,
});
// Write report file
const reportPath = "_system/exports/chain_report.md";
await this.app.vault.adapter.write(reportPath, report);
// Open report
const reportFile = this.app.vault.getAbstractFileByPath(reportPath);
if (reportFile && reportFile instanceof TFile) {
await this.app.workspace.openLinkText(reportPath, "", true);
}
// Show summary
const uniqueNodes = new Set<string>();
for (const path of allPaths) {
for (const nodeId of path.nodes) {
uniqueNodes.add(nodeId);
}
}
const totalWarnings =
graph.warnings.missingFrontmatterId.length +
graph.warnings.missingTargetFile.length +
graph.warnings.missingTargetId.length;
new Notice(
`Chains: ${allPaths.length} paths, ${uniqueNodes.size} nodes, ${totalWarnings} warnings`
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to generate chain report: ${msg}`);
console.error(e);
}
},
});
this.addCommand({
id: "mindnet-create-note-from-profile",
name: "Mindnet: Create note from profile",
callback: async () => {
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
return;
}
if (config.profiles.length === 0) {
new Notice("No profiles available in interview config");
return;
}
// Get link text from clipboard or active selection if available
let initialTitle = "";
try {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
const content = await this.app.vault.read(activeFile);
const selection = this.app.workspace.activeEditor?.editor?.getSelection();
if (selection && selection.trim()) {
initialTitle = selection.trim();
}
}
} catch {
// Ignore errors getting initial title
}
// Show modal
new ProfileSelectionModal(
this.app,
config,
async (result) => {
await this.createNoteFromProfile(result);
},
initialTitle
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to create note: ${msg}`);
console.error(e);
}
},
});
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,
this
);
// 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(
result: { profile: import("./interview/types").InterviewProfile; title: string },
preferredBasename?: string
): Promise<void> {
try {
const config = await this.ensureInterviewConfigLoaded();
if (!config) {
return;
}
// Use preferred basename if provided, otherwise use title directly (preserve spaces)
// Only use slugify if explicitly requested (future feature)
const filenameBase = preferredBasename || result.title;
if (!filenameBase || !filenameBase.trim()) {
new Notice("Invalid title: cannot create filename");
return;
}
// Generate unique ID (simple timestamp-based for now)
const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
// Write frontmatter
const frontmatter = writeFrontmatter({
id,
title: result.title,
noteType: result.profile.note_type,
interviewProfile: result.profile.key,
defaults: result.profile.defaults,
frontmatterWhitelist: config.frontmatterWhitelist,
});
// Create file content
const content = `${frontmatter}\n\n`;
// Determine file path (use filenameBase directly, preserving spaces)
// Handle name conflicts by appending " (2)", " (3)", etc. (Obsidian style)
let fileName = `${filenameBase.trim()}.md`;
let filePath = fileName;
let attempt = 1;
while (true) {
const existingFile = this.app.vault.getAbstractFileByPath(filePath);
if (!existingFile) {
break; // File doesn't exist, we can use this path
}
attempt++;
fileName = `${filenameBase.trim()} (${attempt}).md`;
filePath = fileName;
}
// Create file
const file = await this.app.vault.create(filePath, content);
// Open file
await this.app.workspace.openLinkText(filePath, "", true);
// Show notice
new Notice(`Note created: ${result.title}`);
// Auto-start interview if setting enabled
if (this.settings.autoStartInterviewOnCreate) {
try {
console.log("Start wizard", {
profileKey: result.profile.key,
file: file.path,
});
new InterviewWizardModal(
this.app,
result.profile,
file,
content,
async (wizardResult: WizardResult) => {
new Notice("Interview completed and changes applied");
},
async (wizardResult: WizardResult) => {
new Notice("Interview saved and changes applied");
}
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to start interview wizard: ${msg}`);
console.error("Failed to start wizard:", e);
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to create note: ${msg}`);
console.error(e);
}
}
private async createNoteFromProfile(
result: { profile: import("./interview/types").InterviewProfile; title: string }
): Promise<void> {
// For manual creation, use title directly (preserve spaces, Obsidian style)
// slugify() is kept for future "normalize filename" option
return this.createNoteFromProfileAndOpen(result, result.title);
}
onunload(): void {
// nothing yet
}
private async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
/**
* Ensure vocabulary is loaded. Auto-loads if not present.
* Returns Vocabulary instance or null on failure.
*/
private async ensureVocabularyLoaded(): Promise<Vocabulary | null> {
if (this.vocabulary) {
return this.vocabulary;
}
try {
const text = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
const parsed = parseEdgeVocabulary(text);
this.vocabulary = new Vocabulary(parsed);
const stats = this.vocabulary.getStats();
console.log("Vocabulary auto-loaded", stats);
return this.vocabulary;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
new Notice("edge_vocabulary.md not found. Check the path in plugin settings.");
} else {
new Notice(`Failed to load vocabulary: ${msg}. Check plugin settings.`);
}
console.error("Failed to load vocabulary:", e);
return null;
}
}
/**
* Reload vocabulary from file. Used by manual command and live reload.
*/
private async reloadVocabulary(): Promise<void> {
try {
const text = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
const parsed = parseEdgeVocabulary(text);
this.vocabulary = new Vocabulary(parsed);
const stats = this.vocabulary.getStats();
console.log("Vocabulary loaded", stats);
new Notice(`Edge vocabulary reloaded: ${stats.canonicalCount} canonical, ${stats.aliasCount} aliases`);
// Log normalization examples
if (stats.canonicalCount > 0) {
const firstCanonical = Array.from(parsed.byCanonical.keys())[0];
if (firstCanonical) {
const canonicalNorm = this.vocabulary.normalize(firstCanonical);
console.log(`Normalization example (canonical): "${firstCanonical}" -> canonical: ${canonicalNorm.canonical}, inverse: ${canonicalNorm.inverse}`);
}
if (stats.aliasCount > 0) {
const firstAlias = Array.from(parsed.aliasToCanonical.keys())[0];
if (firstAlias) {
const aliasNorm = this.vocabulary.normalize(firstAlias);
console.log(`Normalization example (alias): "${firstAlias}" -> canonical: ${aliasNorm.canonical}, inverse: ${aliasNorm.inverse}`);
}
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("Vocabulary file not found")) {
new Notice("edge_vocabulary.md not found. Configure path in plugin settings.");
} else {
new Notice(`Failed to reload vocabulary: ${msg}`);
}
console.error(e);
}
}
/**
* Ensure interview config is loaded. Auto-loads if not present.
* Returns InterviewConfig instance or null on failure.
*/
private async ensureInterviewConfigLoaded(): Promise<InterviewConfig | null> {
if (this.interviewConfig) {
return this.interviewConfig;
}
try {
const result = await InterviewConfigLoader.loadConfig(
this.app,
this.settings.interviewConfigPath
);
if (result.errors.length > 0) {
console.warn("Interview config loaded with errors:", result.errors);
new Notice(`Interview config loaded with ${result.errors.length} error(s). Check console.`);
}
this.interviewConfig = result.config;
console.log("Interview config auto-loaded", {
version: result.config.version,
profileCount: result.config.profiles.length,
});
return this.interviewConfig;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("not found in vault")) {
new Notice("interview_config.yaml not found. Check the path in plugin settings.");
} else {
new Notice(`Failed to load interview config: ${msg}. Check plugin settings.`);
}
console.error("Failed to load interview config:", e);
return null;
}
}
/**
* Reload interview config from file. Used by manual command and live reload.
*/
private async reloadInterviewConfig(): Promise<void> {
try {
const result = await InterviewConfigLoader.loadConfig(
this.app,
this.settings.interviewConfigPath
);
if (result.errors.length > 0) {
console.warn("Interview config reloaded with errors:", result.errors);
new Notice(`Interview config reloaded with ${result.errors.length} error(s). Check console.`);
} else {
new Notice(`Interview config reloaded: ${result.config.profiles.length} profile(s)`);
}
this.interviewConfig = result.config;
console.log("Interview config reloaded", {
version: result.config.version,
profileCount: result.config.profiles.length,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("not found in vault")) {
new Notice("interview_config.yaml not found. Configure path in plugin settings.");
} else {
new Notice(`Failed to reload interview config: ${msg}`);
}
console.error(e);
}
}
/**
* Ensure graph schema is loaded. Auto-loads if not present.
* Returns GraphSchema instance or null on failure.
*/
public async ensureGraphSchemaLoaded(): Promise<GraphSchema | null> {
if (this.graphSchema) {
return this.graphSchema;
}
try {
this.graphSchema = await GraphSchemaLoader.load(
this.app,
this.settings.graphSchemaPath
);
const ruleCount = this.graphSchema.schema.size;
console.log(`Graph schema auto-loaded: ${ruleCount} source types`);
return this.graphSchema;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("file not found")) {
console.warn("Graph schema not found. Check the path in plugin settings.");
} else {
console.error(`Failed to load graph schema: ${msg}`);
}
return null;
}
}
/**
* Reload graph schema from file. Used by manual command and live reload.
*/
public async reloadGraphSchema(): Promise<void> {
try {
this.graphSchema = await GraphSchemaLoader.load(
this.app,
this.settings.graphSchemaPath
);
const ruleCount = this.graphSchema.schema.size;
console.log(`Graph schema reloaded: ${ruleCount} source types`);
new Notice(`Graph schema reloaded: ${ruleCount} rules`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("not found") || msg.includes("file not found")) {
new Notice("Graph schema not found. Configure path in plugin settings.");
} else {
new Notice(`Failed to reload graph schema: ${msg}`);
}
console.error(e);
this.graphSchema = null; // Clear cache on error
}
}
2022-04-15 20:13:31 +02:00
}