- Introduced a wide two-column layout for the Chain Workbench modal, improving user experience and accessibility. - Added new styles for workbench components, including headers, filters, and main containers, to enhance visual organization. - Updated chain templates to allow for multiple distinct matches per template, improving flexibility in template matching. - Enhanced documentation to clarify the new settings and commands related to the Chain Workbench and edge detection features. - Implemented logging for better tracking of missing configurations, ensuring users are informed about any loading issues.
943 lines
28 KiB
TypeScript
943 lines
28 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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<Finding | null> {
|
||
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<string | null> {
|
||
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<void> {
|
||
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<void> {
|
||
// 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<void>((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<void> {
|
||
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<void> {
|
||
// 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<void> {
|
||
// 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<void> {
|
||
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<string, string> = {
|
||
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<string | null> {
|
||
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);
|
||
})
|
||
);
|
||
}
|
||
}
|
||
}
|