Implement unresolved link handling and note adoption features
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Added functionality to intercept unresolved link clicks in both Reading View and Live Preview, allowing users to create notes directly from unresolved links.
- Introduced settings for auto-starting interviews on unresolved link clicks, bypass modifiers for link interception, and options for adopting newly created notes in the editor.
- Enhanced the settings interface with new options for managing unresolved link behavior and note adoption criteria.
- Implemented a file create handler to automatically adopt new notes based on content length and user confirmation.
- Improved error handling and user notifications throughout the new features for a better user experience.
This commit is contained in:
Lars 2026-01-17 10:07:07 +01:00
parent 556145e76d
commit 90eafb62f4
10 changed files with 1395 additions and 85 deletions

View File

@ -23,11 +23,31 @@ import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal";
import { GraphSchemaLoader } from "./schema/GraphSchemaLoader"; import { GraphSchemaLoader } from "./schema/GraphSchemaLoader";
import type { GraphSchema } from "./mapping/graphSchema"; import type { GraphSchema } from "./mapping/graphSchema";
import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers"; 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,
type PendingCreateHint,
} from "./unresolvedLink/adoptHelpers";
import { AdoptNoteModal } from "./ui/AdoptNoteModal";
export default class MindnetCausalAssistantPlugin extends Plugin { export default class MindnetCausalAssistantPlugin extends Plugin {
settings: MindnetSettings; settings: MindnetSettings;
private vocabulary: Vocabulary | null = null; private vocabulary: Vocabulary | null = null;
private reloadDebounceTimer: number | null = null; private reloadDebounceTimer: number | null = null;
private pendingCreateHint: PendingCreateHint | null = null;
private interviewConfig: InterviewConfig | null = null; private interviewConfig: InterviewConfig | null = null;
private interviewConfigReloadDebounceTimer: number | null = null; private interviewConfigReloadDebounceTimer: number | null = null;
private graphSchema: GraphSchema | null = null; private graphSchema: GraphSchema | null = null;
@ -39,66 +59,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
// Add settings tab // Add settings tab
this.addSettingTab(new MindnetSettingTab(this.app, this)); this.addSettingTab(new MindnetSettingTab(this.app, this));
// Register click handler for unresolved links // Register unresolved link handlers for Reading View and Live Preview
this.registerDomEvent(document, "click", async (evt: MouseEvent) => { if (this.settings.interceptUnresolvedLinkClicks) {
if (!this.settings.interceptUnresolvedLinkClicks) { this.registerUnresolvedLinkHandlers();
return;
} }
const target = evt.target as HTMLElement; // Register vault create handler for adopting new notes (after layout ready to avoid startup events)
if (!target) return; if (this.settings.adoptNewNotesInEditor) {
this.app.workspace.onLayoutReady(() => {
// Find closest unresolved internal link this.registerEvent(
const anchor = target.closest("a.internal-link.is-unresolved"); this.app.vault.on("create", async (file: TFile) => {
if (!anchor || !(anchor instanceof HTMLElement)) { await this.handleFileCreate(file);
return; })
} );
// Prevent default link behavior
evt.preventDefault();
evt.stopPropagation();
// Extract target basename (preserves spaces and case)
const basename = extractTargetFromAnchor(anchor);
if (!basename) {
return;
}
// Use basename directly as title (Option 1: exact match)
const title = basename;
// Load interview config and open profile selection
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;
}
// Determine default folder (from profile defaults or settings)
const defaultFolder = ""; // Will be set per profile selection
// Open profile selection modal
new ProfileSelectionModal(
this.app,
config,
async (result) => {
await this.createNoteFromProfileAndOpen(result, basename, result.folderPath);
},
title,
defaultFolder
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to handle link click: ${msg}`);
console.error(e);
}
}); });
}
// Register live reload for edge vocabulary file // Register live reload for edge vocabulary file
this.registerEvent( this.registerEvent(
@ -440,7 +415,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
private async createNoteFromProfileAndOpen( private async createNoteFromProfileAndOpen(
result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string }, result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string },
preferredBasename?: string, preferredBasename?: string,
folderPath?: string folderPath?: string,
isUnresolvedClick?: boolean
): Promise<void> { ): Promise<void> {
try { try {
const config = await this.ensureInterviewConfigLoaded(); const config = await this.ensureInterviewConfigLoaded();
@ -499,32 +475,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
// Show notice // Show notice
new Notice(`Note created: ${result.title}`); new Notice(`Note created: ${result.title}`);
// Auto-start interview if setting enabled // Start wizard with Templater compatibility
if (this.settings.autoStartInterviewOnCreate) { await startWizardAfterCreate(
try {
console.log("Start wizard", {
profileKey: result.profile.key,
file: file.path,
});
new InterviewWizardModal(
this.app, this.app,
result.profile, this.settings,
file, file,
result.profile,
content, content,
isUnresolvedClick || false,
async (wizardResult: WizardResult) => { async (wizardResult: WizardResult) => {
new Notice("Interview completed and changes applied"); new Notice("Interview completed and changes applied");
}, },
async (wizardResult: WizardResult) => { async (wizardResult: WizardResult) => {
new Notice("Interview saved and changes applied"); new Notice("Interview saved and changes applied");
} }
).open(); );
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to start interview wizard: ${msg}`);
console.error("Failed to start wizard:", e);
}
}
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to create note: ${msg}`); new Notice(`Failed to create note: ${msg}`);
@ -540,6 +505,372 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
return this.createNoteFromProfileAndOpen(result, result.title); 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;
}
// Check if matches pending hint (high confidence)
const highConfidence = matchesPendingHint(file, this.pendingCreateHint);
if (this.settings.debugLogging) {
console.log(`[Mindnet] Adopt candidate detected: ${file.path}`, {
highConfidence,
pendingHint: this.pendingCreateHint,
});
}
// Clear pending hint (used once)
this.pendingCreateHint = null;
// Show confirm modal if not high confidence
if (!highConfidence) {
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);
}
}
}
/**
* 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");
}
);
} 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 { onunload(): void {
// nothing yet // nothing yet
} }

View File

@ -8,6 +8,14 @@ export interface MindnetSettings {
interviewConfigPath: string; // vault-relativ interviewConfigPath: string; // vault-relativ
autoStartInterviewOnCreate: boolean; autoStartInterviewOnCreate: boolean;
interceptUnresolvedLinkClicks: boolean; interceptUnresolvedLinkClicks: boolean;
autoStartOnUnresolvedClick: boolean; // Auto-start interview when creating note from unresolved link
bypassModifier: "Alt" | "Ctrl" | "Shift" | "None"; // Modifier key to bypass intercept (Reading View)
editorFollowModifier: "Alt" | "Ctrl" | "Shift" | "None"; // Modifier key required for editor intercept (Live Preview/Source)
waitForFirstModifyAfterCreate: boolean; // Wait for Templater to modify file before starting wizard
waitForModifyTimeoutMs: number; // Timeout in ms for waiting for modify event
debugLogging: boolean; // Enable debug logging for unresolved link handling
adoptNewNotesInEditor: boolean; // Auto-adopt newly created notes in editor
adoptMaxChars: number; // Max content length to consider note as adopt-candidate
// Semantic mapping builder settings // Semantic mapping builder settings
mappingWrapperCalloutType: string; // default: "abstract" mappingWrapperCalloutType: string; // default: "abstract"
mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping" mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping"
@ -28,6 +36,14 @@ export interface MindnetSettings {
interviewConfigPath: "_system/dictionary/interview_config.yaml", interviewConfigPath: "_system/dictionary/interview_config.yaml",
autoStartInterviewOnCreate: false, autoStartInterviewOnCreate: false,
interceptUnresolvedLinkClicks: true, interceptUnresolvedLinkClicks: true,
autoStartOnUnresolvedClick: true,
bypassModifier: "Alt",
editorFollowModifier: "Ctrl",
waitForFirstModifyAfterCreate: true,
waitForModifyTimeoutMs: 1200,
debugLogging: false,
adoptNewNotesInEditor: true,
adoptMaxChars: 200,
mappingWrapperCalloutType: "abstract", mappingWrapperCalloutType: "abstract",
mappingWrapperTitle: "🕸️ Semantic Mapping", mappingWrapperTitle: "🕸️ Semantic Mapping",
mappingWrapperFolded: true, mappingWrapperFolded: true,

View File

@ -99,4 +99,19 @@ describe("extractTargetFromData", () => {
const result = extractTargetFromData(null, " test-note "); const result = extractTargetFromData(null, " test-note ");
expect(result).toBe("test-note"); expect(result).toBe("test-note");
}); });
it("handles Live Preview format (data-href with spaces)", () => {
const result = extractTargetFromData("My Live Preview Note", null);
expect(result).toBe("My Live Preview Note");
});
it("handles Live Preview format with alias", () => {
const result = extractTargetFromData("My Note|Display Text", null);
expect(result).toBe("My Note");
});
it("handles Live Preview format with heading", () => {
const result = extractTargetFromData("My Note#Section", null);
expect(result).toBe("My Note");
});
}); });

View File

@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import {
isAdoptCandidate,
matchesPendingHint,
mergeFrontmatter,
type PendingCreateHint,
} from "../../unresolvedLink/adoptHelpers";
import { TFile } from "obsidian";
describe("isAdoptCandidate", () => {
it("returns true for empty content", () => {
expect(isAdoptCandidate("", 200)).toBe(true);
expect(isAdoptCandidate(" ", 200)).toBe(true);
});
it("returns true for content without frontmatter id", () => {
expect(isAdoptCandidate("Some text", 200)).toBe(true);
expect(isAdoptCandidate("Some text\nwith multiple lines", 200)).toBe(true);
});
it("returns true for content with frontmatter but no id", () => {
const content = `---
title: Test
---
Body content`;
expect(isAdoptCandidate(content, 200)).toBe(true);
});
it("returns false for content with id", () => {
const content = `---
id: note_123
title: Test
---
Body`;
expect(isAdoptCandidate(content, 200)).toBe(false);
});
it("returns false for content exceeding maxChars", () => {
const longContent = "x".repeat(300);
expect(isAdoptCandidate(longContent, 200)).toBe(false);
});
it("returns true for content within maxChars even with frontmatter", () => {
const content = `---
title: Test
---
Short body`;
expect(isAdoptCandidate(content, 200)).toBe(true);
});
});
describe("matchesPendingHint", () => {
it("returns true when basename matches and within time window", () => {
const file = { basename: "test-note" } as TFile;
const hint: PendingCreateHint = {
basename: "test-note",
sourcePath: "source.md",
timestamp: Date.now() - 1000, // 1 second ago
};
expect(matchesPendingHint(file, hint, 3000)).toBe(true);
});
it("returns false when basename doesn't match", () => {
const file = { basename: "other-note" } as TFile;
const hint: PendingCreateHint = {
basename: "test-note",
sourcePath: "source.md",
timestamp: Date.now() - 1000,
};
expect(matchesPendingHint(file, hint, 3000)).toBe(false);
});
it("returns false when time window exceeded", () => {
const file = { basename: "test-note" } as TFile;
const hint: PendingCreateHint = {
basename: "test-note",
sourcePath: "source.md",
timestamp: Date.now() - 5000, // 5 seconds ago
};
expect(matchesPendingHint(file, hint, 3000)).toBe(false);
});
it("returns false when hint is null", () => {
const file = { basename: "test-note" } as TFile;
expect(matchesPendingHint(file, null, 3000)).toBe(false);
});
});
describe("mergeFrontmatter", () => {
it("prepends frontmatter to content without existing frontmatter", () => {
const content = "Body content";
const frontmatter = `---
id: note_123
title: Test
---`;
const result = mergeFrontmatter(content, frontmatter);
expect(result).toContain("id: note_123");
expect(result).toContain("title: Test");
expect(result).toContain("Body content");
});
it("merges with existing frontmatter preserving existing fields", () => {
const content = `---
title: Existing
custom: value
---
Body content`;
const frontmatter = `---
id: note_123
title: New
type: note
---`;
const result = mergeFrontmatter(content, frontmatter);
expect(result).toContain("id: note_123");
expect(result).toContain("title: Existing"); // Existing takes precedence
expect(result).toContain("type: note");
expect(result).toContain("custom: value");
expect(result).toContain("Body content");
});
it("preserves body content when merging", () => {
const content = `---
title: Test
---
Line 1
Line 2
Line 3`;
const frontmatter = `---
id: note_123
---`;
const result = mergeFrontmatter(content, frontmatter);
expect(result).toContain("Line 1");
expect(result).toContain("Line 2");
expect(result).toContain("Line 3");
});
it("handles empty body", () => {
const content = `---
title: Test
---`;
const frontmatter = `---
id: note_123
---`;
const result = mergeFrontmatter(content, frontmatter);
expect(result).toContain("id: note_123");
expect(result).toContain("title: Test");
});
});

View File

@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { normalizeLinkTarget, isUnresolvedLink, waitForFileModify, parseWikilinkAtPosition } from "../../unresolvedLink/linkHelpers";
import { App, TFile } from "obsidian";
describe("normalizeLinkTarget", () => {
it("preserves simple basename", () => {
expect(normalizeLinkTarget("test-note")).toBe("test-note");
});
it("preserves spaces and case", () => {
expect(normalizeLinkTarget("My Note")).toBe("My Note");
expect(normalizeLinkTarget("My Test Note")).toBe("My Test Note");
});
it("removes alias separator", () => {
expect(normalizeLinkTarget("test-note|Alias Text")).toBe("test-note");
expect(normalizeLinkTarget("My Note|Display Name")).toBe("My Note");
});
it("removes heading separator", () => {
expect(normalizeLinkTarget("test-note#Section")).toBe("test-note");
expect(normalizeLinkTarget("My Note#Section")).toBe("My Note");
});
it("removes alias and heading", () => {
expect(normalizeLinkTarget("test-note#Section|Alias")).toBe("test-note");
expect(normalizeLinkTarget("My Note#Section|Alias")).toBe("My Note");
});
it("trims whitespace", () => {
expect(normalizeLinkTarget(" test-note ")).toBe("test-note");
expect(normalizeLinkTarget(" My Note ")).toBe("My Note");
});
it("handles empty string", () => {
expect(normalizeLinkTarget("")).toBe("");
});
});
describe("parseWikilinkAtPosition", () => {
it("finds wikilink at cursor position", () => {
const line = "This is a [[test-note]] link";
const result = parseWikilinkAtPosition(line, 15); // Position inside [[test-note]]
expect(result).toBe("test-note");
});
it("finds wikilink with alias", () => {
const line = "This is a [[test-note|Display Text]] link";
const result = parseWikilinkAtPosition(line, 15);
expect(result).toBe("test-note|Display Text");
});
it("finds wikilink with heading", () => {
const line = "This is a [[test-note#Section]] link";
const result = parseWikilinkAtPosition(line, 15);
expect(result).toBe("test-note#Section");
});
it("finds nearest wikilink when cursor is outside", () => {
const line = "This is a [[test-note]] link";
const result = parseWikilinkAtPosition(line, 5); // Position before link
expect(result).toBe("test-note");
});
it("returns null when no wikilink found", () => {
const line = "This is plain text";
const result = parseWikilinkAtPosition(line, 5);
expect(result).toBe(null);
});
it("returns null when cursor is too far from link", () => {
const line = "This is a [[test-note]] link";
const result = parseWikilinkAtPosition(line, 0); // Position at start, too far
expect(result).toBe(null);
});
it("handles multiple wikilinks and finds the correct one", () => {
const line = "[[first]] and [[second]] links";
const result1 = parseWikilinkAtPosition(line, 5); // Near first
const result2 = parseWikilinkAtPosition(line, 20); // Near second
expect(result1).toBe("first");
expect(result2).toBe("second");
});
});
describe("isUnresolvedLink", () => {
let mockApp: Partial<App>;
let mockMetadataCache: any;
beforeEach(() => {
mockMetadataCache = {
getFirstLinkpathDest: vi.fn(),
};
mockApp = {
metadataCache: mockMetadataCache as any,
};
});
it("returns true when file is not found", () => {
mockMetadataCache.getFirstLinkpathDest.mockReturnValue(null);
const result = isUnresolvedLink(mockApp as App, "test-note", "source.md");
expect(result).toBe(true);
expect(mockMetadataCache.getFirstLinkpathDest).toHaveBeenCalledWith("test-note", "source.md");
});
it("returns false when file is found", () => {
const mockFile = { path: "test-note.md" } as TFile;
mockMetadataCache.getFirstLinkpathDest.mockReturnValue(mockFile);
const result = isUnresolvedLink(mockApp as App, "test-note", "source.md");
expect(result).toBe(false);
});
it("returns false for empty target", () => {
const result = isUnresolvedLink(mockApp as App, "", "source.md");
expect(result).toBe(false);
expect(mockMetadataCache.getFirstLinkpathDest).not.toHaveBeenCalled();
});
});
describe("waitForFileModify", () => {
let mockApp: Partial<App>;
let mockVault: any;
let mockFile: TFile;
let modifyHandler: ((file: TFile) => void) | null;
beforeEach(() => {
modifyHandler = null;
mockFile = { path: "test.md" } as TFile;
mockVault = {
on: vi.fn((event: string, fn: any) => {
if (event === "modify") {
modifyHandler = fn;
}
}),
off: vi.fn(),
};
mockApp = {
vault: mockVault as any,
};
});
it("resolves with 'modified' when file modify event fires", async () => {
const promise = waitForFileModify(mockApp as App, mockFile, 1000);
// Simulate modify event immediately (before timeout)
if (modifyHandler) {
modifyHandler(mockFile);
}
const result = await promise;
expect(result).toBe("modified");
expect(mockVault.off).toHaveBeenCalled();
});
it("resolves with 'timeout' when timeout expires", async () => {
// Don't trigger modify handler - simulate no modify event
const promise = waitForFileModify(mockApp as App, mockFile, 50);
// Wait for timeout (should be ~50ms)
const result = await promise;
expect(result).toBe("timeout");
expect(mockVault.off).toHaveBeenCalled();
}, 200); // Increase test timeout
it("ignores modify events for other files", async () => {
const otherFile = { path: "other.md" } as TFile;
const promise = waitForFileModify(mockApp as App, mockFile, 50);
// Simulate modify event for different file (should be ignored)
if (modifyHandler) {
modifyHandler(otherFile);
}
// Wait for timeout (other file modify should not trigger resolution)
const result = await promise;
expect(result).toBe("timeout"); // Should timeout because other file was modified
expect(mockVault.off).toHaveBeenCalled();
}, 200); // Increase test timeout
});

71
src/ui/AdoptNoteModal.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* Modal for confirming adoption of a newly created note.
*/
import { Modal, Notice } from "obsidian";
export interface AdoptNoteResult {
adopt: boolean;
}
export class AdoptNoteModal extends Modal {
private result: AdoptNoteResult | null = null;
private resolve: ((result: AdoptNoteResult) => void) | null = null;
private fileName: string;
constructor(app: any, fileName: string) {
super(app);
this.fileName = fileName;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Convert to Mindnet note?" });
contentEl.createEl("p", {
text: `The note "${this.fileName}" appears to be newly created. Would you like to convert it to a Mindnet note?`,
});
const buttonContainer = contentEl.createEl("div");
buttonContainer.style.display = "flex";
buttonContainer.style.gap = "0.5em";
buttonContainer.style.justifyContent = "flex-end";
buttonContainer.style.marginTop = "1em";
const noBtn = buttonContainer.createEl("button", { text: "No" });
noBtn.onclick = () => {
this.result = { adopt: false };
this.close();
};
const yesBtn = buttonContainer.createEl("button", {
text: "Yes",
cls: "mod-cta",
});
yesBtn.onclick = () => {
this.result = { adopt: true };
this.close();
};
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
if (this.resolve) {
this.resolve(this.result || { adopt: false });
}
}
/**
* Show modal and return promise that resolves with user's choice.
*/
async show(): Promise<AdoptNoteResult> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
}

View File

@ -223,6 +223,130 @@ export class MindnetSettingTab extends PluginSettingTab {
}) })
); );
// Auto-start interview on unresolved click toggle
new Setting(containerEl)
.setName("Auto-start interview on unresolved link click")
.setDesc("Automatically start interview wizard when creating note from unresolved link")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoStartOnUnresolvedClick)
.onChange(async (value) => {
this.plugin.settings.autoStartOnUnresolvedClick = value;
await this.plugin.saveSettings();
})
);
// Bypass modifier (Reading View)
new Setting(containerEl)
.setName("Bypass modifier (Reading View)")
.setDesc("Modifier key to bypass unresolved link intercept in Reading View (Alt/Ctrl/Shift/None)")
.addDropdown((dropdown) =>
dropdown
.addOption("Alt", "Alt")
.addOption("Ctrl", "Ctrl/Cmd")
.addOption("Shift", "Shift")
.addOption("None", "None")
.setValue(this.plugin.settings.bypassModifier)
.onChange(async (value) => {
if (value === "Alt" || value === "Ctrl" || value === "Shift" || value === "None") {
this.plugin.settings.bypassModifier = value;
await this.plugin.saveSettings();
}
})
);
// Editor follow modifier (Live Preview/Source)
new Setting(containerEl)
.setName("Editor follow modifier (Live Preview/Source)")
.setDesc("Modifier key required to intercept unresolved links in editor (Alt/Ctrl/Shift/None). Default: Ctrl/Cmd")
.addDropdown((dropdown) =>
dropdown
.addOption("Alt", "Alt")
.addOption("Ctrl", "Ctrl/Cmd")
.addOption("Shift", "Shift")
.addOption("None", "None")
.setValue(this.plugin.settings.editorFollowModifier)
.onChange(async (value) => {
if (value === "Alt" || value === "Ctrl" || value === "Shift" || value === "None") {
this.plugin.settings.editorFollowModifier = value;
await this.plugin.saveSettings();
}
})
);
// Wait for first modify after create
new Setting(containerEl)
.setName("Wait for first modify after create")
.setDesc("Wait for Templater to modify file before starting wizard (recommended if using Templater)")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.waitForFirstModifyAfterCreate)
.onChange(async (value) => {
this.plugin.settings.waitForFirstModifyAfterCreate = value;
await this.plugin.saveSettings();
})
);
// Modify timeout
new Setting(containerEl)
.setName("Modify timeout (ms)")
.setDesc("Timeout in milliseconds for waiting for file modify event")
.addText((text) =>
text
.setPlaceholder("1200")
.setValue(String(this.plugin.settings.waitForModifyTimeoutMs))
.onChange(async (value) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.waitForModifyTimeoutMs = numValue;
await this.plugin.saveSettings();
}
})
);
// Debug logging
new Setting(containerEl)
.setName("Debug logging")
.setDesc("Enable debug logging for unresolved link handling")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.debugLogging)
.onChange(async (value) => {
this.plugin.settings.debugLogging = value;
await this.plugin.saveSettings();
})
);
// Adopt new notes in editor
new Setting(containerEl)
.setName("Adopt new notes in editor")
.setDesc("Automatically adopt newly created notes in editor and convert to Mindnet format")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.adoptNewNotesInEditor)
.onChange(async (value) => {
this.plugin.settings.adoptNewNotesInEditor = value;
await this.plugin.saveSettings();
})
);
// Adopt max chars
new Setting(containerEl)
.setName("Adopt max chars")
.setDesc("Maximum content length to consider a note as adopt-candidate (default: 200)")
.addText((text) =>
text
.setPlaceholder("200")
.setValue(String(this.plugin.settings.adoptMaxChars))
.onChange(async (value) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.adoptMaxChars = numValue;
await this.plugin.saveSettings();
}
})
);
// Semantic Mapping Builder section // Semantic Mapping Builder section
containerEl.createEl("h2", { text: "Semantic Mapping Builder" }); containerEl.createEl("h2", { text: "Semantic Mapping Builder" });

View File

@ -0,0 +1,146 @@
/**
* Helper functions for adopting newly created notes.
*/
import { TFile } from "obsidian";
import { extractFrontmatterId } from "../parser/parseFrontmatter";
export interface PendingCreateHint {
basename: string;
sourcePath: string;
timestamp: number;
}
/**
* Check if a file is a candidate for adoption.
* A file is adoptable if:
* - Content is empty/whitespace OR content length < maxChars
* - Frontmatter has no "id" field
*/
export function isAdoptCandidate(
content: string,
maxChars: number
): boolean {
// Check content length
const trimmedContent = content.trim();
if (trimmedContent.length > maxChars) {
return false;
}
// Check if frontmatter has id
const frontmatterId = extractFrontmatterId(content);
if (frontmatterId) {
return false; // Already has id, not a fresh note
}
return true;
}
/**
* Check if a created file matches a pending create hint.
* Returns true if basename matches and within time window (3 seconds).
*/
export function matchesPendingHint(
file: TFile,
hint: PendingCreateHint | null,
timeWindowMs: number = 3000
): boolean {
if (!hint) return false;
const fileBasename = file.basename;
const timeDiff = Date.now() - hint.timestamp;
return (
fileBasename === hint.basename &&
timeDiff >= 0 &&
timeDiff <= timeWindowMs
);
}
/**
* Merge new frontmatter into existing content.
* Preserves existing frontmatter fields and body content.
*/
export function mergeFrontmatter(
existingContent: string,
newFrontmatter: string
): string {
const lines = existingContent.split(/\r?\n/);
// Check if file has existing frontmatter
if (lines.length > 0 && lines[0]?.trim() === "---") {
// Find closing delimiter
let endIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i]?.trim() === "---") {
endIndex = i;
break;
}
}
if (endIndex !== -1) {
// Parse existing frontmatter
const existingFrontmatterLines = lines.slice(1, endIndex);
const existingFrontmatterText = existingFrontmatterLines.join("\n");
const bodyLines = lines.slice(endIndex + 1);
// Parse new frontmatter (remove --- delimiters)
const newFrontmatterLines = newFrontmatter.split(/\r?\n/);
const newFrontmatterText = newFrontmatterLines
.filter((line) => line.trim() !== "---")
.join("\n");
// Merge: existing fields take precedence, new fields are added
const merged = mergeYamlFields(existingFrontmatterText, newFrontmatterText);
// Reconstruct content
return `---\n${merged}\n---\n${bodyLines.join("\n")}`;
}
}
// No existing frontmatter, just prepend new one
const body = existingContent.trim();
if (body) {
return `${newFrontmatter}\n\n${body}`;
}
return newFrontmatter;
}
/**
* Merge two YAML field sets.
* Existing fields take precedence over new fields.
*/
function mergeYamlFields(existing: string, newFields: string): string {
const existingMap = parseYamlFields(existing);
const newMap = parseYamlFields(newFields);
// Merge: new fields only if not in existing
const merged = { ...newMap, ...existingMap };
// Convert back to YAML
const lines: string[] = [];
for (const [key, value] of Object.entries(merged)) {
lines.push(`${key}: ${value}`);
}
return lines.join("\n");
}
/**
* Simple YAML field parser.
* Returns map of key: value pairs.
*/
function parseYamlFields(yamlText: string): Record<string, string> {
const map: Record<string, string> = {};
const lines = yamlText.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^([^:]+):\s*(.+)$/);
if (match && match[1] && match[2]) {
const key = match[1].trim();
const value = match[2].trim();
map[key] = value;
}
}
return map;
}

View File

@ -0,0 +1,160 @@
/**
* Helper functions for unresolved link detection and handling.
*/
import { App, TFile } from "obsidian";
/**
* Normalize link target by removing alias separator (|) and heading separator (#).
* Preserves spaces and case.
* Examples:
* - "file#sec|alias" -> "file"
* - "My Note" -> "My Note"
* - "My Note|Display" -> "My Note"
*/
export function normalizeLinkTarget(raw: string): string {
if (!raw) return "";
// Split by pipe (alias separator) and take first part
const parts = raw.split("|");
const firstPart = parts[0];
if (!firstPart) return raw.trim();
// Split by hash (heading separator) and take first part
const baseParts = firstPart.split("#");
const base = baseParts[0];
if (!base) return raw.trim();
return base.trim();
}
/**
* Extract link target from an anchor element.
* Prefers data-href attribute, falls back to textContent.
* Returns normalized target (without alias/heading).
*/
export function extractLinkTargetFromAnchor(anchor: HTMLElement): string | null {
const dataHref = anchor.getAttribute("data-href");
const textContent = anchor.textContent;
// Try data-href first
if (dataHref) {
return normalizeLinkTarget(dataHref);
}
// Fall back to text content
if (textContent) {
return normalizeLinkTarget(textContent.trim());
}
return null;
}
/**
* Check if a link target is unresolved (file doesn't exist).
* Uses metadataCache as single source of truth.
*/
export function isUnresolvedLink(
app: App,
target: string,
sourcePath: string
): boolean {
if (!target) return false;
const resolvedFile = app.metadataCache.getFirstLinkpathDest(target, sourcePath);
return resolvedFile === null;
}
/**
* Parse wikilink at cursor position in a line of text.
* Finds the nearest [[...]] that contains or is near the cursor position.
* Returns the raw target (with alias/heading) or null if not found.
*/
export function parseWikilinkAtPosition(
lineText: string,
cursorPosInLine: number
): string | null {
if (!lineText || cursorPosInLine < 0) return null;
// Find all [[...]] pairs in the line
const linkPattern = /\[\[([^\]]+)\]\]/g;
const matches: Array<{ start: number; end: number; target: string }> = [];
let match;
while ((match = linkPattern.exec(lineText)) !== null) {
const start = match.index;
const end = match.index + match[0].length;
const target = match[1]; // Content between [[ and ]]
if (target) {
matches.push({ start, end, target });
}
}
if (matches.length === 0) return null;
// Find the link that contains the cursor or is closest to it
for (const link of matches) {
if (cursorPosInLine >= link.start && cursorPosInLine <= link.end) {
// Cursor is inside this link
return link.target;
}
}
// If cursor is not inside any link, find the nearest one
let nearestLink: { start: number; end: number; target: string } | null = null;
let minDistance = Infinity;
for (const link of matches) {
// Distance to link start or end, whichever is closer
const distToStart = Math.abs(cursorPosInLine - link.start);
const distToEnd = Math.abs(cursorPosInLine - link.end);
const distance = Math.min(distToStart, distToEnd);
if (distance < minDistance) {
minDistance = distance;
nearestLink = link;
}
}
// Only return if reasonably close (e.g., within 10 characters)
if (nearestLink && minDistance <= 10) {
return nearestLink.target;
}
return null;
}
/**
* Wait for a file to be modified after creation, or timeout.
* Returns "modified" if modify event fired, "timeout" otherwise.
* Useful for Templater compatibility.
*/
export function waitForFileModify(
app: App,
file: TFile,
timeoutMs: number
): Promise<"modified" | "timeout"> {
return new Promise((resolve) => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
app.vault.off("modify", handler);
resolve("timeout");
}
}, timeoutMs);
const handler = (modifiedFile: TFile) => {
if (modifiedFile.path === file.path && !resolved) {
resolved = true;
clearTimeout(timeout);
app.vault.off("modify", handler);
resolve("modified");
}
};
app.vault.on("modify", handler);
});
}

View File

@ -0,0 +1,116 @@
/**
* Unresolved link click handler for Reading View and Live Preview.
*/
import { App, MarkdownPostProcessorContext, Notice } from "obsidian";
import type { InterviewConfig } from "../interview/types";
import { ProfileSelectionModal } from "../ui/ProfileSelectionModal";
import {
extractLinkTargetFromAnchor,
isUnresolvedLink,
waitForFileModify,
} from "./linkHelpers";
import type { MindnetSettings } from "../settings";
import { TFile } from "obsidian";
export interface UnresolvedLinkClickContext {
mode: "reading" | "live";
linkText: string;
sourcePath: string;
resolved: boolean;
}
/**
* Check if bypass modifier is pressed.
*/
export function isBypassModifierPressed(
evt: MouseEvent,
bypassModifier: "Alt" | "Ctrl" | "Shift" | "None"
): boolean {
if (bypassModifier === "None") return false;
if (bypassModifier === "Alt") return evt.altKey;
if (bypassModifier === "Ctrl") return evt.ctrlKey || evt.metaKey;
if (bypassModifier === "Shift") return evt.shiftKey;
return false;
}
/**
* Start wizard after note creation, with optional Templater wait.
*/
export async function startWizardAfterCreate(
app: App,
settings: MindnetSettings,
file: TFile,
profile: any,
content: string,
isUnresolvedClick: boolean,
onWizardComplete: (result: any) => void,
onWizardSave: (result: any) => void
): Promise<void> {
// Determine if wizard should start
const shouldStartInterview = isUnresolvedClick
? (settings.autoStartOnUnresolvedClick || settings.autoStartInterviewOnCreate)
: settings.autoStartInterviewOnCreate;
if (!shouldStartInterview) {
if (settings.debugLogging) {
console.log("[Mindnet] Wizard start skipped (settings)");
}
return;
}
// Wait for Templater if enabled
if (settings.waitForFirstModifyAfterCreate) {
if (settings.debugLogging) {
console.log(
`[Mindnet] Waiting for file modify (timeout: ${settings.waitForModifyTimeoutMs}ms)`
);
}
const result = await waitForFileModify(
app,
file,
settings.waitForModifyTimeoutMs
);
if (settings.debugLogging) {
console.log(`[Mindnet] File modify wait result: ${result}`);
}
// Reload file content in case Templater modified it
if (result === "modified") {
content = await app.vault.read(file);
}
}
// Start wizard
try {
if (settings.debugLogging) {
console.log("[Mindnet] Starting wizard", {
profileKey: profile.key,
file: file.path,
wizardStartReason: isUnresolvedClick
? settings.autoStartOnUnresolvedClick
? "autoStartOnUnresolvedClick"
: "autoStartInterviewOnCreate"
: "autoStartInterviewOnCreate",
});
}
const { InterviewWizardModal } = await import("../ui/InterviewWizardModal");
new InterviewWizardModal(
app,
profile,
file,
content,
onWizardComplete,
onWizardSave
).open();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`Failed to start interview wizard: ${msg}`);
console.error("[Mindnet] Failed to start wizard:", e);
}
}