/** * Command: Fix Findings (Current Section) */ import type { App, Editor } from "obsidian"; import { Notice, Modal, Setting } from "obsidian"; import { resolveSectionContext } from "../analysis/sectionContext"; import { inspectChains } from "../analysis/chainInspector"; import type { Finding, InspectorOptions } from "../analysis/chainInspector"; import type { ChainRolesConfig } from "../dictionary/types"; import type { MindnetSettings } from "../settings"; import { EntityPickerModal } from "../ui/EntityPickerModal"; import { ProfileSelectionModal } from "../ui/ProfileSelectionModal"; import type { InterviewConfig } from "../interview/types"; import { writeFrontmatter } from "../interview/writeFrontmatter"; import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "../mapping/folderHelpers"; import { normalizeLinkTarget } from "../unresolvedLink/linkHelpers"; import { startWizardAfterCreate } from "../unresolvedLink/unresolvedLinkHandler"; /** * Execute fix findings command. */ export async function executeFixFindings( app: App, editor: Editor, filePath: string, chainRoles: ChainRolesConfig | null, interviewConfig: InterviewConfig | null, settings: MindnetSettings, pluginInstance?: any ): Promise { try { // Resolve section context const context = resolveSectionContext(editor, filePath); // Inspect chains to get findings const inspectorOptions: InspectorOptions = { includeNoteLinks: true, includeCandidates: false, maxDepth: 3, direction: "both", maxMatchesPerTemplateDefault: settings.maxMatchesPerTemplateDefault, debugLogging: settings.debugLogging, }; const report = await inspectChains( app, context, inspectorOptions, chainRoles, settings.edgeVocabularyPath ); // Filter findings that have fix actions const fixableFindings = report.findings.filter( (f) => f.code === "dangling_target" || f.code === "dangling_target_heading" || f.code === "only_candidates" ); console.log("[Fix Findings] Found", fixableFindings.length, "fixable findings:", fixableFindings.map(f => f.code)); if (fixableFindings.length === 0) { new Notice("No fixable findings found in current section"); return; } // Let user select a finding const selectedFinding = await selectFinding(app, fixableFindings); if (!selectedFinding) { console.log("[Fix Findings] No finding selected, aborting"); return; // User cancelled } // Let user select an action const action = await selectAction(app, selectedFinding); if (!action) { console.log("[Fix Findings] No action selected, aborting"); return; // User cancelled } console.log("[Fix Findings] Applying action", action, "for finding", selectedFinding.code); // Apply action await applyFixAction( app, editor, context, selectedFinding, action, report, interviewConfig, settings, pluginInstance ); new Notice(`Fix action "${action}" applied successfully`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to fix findings: ${msg}`); console.error(e); } } /** * Let user select a finding. */ function selectFinding( app: App, findings: Finding[] ): Promise { return new Promise((resolve) => { console.log("[Fix Findings] Showing finding selection modal with", findings.length, "findings"); let resolved = false; const modal = new FindingSelectionModal(app, findings, (finding) => { console.log("[Fix Findings] Finding selected:", finding.code); if (!resolved) { resolved = true; modal.close(); resolve(finding); } }); modal.onClose = () => { if (!resolved) { console.log("[Fix Findings] Finding selection cancelled"); resolved = true; resolve(null); } }; modal.open(); }); } /** * Let user select an action for a finding. */ function selectAction( app: App, finding: Finding ): Promise { return new Promise((resolve) => { const actions = getAvailableActions(finding); console.log("[Fix Findings] Showing action selection modal for", finding.code, "with actions:", actions); let resolved = false; const modal = new ActionSelectionModal(app, finding, actions, (action) => { console.log("[Fix Findings] Action selected:", action); if (!resolved) { resolved = true; modal.close(); resolve(action); } }); modal.onClose = () => { if (!resolved) { console.log("[Fix Findings] Action selection cancelled"); resolved = true; resolve(null); } }; modal.open(); }); } /** * Get available actions for a finding. */ function getAvailableActions(finding: Finding): string[] { switch (finding.code) { case "dangling_target": return ["create_missing_note", "retarget_link"]; case "dangling_target_heading": return ["create_missing_heading", "retarget_to_heading"]; case "only_candidates": return ["promote_candidate"]; default: return []; } } /** * Apply fix action. */ async function applyFixAction( app: App, editor: Editor, context: any, finding: Finding, action: string, report: any, interviewConfig: InterviewConfig | null, settings: MindnetSettings, pluginInstance?: any ): Promise { console.log(`[Fix Findings] Applying action ${action} for finding ${finding.code}`); try { switch (action) { case "create_missing_note": await createMissingNote( app, finding, report, interviewConfig, settings, pluginInstance ); break; case "retarget_link": console.log(`[Fix Findings] Calling retargetLink...`); await retargetLink(app, editor, finding, report); console.log(`[Fix Findings] retargetLink completed`); break; case "create_missing_heading": await createMissingHeading(app, finding, report, settings); break; case "retarget_to_heading": await retargetToHeading(app, editor, finding, report); break; case "promote_candidate": await promoteCandidate(app, editor, context, finding, report, settings); break; default: throw new Error(`Unknown action: ${action}`); } } catch (e) { console.error(`[Fix Findings] Error applying action ${action}:`, e); throw e; } } /** * Create missing note. */ async function createMissingNote( app: App, finding: Finding, report: any, interviewConfig: InterviewConfig | null, settings: MindnetSettings, pluginInstance?: any ): Promise { // Extract target file from finding message const message = finding.message; const targetMatch = message.match(/Target file does not exist: (.+)/); if (!targetMatch || !targetMatch[1]) { throw new Error("Could not extract target file from finding"); } const targetFile = targetMatch[1]; const mode = settings.fixActions.createMissingNote.mode; const defaultTypeStrategy = settings.fixActions.createMissingNote.defaultTypeStrategy; const includeZones = settings.fixActions.createMissingNote.includeZones; if (mode === "skeleton_only") { // Create skeleton note const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; const title = targetFile.replace(/\.md$/, ""); let noteType = "concept"; // Default fallback if (defaultTypeStrategy === "default_concept_no_prompt") { // Use default from types.yaml or "concept" noteType = "concept"; // TODO: Load from types.yaml if available } else if (defaultTypeStrategy === "profile_picker" || defaultTypeStrategy === "inference_then_picker") { // Will be handled by profile picker noteType = "concept"; // Placeholder, will be overwritten } // Build frontmatter const frontmatterLines = [ "---", `id: ${id}`, `title: ${JSON.stringify(title)}`, `type: ${noteType}`, `status: draft`, `created: ${new Date().toISOString().split("T")[0]}`, "---", ]; // Build content with optional zones let content = frontmatterLines.join("\n") + "\n\n"; if (includeZones === "note_links_only" || includeZones === "both") { content += "## Note-Verbindungen\n\n"; } if (includeZones === "candidates_only" || includeZones === "both") { content += "## Kandidaten\n\n"; } // Determine folder path const folderPath = settings.defaultNotesFolder || ""; if (folderPath) { await ensureFolderExists(app, folderPath); } // Build file path const fileName = `${title}.md`; const desiredPath = joinFolderAndBasename(folderPath, fileName); const filePath = await ensureUniqueFilePath(app, desiredPath); // Create file await app.vault.create(filePath, content); new Notice(`Created skeleton note: ${title}`); } else if (mode === "create_and_open_profile_picker" || mode === "create_and_start_wizard") { // Use profile picker if (!interviewConfig) { throw new Error("Interview config required for profile picker mode"); } const title = targetFile.replace(/\.md$/, ""); const folderPath = settings.defaultNotesFolder || ""; await new Promise((resolve, reject) => { new ProfileSelectionModal( app, interviewConfig, async (result) => { try { // Generate ID 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: interviewConfig.frontmatterWhitelist, }); // Build content let content = `${frontmatter}\n\n`; if (includeZones === "note_links_only" || includeZones === "both") { content += "## Note-Verbindungen\n\n"; } if (includeZones === "candidates_only" || includeZones === "both") { content += "## Kandidaten\n\n"; } // Ensure folder exists const finalFolderPath = result.folderPath || folderPath; if (finalFolderPath) { await ensureFolderExists(app, finalFolderPath); } // Build file path const fileName = `${result.title}.md`; const desiredPath = joinFolderAndBasename(finalFolderPath, fileName); const filePath = await ensureUniqueFilePath(app, desiredPath); // Create file const file = await app.vault.create(filePath, content); // Open file await app.workspace.openLinkText(filePath, "", true); // Start wizard if requested if (mode === "create_and_start_wizard" && pluginInstance) { await startWizardAfterCreate( app, settings, file, result.profile, content, false, async () => { new Notice("Wizard completed"); }, async () => { new Notice("Wizard saved"); }, pluginInstance ); } resolve(); } catch (e) { reject(e); } }, title, folderPath ).open(); }); } } /** * Retarget link to existing note. */ async function retargetLink( app: App, editor: Editor, finding: Finding, report: any ): Promise { console.log(`[Fix Findings] retargetLink called`); // Extract target file from finding message const message = finding.message; console.log(`[Fix Findings] Finding message: "${message}"`); const targetMatch = message.match(/Target file does not exist: (.+)/); if (!targetMatch || !targetMatch[1]) { throw new Error("Could not extract target file from finding"); } const oldTarget = targetMatch[1]; console.log(`[Fix Findings] Extracted oldTarget: "${oldTarget}"`); // Find the edge in the report that matches this finding const edge = findEdgeForFinding(report, finding); if (!edge) { console.error(`[Fix Findings] Could not find edge for finding:`, finding); console.error(`[Fix Findings] Report neighbors.outgoing:`, report.neighbors?.outgoing); throw new Error("Could not find edge for finding"); } console.log(`[Fix Findings] Found edge:`, edge); // Show note picker console.log(`[Fix Findings] Opening EntityPickerModal...`); const { NoteIndex } = await import("../entityPicker/noteIndex"); const noteIndex = new NoteIndex(app); let resolved = false; const selectedNote = await new Promise<{ basename: string; path: string } | null>( (resolve) => { const modal = new EntityPickerModal( app, noteIndex, (result) => { console.log(`[Fix Findings] EntityPickerModal selected:`, result); if (!resolved) { resolved = true; modal.close(); resolve(result); } } ); modal.onClose = () => { if (!resolved) { console.log(`[Fix Findings] EntityPickerModal cancelled`); resolved = true; resolve(null); } }; modal.open(); } ); console.log(`[Fix Findings] Selected note:`, selectedNote); if (!selectedNote) { return; // User cancelled } // Find and replace ALL occurrences of the link in the current section const content = editor.getValue(); const lines = content.split(/\r?\n/); const newTarget = selectedNote.basename.replace(/\.md$/, ""); const heading = edge.target.heading ? `#${edge.target.heading}` : ""; const newLink = `[[${newTarget}${heading}]]`; // Escape oldTarget for regex (but keep it flexible for matching) const escapedOldTarget = oldTarget.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); console.log(`[Fix Findings] Retargeting link:`); console.log(` - oldTarget: "${oldTarget}"`); console.log(` - newTarget: "${newTarget}"`); console.log(` - newLink: "${newLink}"`); console.log(` - report.context:`, report.context); // Build regex pattern that matches: // - [[oldTarget]] // - [[oldTarget#heading]] // - [[oldTarget|alias]] // - [[oldTarget#heading|alias]] const linkPattern = new RegExp( `(\\[\\[)${escapedOldTarget}(#[^\\]|]+)?(\\|[^\\]]+)?(\\]\\])`, "g" ); // Find the current section boundaries const { splitIntoSections } = await import("../mapping/sectionParser"); const sections = splitIntoSections(content); const context = report.context; // Find section that matches the context let sectionStartLine = 0; let sectionEndLine = lines.length; if (context.heading !== null) { // Find section with matching heading for (const section of sections) { if (section.heading === context.heading) { sectionStartLine = section.startLine; sectionEndLine = section.endLine; console.log(`[Fix Findings] Found section "${context.heading}" at lines ${sectionStartLine}-${sectionEndLine}`); break; } } } else { // Root section (before first heading) if (sections.length > 0 && sections[0]) { sectionEndLine = sections[0].startLine; } console.log(`[Fix Findings] Using root section (lines 0-${sectionEndLine})`); } // Replace ALL occurrences in the current section let replacedCount = 0; for (let i = sectionStartLine; i < sectionEndLine && i < lines.length; i++) { const line = lines[i]; if (!line) continue; // Check if line contains the old target if (line.includes(oldTarget) || linkPattern.test(line)) { // Reset regex lastIndex for global match linkPattern.lastIndex = 0; const newLine = line.replace(linkPattern, `$1${newTarget}$2$3$4`); if (newLine !== line) { lines[i] = newLine; replacedCount++; console.log(`[Fix Findings] ✓ Replaced link in line ${i}:`); console.log(` Before: "${line.trim()}"`); console.log(` After: "${newLine.trim()}"`); } } } if (replacedCount === 0) { console.warn(`[Fix Findings] No links found in section, trying full content search`); // Fallback: search entire content linkPattern.lastIndex = 0; const newContent = content.replace(linkPattern, `$1${newTarget}$2$3$4`); if (newContent !== content) { editor.setValue(newContent); console.log(`[Fix Findings] ✓ Replaced link in full content`); return; } else { console.error(`[Fix Findings] ✗ Link not found even in full content search`); throw new Error(`Could not find link "${oldTarget}" in content to replace`); } } // Write back modified lines const newContent = lines.join("\n"); editor.setValue(newContent); console.log(`[Fix Findings] ✓ Link retargeted successfully (${replacedCount} occurrence(s) replaced)`); } /** * Create missing heading in target note. */ async function createMissingHeading( app: App, finding: Finding, report: any, settings: MindnetSettings ): Promise { // Extract target file and heading from finding message const message = finding.message; const match = message.match(/Target heading not found in (.+): (.+)/); if (!match || !match[1] || !match[2]) { throw new Error("Could not extract target file and heading from finding"); } const targetFile = match[1]; const targetHeading = match[2]; // Find the edge const edge = findEdgeForFinding(report, finding); if (!edge) { throw new Error("Could not find edge for finding"); } // Resolve target file const resolvedFile = app.metadataCache.getFirstLinkpathDest( normalizeLinkTarget(targetFile), report.context.file ); if (!resolvedFile) { throw new Error(`Target file not found: ${targetFile}`); } // Read file const content = await app.vault.read(resolvedFile); // Create heading with specified level const level = settings.fixActions.createMissingHeading.level; const headingPrefix = "#".repeat(level); const newHeading = `\n\n${headingPrefix} ${targetHeading}\n`; // Append heading at end of file const newContent = content + newHeading; // Write file await app.vault.modify(resolvedFile, newContent); } /** * Retarget to existing heading. */ async function retargetToHeading( app: App, editor: Editor, finding: Finding, report: any ): Promise { // Extract target file and heading from finding message const message = finding.message; const match = message.match(/Target heading not found in (.+): (.+)/); if (!match || !match[1] || !match[2]) { throw new Error("Could not extract target file and heading from finding"); } const targetFile = match[1]; const oldHeading = match[2]; // Resolve target file const resolvedFile = app.metadataCache.getFirstLinkpathDest( normalizeLinkTarget(targetFile), report.context.file ); if (!resolvedFile) { throw new Error(`Target file not found: ${targetFile}`); } // Get headings from file cache const fileCache = app.metadataCache.getFileCache(resolvedFile); if (!fileCache || !fileCache.headings || fileCache.headings.length === 0) { throw new Error("No headings found in target file"); } // Show heading picker const headings = fileCache.headings.map((h) => h.heading); const selectedHeading = await selectHeading(app, headings, targetFile); if (!selectedHeading) { return; // User cancelled } // Find and replace link in editor const content = editor.getValue(); const oldLinkPattern = new RegExp( `\\[\\[${targetFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}#${oldHeading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]\\]`, "g" ); const newLink = `[[${targetFile}#${selectedHeading}]]`; const newContent = content.replace(oldLinkPattern, newLink); editor.setValue(newContent); } /** * Promote candidate edge to explicit edge. */ async function promoteCandidate( app: App, editor: Editor, context: any, finding: Finding, report: any, settings: MindnetSettings ): Promise { const content = editor.getValue(); // Find candidates zone const candidatesMatch = content.match(/## Kandidaten\n([\s\S]*?)(?=\n## |$)/); if (!candidatesMatch || !candidatesMatch[1]) { throw new Error("Could not find candidates zone"); } const candidatesContent = candidatesMatch[1]; // Parse first candidate edge const edgeMatch = candidatesContent.match( />\s*\[!edge\]\s+(\S+)\s*\n>\s*\[\[([^\]]+)\]\]/ ); if (!edgeMatch || !edgeMatch[1] || !edgeMatch[2]) { throw new Error("Could not find candidate edge to promote"); } const edgeType = edgeMatch[1]; const target = edgeMatch[2]; const fullEdgeMatch = edgeMatch[0]; // Find current section (by heading or root) const headingPattern = context.heading ? new RegExp(`^##\\s+${context.heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m") : /^##\s+/m; const sectionMatch = content.match( new RegExp( `(${context.heading ? `##\\s+${context.heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` : "^[^#]"}[\\s\\S]*?)(?=\\n##|$)`, "m" ) ); if (!sectionMatch || !sectionMatch[1]) { throw new Error("Could not find current section"); } const currentSection = sectionMatch[1]; const sectionStart = sectionMatch.index || 0; // Check if section already has semantic mapping block const hasMappingBlock = currentSection.includes("đŸ•¸ī¸ Semantic Mapping"); let newSectionContent: string; if (hasMappingBlock) { // Add edge to existing mapping block const mappingBlockMatch = currentSection.match( /(>\s*\[!abstract\]-?\s*đŸ•¸ī¸ Semantic Mapping[\s\S]*?)(\n\n|$)/ ); if (mappingBlockMatch && mappingBlockMatch[1]) { const mappingBlock = mappingBlockMatch[1]; const newEdge = `>> [!edge] ${edgeType}\n>> [[${target}]]\n`; const updatedMappingBlock = mappingBlock.trimEnd() + "\n" + newEdge; newSectionContent = currentSection.replace( mappingBlockMatch[0], updatedMappingBlock + "\n\n" ); } else { throw new Error("Could not parse existing mapping block"); } } else { // Create new mapping block at end of section const mappingBlock = `\n\n> [!abstract]- đŸ•¸ī¸ Semantic Mapping\n>> [!edge] ${edgeType}\n>> [[${target}]]\n`; newSectionContent = currentSection.trimEnd() + mappingBlock; } // Replace section in content const beforeSection = content.substring(0, sectionStart); const afterSection = content.substring(sectionStart + currentSection.length); let newContent = beforeSection + newSectionContent + afterSection; // Remove candidate edge if keepOriginal is false if (!settings.fixActions.promoteCandidate.keepOriginal) { const updatedCandidatesContent = candidatesContent.replace( fullEdgeMatch + "\n", "" ).replace(fullEdgeMatch, ""); newContent = newContent.replace( /## Kandidaten\n[\s\S]*?(?=\n## |$)/, `## Kandidaten\n${updatedCandidatesContent}` ); } editor.setValue(newContent); } /** * Find edge in report that matches finding. */ function findEdgeForFinding(report: any, finding: Finding): any { if (finding.code === "dangling_target") { const message = finding.message; const targetMatch = message.match(/Target file does not exist: (.+)/); if (targetMatch && targetMatch[1]) { const targetFile = targetMatch[1]; // Match by exact file or basename return report.neighbors.outgoing.find((e: any) => { const edgeTarget = e.target.file; return ( edgeTarget === targetFile || edgeTarget === targetFile.replace(/\.md$/, "") || edgeTarget.replace(/\.md$/, "") === targetFile || edgeTarget.replace(/\.md$/, "") === targetFile.replace(/\.md$/, "") ); }); } } else if (finding.code === "dangling_target_heading") { const message = finding.message; const match = message.match(/Target heading not found in (.+): (.+)/); if (match && match[1] && match[2]) { const targetFile = match[1]; const targetHeading = match[2]; return report.neighbors.outgoing.find((e: any) => { const edgeTarget = e.target.file; const edgeHeading = e.target.heading; const fileMatches = edgeTarget === targetFile || edgeTarget === targetFile.replace(/\.md$/, "") || edgeTarget.replace(/\.md$/, "") === targetFile; return fileMatches && edgeHeading === targetHeading; }); } } return null; } /** * Simple modal for finding selection. */ class FindingSelectionModal extends Modal { constructor( app: App, findings: Finding[], onSelect: (finding: Finding) => void ) { super(app); this.findings = findings; this.onSelect = onSelect; } private findings: Finding[]; private onSelect: (finding: Finding) => void; onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.createEl("h2", { text: "Select Finding to Fix" }); for (const finding of this.findings) { const severityIcon = finding.severity === "error" ? "❌" : finding.severity === "warn" ? "âš ī¸" : "â„šī¸"; const setting = new Setting(contentEl) .setName(`${severityIcon} ${finding.code}`) .setDesc(finding.message) .addButton((btn) => btn.setButtonText("Fix").setCta().onClick(() => { console.log("[Fix Findings] Fix button clicked for finding:", finding.code); this.onSelect(finding); }) ); } } } /** * Simple modal for action selection. */ class ActionSelectionModal extends Modal { constructor( app: App, finding: Finding, actions: string[], onSelect: (action: string) => void ) { super(app); this.finding = finding; this.actions = actions; this.onSelect = onSelect; } private finding: Finding; private actions: string[]; private onSelect: (action: string) => void; onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.createEl("h2", { text: "Select Action" }); contentEl.createEl("p", { text: `Finding: ${this.finding.message}` }); const actionLabels: Record = { create_missing_note: "Create Missing Note", retarget_link: "Retarget Link to Existing Note", create_missing_heading: "Create Missing Heading", retarget_to_heading: "Retarget to Existing Heading", promote_candidate: "Promote Candidate Edge", }; for (const action of this.actions) { const setting = new Setting(contentEl) .setName(actionLabels[action] || action) .addButton((btn) => btn.setButtonText("Apply").setCta().onClick(() => { console.log("[Fix Findings] Apply button clicked for action:", action); this.onSelect(action); }) ); } } } /** * Select heading from list. */ function selectHeading( app: App, headings: string[], targetFile: string ): Promise { return new Promise((resolve) => { let resolved = false; const modal = new HeadingSelectionModal(app, headings, targetFile, (heading) => { if (!resolved) { resolved = true; modal.close(); resolve(heading); } }); modal.onClose = () => { if (!resolved) { resolved = true; resolve(null); } }; modal.open(); }); } /** * Simple modal for heading selection. */ class HeadingSelectionModal extends Modal { constructor( app: App, headings: string[], targetFile: string, onSelect: (heading: string) => void ) { super(app); this.headings = headings; this.targetFile = targetFile; this.onSelect = onSelect; } private headings: string[]; private targetFile: string; private onSelect: (heading: string) => void; onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.createEl("h2", { text: "Select Heading" }); contentEl.createEl("p", { text: `File: ${this.targetFile}` }); for (const heading of this.headings) { const setting = new Setting(contentEl) .setName(heading) .addButton((btn) => btn.setButtonText("Select").onClick(() => { this.onSelect(heading); }) ); } } }