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"; 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; async onload(): Promise { 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); } }) ); 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: ' 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(); 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 ); // 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 { 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 { // 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 { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings(): Promise { 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 { 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 { 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 { 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 { 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); } } }