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"; import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers"; import { extractLinkTargetFromAnchor, isUnresolvedLink, waitForFileModify, parseWikilinkAtPosition, normalizeLinkTarget, } from "./unresolvedLink/linkHelpers"; import { isBypassModifierPressed, startWizardAfterCreate, type UnresolvedLinkClickContext, } from "./unresolvedLink/unresolvedLinkHandler"; import { isAdoptCandidate, matchesPendingHint, mergeFrontmatter, evaluateAdoptionConfidence, type PendingCreateHint, } from "./unresolvedLink/adoptHelpers"; import { AdoptNoteModal } from "./ui/AdoptNoteModal"; import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "./mapping/edgeTypeSelector"; import { ChainRolesLoader } from "./dictionary/ChainRolesLoader"; import { ChainTemplatesLoader } from "./dictionary/ChainTemplatesLoader"; import type { ChainRolesConfig, ChainTemplatesConfig, DictionaryLoadResult } from "./dictionary/types"; import { executeInspectChains } from "./commands/inspectChainsCommand"; import { executeFixFindings } from "./commands/fixFindingsCommand"; export default class MindnetCausalAssistantPlugin extends Plugin { settings: MindnetSettings; private vocabulary: Vocabulary | null = null; private reloadDebounceTimer: number | null = null; private pendingCreateHint: PendingCreateHint | null = null; private interviewConfig: InterviewConfig | null = null; private interviewConfigReloadDebounceTimer: number | null = null; private graphSchema: GraphSchema | null = null; private graphSchemaReloadDebounceTimer: number | null = null; private chainRoles: { data: ChainRolesConfig | null; loadedAt: number | null; result: DictionaryLoadResult | null } = { data: null, loadedAt: null, result: null, }; private chainRolesReloadDebounceTimer: number | null = null; private chainTemplates: { data: ChainTemplatesConfig | null; loadedAt: number | null; result: DictionaryLoadResult | null } = { data: null, loadedAt: null, result: null, }; private chainTemplatesReloadDebounceTimer: number | null = null; async onload(): Promise { await this.loadSettings(); // Add settings tab this.addSettingTab(new MindnetSettingTab(this.app, this)); // Register unresolved link handlers for Reading View and Live Preview if (this.settings.interceptUnresolvedLinkClicks) { this.registerUnresolvedLinkHandlers(); } // Register vault create handler for adopting new notes (after layout ready to avoid startup events) if (this.settings.adoptNewNotesInEditor) { this.app.workspace.onLayoutReady(() => { this.registerEvent( this.app.vault.on("create", async (file: TFile) => { await this.handleFileCreate(file); }) ); }); } // 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); } // Check if modified file matches chain roles path const normalizedChainRolesPath = normalizeVaultPath(this.settings.chainRolesPath); if (normalizedFilePath === normalizedChainRolesPath || normalizedFilePath === `/${normalizedChainRolesPath}` || normalizedFilePath.endsWith(`/${normalizedChainRolesPath}`)) { // Debounce reload if (this.chainRolesReloadDebounceTimer !== null) { window.clearTimeout(this.chainRolesReloadDebounceTimer); } this.chainRolesReloadDebounceTimer = window.setTimeout(async () => { await this.reloadChainRoles(); this.chainRolesReloadDebounceTimer = null; }, 200); } // Check if modified file matches chain templates path const normalizedChainTemplatesPath = normalizeVaultPath(this.settings.chainTemplatesPath); if (normalizedFilePath === normalizedChainTemplatesPath || normalizedFilePath === `/${normalizedChainTemplatesPath}` || normalizedFilePath.endsWith(`/${normalizedChainTemplatesPath}`)) { // Debounce reload if (this.chainTemplatesReloadDebounceTimer !== null) { window.clearTimeout(this.chainTemplatesReloadDebounceTimer); } this.chainTemplatesReloadDebounceTimer = window.setTimeout(async () => { await this.reloadChainTemplates(); this.chainTemplatesReloadDebounceTimer = 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 = this.settings.exportPath || "_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"; // Ensure output directory exists const pathParts = reportPath.split("/"); if (pathParts.length > 1) { const directoryPath = pathParts.slice(0, -1).join("/"); await ensureFolderExists(this.app, directoryPath); } 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 } // Determine default folder (from profile defaults or settings) const defaultFolder = ""; // Will be set per profile selection // Show modal new ProfileSelectionModal( this.app, config, async (result) => { await this.createNoteFromProfile(result); }, initialTitle, defaultFolder ).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-change-edge-type", name: "Mindnet: Edge-Type ändern", editorCallback: async (editor) => { try { console.log("[Main] Edge-Type ändern command called"); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile || activeFile.extension !== "md") { new Notice("Bitte öffnen Sie eine Markdown-Datei"); return; } console.log("[Main] Active file:", activeFile.path); const content = editor.getValue(); console.log("[Main] Content length:", content.length); const context = detectEdgeSelectorContext(editor, content); if (!context) { console.warn("[Main] Context could not be detected"); new Notice("Kontext konnte nicht erkannt werden"); return; } console.log("[Main] Context detected:", context.mode); await changeEdgeTypeForLinks( this.app, editor, activeFile, this.settings, context, { ensureGraphSchemaLoaded: () => this.ensureGraphSchemaLoaded() } ); console.log("[Main] changeEdgeTypeForLinks completed"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[Main] Error in edge-type command:", e); new Notice(`Fehler beim Ändern des Edge-Types: ${msg}`); } }, }); this.addCommand({ id: "mindnet-debug-chain-roles", name: "Mindnet: Debug Chain Roles (Loaded)", callback: async () => { try { await this.ensureChainRolesLoaded(); const result = this.chainRoles.result; if (!result) { new Notice("Chain roles not loaded yet"); console.log("Chain roles: not loaded"); return; } const output = this.formatDebugOutput(result, "Chain Roles"); console.log("=== Chain Roles Debug ==="); console.log(output); new Notice("Chain roles debug info logged to console (F12)"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to debug chain roles: ${msg}`); console.error(e); } }, }); this.addCommand({ id: "mindnet-debug-chain-templates", name: "Mindnet: Debug Chain Templates (Loaded)", callback: async () => { try { await this.ensureChainTemplatesLoaded(); const result = this.chainTemplates.result; if (!result) { new Notice("Chain templates not loaded yet"); console.log("Chain templates: not loaded"); return; } const output = this.formatDebugOutput(result, "Chain Templates"); console.log("=== Chain Templates Debug ==="); console.log(output); new Notice("Chain templates debug info logged to console (F12)"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to debug chain templates: ${msg}`); console.error(e); } }, }); this.addCommand({ id: "mindnet-fix-findings", name: "Mindnet: Fix Findings (Current Section)", editorCallback: async (editor) => { 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; } // Ensure chain roles and interview config are loaded await this.ensureChainRolesLoaded(); const chainRoles = this.chainRoles.data; const interviewConfig = await this.ensureInterviewConfigLoaded(); await executeFixFindings( this.app, editor, activeFile.path, chainRoles, interviewConfig, this.settings, this ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to fix findings: ${msg}`); console.error(e); } }, }); this.addCommand({ id: "mindnet-inspect-chains", name: "Mindnet: Inspect Chains (Current Section)", editorCallback: async (editor) => { 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; } // Ensure chain roles and templates are loaded await this.ensureChainRolesLoaded(); const chainRoles = this.chainRoles.data; await this.ensureChainTemplatesLoaded(); const chainTemplates = this.chainTemplates.data; const templatesLoadResult = this.chainTemplates.result; await executeInspectChains( this.app, editor, activeFile.path, chainRoles, this.settings, {}, chainTemplates, templatesLoadResult || undefined ); new Notice("Chain inspection complete. Check console (F12) for report."); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to inspect chains: ${msg}`); console.error(e); } }, }); this.addCommand({ id: "mindnet-build-semantic-mappings", name: "Mindnet: Build semantic mapping blocks (by section)", editorCallback: async (editor) => { try { console.log("[Main] Build semantic mapping blocks command called"); 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 context first - if there's a selection or cursor in link, use edge-type selector const content = editor.getValue(); const context = detectEdgeSelectorContext(editor, content); if (context && (context.mode === "single-link" || context.mode === "selection-links" || context.mode === "create-link")) { // Use edge-type selector for specific links or create new link console.log("[Main] Using edge-type selector for context:", context.mode); await changeEdgeTypeForLinks( this.app, editor, activeFile, this.settings, context, { ensureGraphSchemaLoaded: () => this.ensureGraphSchemaLoaded() } ); return; } // Otherwise, process whole note console.log("[Main] Processing whole note"); // 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; folderPath?: string }, preferredBasename?: string, folderPath?: string, isUnresolvedClick?: boolean ): 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 folder path (from result, parameter, profile defaults, or settings) const finalFolderPath = folderPath || result.folderPath || (result.profile.defaults?.folder as string) || this.settings.defaultNotesFolder || ""; // Ensure folder exists if (finalFolderPath) { await ensureFolderExists(this.app, finalFolderPath); } // Build file path const fileName = `${filenameBase.trim()}.md`; const desiredPath = joinFolderAndBasename(finalFolderPath, fileName); // Ensure unique file path const filePath = await ensureUniqueFilePath(this.app, desiredPath); // 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}`); // Start wizard with Templater compatibility await startWizardAfterCreate( this.app, this.settings, file, result.profile, content, isUnresolvedClick || false, async (wizardResult: WizardResult) => { new Notice("Interview completed and changes applied"); }, async (wizardResult: WizardResult) => { new Notice("Interview saved and changes applied"); }, this // Pass plugin instance for graph schema loading ); } 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); } /** * Register unresolved link handlers for Reading View and Live Preview. */ private registerUnresolvedLinkHandlers(): void { // Reading View: Markdown Post Processor this.registerMarkdownPostProcessor((el, ctx) => { if (!this.settings.interceptUnresolvedLinkClicks) { return; } const sourcePath = ctx.sourcePath; const links = Array.from(el.querySelectorAll("a.internal-link")); for (const link of links) { if (!(link instanceof HTMLElement)) continue; // Check if already processed (avoid duplicate listeners) if (link.dataset.mindnetProcessed === "true") continue; link.dataset.mindnetProcessed = "true"; // Extract link target const linkTarget = extractLinkTargetFromAnchor(link); if (!linkTarget) continue; // Check if unresolved const unresolved = isUnresolvedLink(this.app, linkTarget, sourcePath); if (!unresolved) continue; // Add click handler link.addEventListener("click", async (evt: MouseEvent) => { // Check bypass modifier if ( isBypassModifierPressed(evt, this.settings.bypassModifier) ) { if (this.settings.debugLogging) { console.log("[Mindnet] Bypass modifier pressed, skipping intercept"); } return; // Let Obsidian handle it } evt.preventDefault(); evt.stopPropagation(); 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; } const context: UnresolvedLinkClickContext = { mode: "reading", linkText: linkTarget, sourcePath: sourcePath, resolved: false, }; if (this.settings.debugLogging) { console.log("[Mindnet] Unresolved link click (Reading View):", context); } // Open profile selection new ProfileSelectionModal( this.app, config, async (result) => { await this.createNoteFromProfileAndOpen( result, linkTarget, result.folderPath, true // isUnresolvedClick ); }, linkTarget, "" ).open(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to handle link click: ${msg}`); console.error("[Mindnet] Failed to handle link click:", e); } }); } }); // Live Preview / Source Mode: CodeMirror-based click handler this.registerDomEvent(document, "click", async (evt: MouseEvent) => { if (!this.settings.interceptUnresolvedLinkClicks) { return; } // Check if we're in a markdown editor view const activeView = this.app.workspace.getActiveViewOfType( require("obsidian").MarkdownView ); if (!activeView) { return; // Not in a markdown view } // Get editor instance (works for both Live Preview and Source mode) const editor = (activeView as any).editor; if (!editor || !editor.cm) { return; // No CodeMirror editor available } const view = editor.cm; if (!view) return; // Check if editor follow modifier is pressed const modifierPressed = isBypassModifierPressed( evt, this.settings.editorFollowModifier ); if (!modifierPressed) { return; // Modifier not pressed, don't intercept } // Get position from mouse coordinates const pos = view.posAtCoords({ x: evt.clientX, y: evt.clientY }); if (pos === null) { return; } // Get line at position const line = view.state.doc.lineAt(pos); const lineText = line.text; const posInLine = pos - line.from; // Parse wikilink at cursor position const rawTarget = parseWikilinkAtPosition(lineText, posInLine); if (!rawTarget) { return; // No wikilink found at cursor } // Normalize target (remove alias/heading) const linkTarget = normalizeLinkTarget(rawTarget); if (!linkTarget) { return; } // Get source path const activeFile = this.app.workspace.getActiveFile(); const sourcePath = activeFile?.path || ""; // Check if unresolved const unresolved = isUnresolvedLink(this.app, linkTarget, sourcePath); if (!unresolved) { return; // Link is resolved, don't intercept } // Prevent default evt.preventDefault(); evt.stopPropagation(); 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; } const context: UnresolvedLinkClickContext = { mode: "live", linkText: linkTarget, sourcePath: sourcePath, resolved: false, }; if (this.settings.debugLogging) { console.log("[Mindnet] Unresolved link click (Editor):", context); } // Set pending create hint for correlation with vault create event if (this.settings.adoptNewNotesInEditor) { this.pendingCreateHint = { basename: linkTarget, sourcePath: sourcePath, timestamp: Date.now(), }; } // Open profile selection new ProfileSelectionModal( this.app, config, async (result) => { await this.createNoteFromProfileAndOpen( result, linkTarget, result.folderPath, true // isUnresolvedClick ); }, linkTarget, "" ).open(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to handle link click: ${msg}`); console.error("[Mindnet] Failed to handle link click:", e); } }); } /** * Handle file create event for adopting new notes. */ private async handleFileCreate(file: TFile): Promise { // Only consider markdown files if (file.extension !== "md") { return; } // Ignore files under .obsidian/ if (file.path.startsWith(".obsidian/")) { return; } try { // Read file content const content = await this.app.vault.cachedRead(file); // Check if adopt candidate if (!isAdoptCandidate(content, this.settings.adoptMaxChars)) { if (this.settings.debugLogging) { console.log(`[Mindnet] File ${file.path} is not an adopt candidate (too large or has id)`); } return; } // Evaluate confidence level const confidence = evaluateAdoptionConfidence( file, content, this.settings.adoptMaxChars, this.pendingCreateHint, this.settings.highConfidenceWindowMs ); if (this.settings.debugLogging) { console.log(`[Mindnet] Adopt candidate detected: ${file.path}`, { confidence, pendingHint: this.pendingCreateHint, fileCtime: file.stat.ctime, timeSinceCreation: Date.now() - file.stat.ctime, }); } // Clear pending hint (used once) this.pendingCreateHint = null; // Determine if we need to show confirmation const shouldShowConfirm = this.shouldShowAdoptionConfirm(confidence); if (shouldShowConfirm) { const adoptModal = new AdoptNoteModal(this.app, file.basename); const result = await adoptModal.show(); if (!result.adopt) { return; } } // Load interview config 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 new ProfileSelectionModal( this.app, config, async (result) => { await this.adoptNote(file, result, content); }, file.basename, "" ).open(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (this.settings.debugLogging) { console.error(`[Mindnet] Failed to handle file create: ${msg}`, e); } } } /** * Determine if adoption confirmation should be shown based on mode and confidence. */ private shouldShowAdoptionConfirm(confidence: "high" | "low"): boolean { if (this.settings.adoptConfirmMode === "always") { return true; } if (this.settings.adoptConfirmMode === "never") { return false; } // onlyLowConfidence: show only for low confidence return confidence === "low"; } /** * Adopt a note: convert to Mindnet format and start wizard. */ private async adoptNote( file: TFile, result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath: string }, existingContent: string ): Promise { try { // Generate unique ID const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; // Write frontmatter const newFrontmatter = writeFrontmatter({ id, title: result.title, noteType: result.profile.note_type, interviewProfile: result.profile.key, defaults: result.profile.defaults, frontmatterWhitelist: (await this.ensureInterviewConfigLoaded())?.frontmatterWhitelist || [], }); // Merge frontmatter into existing content const mergedContent = mergeFrontmatter(existingContent, newFrontmatter); // Determine folder path const finalFolderPath = result.folderPath || (result.profile.defaults?.folder as string) || this.settings.defaultNotesFolder || ""; // Move file if folder changed let targetFile = file; if (finalFolderPath && file.parent?.path !== finalFolderPath) { const newPath = joinFolderAndBasename(finalFolderPath, `${file.basename}.md`); await ensureFolderExists(this.app, finalFolderPath); await this.app.vault.rename(file, newPath); const renamedFile = this.app.vault.getAbstractFileByPath(newPath); if (renamedFile instanceof TFile) { targetFile = renamedFile; } } // Write merged content await this.app.vault.modify(targetFile, mergedContent); // Open file await this.app.workspace.openLinkText(file.path, "", true); // Show notice new Notice(`Note adopted: ${result.title}`); // Start wizard await startWizardAfterCreate( this.app, this.settings, targetFile, result.profile, mergedContent, true, // isUnresolvedClick async (wizardResult: WizardResult) => { new Notice("Interview completed and changes applied"); }, async (wizardResult: WizardResult) => { new Notice("Interview saved and changes applied"); }, this // Pass plugin instance for graph schema loading ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to adopt note: ${msg}`); console.error("[Mindnet] Failed to adopt note:", e); } } 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); } } /** * Ensure graph schema is loaded. Auto-loads if not present. * Returns GraphSchema instance or null on failure. */ public async ensureGraphSchemaLoaded(): Promise { 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 { 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 } } /** * Ensure chain roles are loaded. Auto-loads if not present. */ private async ensureChainRolesLoaded(): Promise { if (this.chainRoles.result && this.chainRoles.data !== null) { return; } const lastKnownGood = { data: this.chainRoles.data, loadedAt: this.chainRoles.loadedAt, }; const result = await ChainRolesLoader.load( this.app, this.settings.chainRolesPath, lastKnownGood ); this.chainRoles.result = result; if (result.data !== null) { this.chainRoles.data = result.data; this.chainRoles.loadedAt = result.loadedAt; } if (result.errors.length > 0) { console.warn("Chain roles loaded with errors:", result.errors); } if (result.warnings.length > 0) { console.warn("Chain roles loaded with warnings:", result.warnings); } } /** * Reload chain roles from file. Used by manual command and live reload. */ private async reloadChainRoles(): Promise { const lastKnownGood = { data: this.chainRoles.data, loadedAt: this.chainRoles.loadedAt, }; const result = await ChainRolesLoader.load( this.app, this.settings.chainRolesPath, lastKnownGood ); this.chainRoles.result = result; if (result.data !== null) { this.chainRoles.data = result.data; this.chainRoles.loadedAt = result.loadedAt; } if (result.status === "loaded") { const roleCount = Object.keys(result.data?.roles || {}).length; console.log(`Chain roles reloaded: ${roleCount} roles`); new Notice(`Chain roles reloaded: ${roleCount} roles`); } else if (result.status === "using-last-known-good") { console.warn("Chain roles reload failed, using last-known-good:", result.errors); new Notice(`Chain roles reload failed (using last-known-good). Check console.`); } else { console.error("Chain roles reload failed:", result.errors); new Notice(`Chain roles reload failed. Check console.`); } } /** * Ensure chain templates are loaded. Auto-loads if not present. */ private async ensureChainTemplatesLoaded(): Promise { if (this.chainTemplates.result && this.chainTemplates.data !== null) { return; } const lastKnownGood = { data: this.chainTemplates.data, loadedAt: this.chainTemplates.loadedAt, }; const result = await ChainTemplatesLoader.load( this.app, this.settings.chainTemplatesPath, lastKnownGood ); this.chainTemplates.result = result; if (result.data !== null) { this.chainTemplates.data = result.data; this.chainTemplates.loadedAt = result.loadedAt; } if (result.errors.length > 0) { console.warn("Chain templates loaded with errors:", result.errors); } if (result.warnings.length > 0) { console.warn("Chain templates loaded with warnings:", result.warnings); } } /** * Reload chain templates from file. Used by manual command and live reload. */ private async reloadChainTemplates(): Promise { const lastKnownGood = { data: this.chainTemplates.data, loadedAt: this.chainTemplates.loadedAt, }; const result = await ChainTemplatesLoader.load( this.app, this.settings.chainTemplatesPath, lastKnownGood ); this.chainTemplates.result = result; if (result.data !== null) { this.chainTemplates.data = result.data; this.chainTemplates.loadedAt = result.loadedAt; } if (result.status === "loaded") { const templateCount = result.data?.templates?.length || 0; console.log(`Chain templates reloaded: ${templateCount} templates`); new Notice(`Chain templates reloaded: ${templateCount} templates`); } else if (result.status === "using-last-known-good") { console.warn("Chain templates reload failed, using last-known-good:", result.errors); new Notice(`Chain templates reload failed (using last-known-good). Check console.`); } else { console.error("Chain templates reload failed:", result.errors); new Notice(`Chain templates reload failed. Check console.`); } } /** * Format debug output with stable ordering (alphabetical keys). */ private formatDebugOutput( result: DictionaryLoadResult, title: string ): string { const lines: string[] = []; lines.push(`${title} Debug Output`); lines.push("=".repeat(50)); lines.push(`Resolved Path: ${result.resolvedPath}`); lines.push(`Status: ${result.status}`); lines.push(`Loaded At: ${result.loadedAt ? new Date(result.loadedAt).toISOString() : "null"}`); if (result.errors.length > 0) { lines.push(`Errors (${result.errors.length}):`); for (const err of result.errors) { lines.push(` - ${err}`); } } if (result.warnings.length > 0) { lines.push(`Warnings (${result.warnings.length}):`); for (const warn of result.warnings) { lines.push(` - ${warn}`); } } if (result.data) { if ("roles" in result.data) { // ChainRolesConfig const roles = result.data.roles; const roleKeys = Object.keys(roles).sort(); // Stable alphabetical order lines.push(`Roles (${roleKeys.length}):`); for (const roleKey of roleKeys) { const role = roles[roleKey]; if (role) { const edgeTypesCount = role.edge_types?.length || 0; lines.push(` - ${roleKey}: ${edgeTypesCount} edge types`); } } } else if ("templates" in result.data) { // ChainTemplatesConfig const templates = result.data.templates; lines.push(`Templates (${templates.length}):`); for (const template of templates) { const slotsCount = template.slots?.length || 0; lines.push(` - ${template.name}: ${slotsCount} slots`); } } } else { lines.push("Data: null"); } return lines.join("\n"); } }