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