mindnet_obsidian/src/main.ts
Lars 90ccec5f7d
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Implement findings fixing and template matching features in Mindnet plugin
- Added a new command to fix findings in the current section of a markdown file, enhancing user experience by automating issue resolution.
- Introduced settings for configuring actions related to missing notes and headings, allowing for customizable behavior during the fixing process.
- Enhanced the chain inspector to support template matching, providing users with insights into template utilization and potential gaps in their content.
- Updated the analysis report to include detailed metadata about edges and role matches, improving the clarity and usefulness of inspection results.
- Improved error handling and user notifications for fixing findings and template matching processes, ensuring better feedback during execution.
2026-01-18 21:10:33 +01:00

1555 lines
52 KiB
TypeScript

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<ChainRolesConfig> | null } = {
data: null,
loadedAt: null,
result: null,
};
private chainRolesReloadDebounceTimer: number | null = null;
private chainTemplates: { data: ChainTemplatesConfig | null; loadedAt: number | null; result: DictionaryLoadResult<ChainTemplatesConfig> | null } = {
data: null,
loadedAt: null,
result: null,
};
private chainTemplatesReloadDebounceTimer: number | null = null;
async onload(): Promise<void> {
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: <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";
// 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<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
}
// 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<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 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<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);
}
/**
* 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<void> {
// 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<void> {
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<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
}
}
/**
* Ensure chain roles are loaded. Auto-loads if not present.
*/
private async ensureChainRolesLoaded(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<T extends ChainRolesConfig | ChainTemplatesConfig>(
result: DictionaryLoadResult<T>,
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");
}
}