From 90eafb62f426ace742fbc5f7c85212b5bc9fb5cd Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 17 Jan 2026 10:07:07 +0100 Subject: [PATCH] Implement unresolved link handling and note adoption features - 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. --- src/main.ts | 501 +++++++++++++++--- src/settings.ts | 16 + .../interview/extractTargetFromAnchor.test.ts | 15 + src/tests/unresolvedLink/adoptHelpers.test.ts | 148 ++++++ src/tests/unresolvedLink/linkHelpers.test.ts | 183 +++++++ src/ui/AdoptNoteModal.ts | 71 +++ src/ui/MindnetSettingTab.ts | 124 +++++ src/unresolvedLink/adoptHelpers.ts | 146 +++++ src/unresolvedLink/linkHelpers.ts | 160 ++++++ src/unresolvedLink/unresolvedLinkHandler.ts | 116 ++++ 10 files changed, 1395 insertions(+), 85 deletions(-) create mode 100644 src/tests/unresolvedLink/adoptHelpers.test.ts create mode 100644 src/tests/unresolvedLink/linkHelpers.test.ts create mode 100644 src/ui/AdoptNoteModal.ts create mode 100644 src/unresolvedLink/adoptHelpers.ts create mode 100644 src/unresolvedLink/linkHelpers.ts create mode 100644 src/unresolvedLink/unresolvedLinkHandler.ts diff --git a/src/main.ts b/src/main.ts index e26dd94..9c05a5a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,11 +23,31 @@ import { ConfirmOverwriteModal } from "./ui/ConfirmOverwriteModal"; import { GraphSchemaLoader } from "./schema/GraphSchemaLoader"; import type { GraphSchema } from "./mapping/graphSchema"; import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers"; +import { + extractLinkTargetFromAnchor, + isUnresolvedLink, + waitForFileModify, + parseWikilinkAtPosition, + normalizeLinkTarget, +} from "./unresolvedLink/linkHelpers"; +import { + isBypassModifierPressed, + startWizardAfterCreate, + type UnresolvedLinkClickContext, +} from "./unresolvedLink/unresolvedLinkHandler"; +import { + isAdoptCandidate, + matchesPendingHint, + mergeFrontmatter, + type PendingCreateHint, +} from "./unresolvedLink/adoptHelpers"; +import { AdoptNoteModal } from "./ui/AdoptNoteModal"; export default class MindnetCausalAssistantPlugin extends Plugin { settings: MindnetSettings; private vocabulary: Vocabulary | null = null; private reloadDebounceTimer: number | null = null; + private pendingCreateHint: PendingCreateHint | null = null; private interviewConfig: InterviewConfig | null = null; private interviewConfigReloadDebounceTimer: number | null = null; private graphSchema: GraphSchema | null = null; @@ -39,66 +59,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin { // Add settings tab this.addSettingTab(new MindnetSettingTab(this.app, this)); - // Register click handler for unresolved links - this.registerDomEvent(document, "click", async (evt: MouseEvent) => { - if (!this.settings.interceptUnresolvedLinkClicks) { - return; - } + // Register unresolved link handlers for Reading View and Live Preview + if (this.settings.interceptUnresolvedLinkClicks) { + this.registerUnresolvedLinkHandlers(); + } - const target = evt.target as HTMLElement; - if (!target) return; - - // Find closest unresolved internal link - const anchor = target.closest("a.internal-link.is-unresolved"); - if (!anchor || !(anchor instanceof HTMLElement)) { - 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 vault create handler for adopting new notes (after layout ready to avoid startup events) + if (this.settings.adoptNewNotesInEditor) { + this.app.workspace.onLayoutReady(() => { + this.registerEvent( + this.app.vault.on("create", async (file: TFile) => { + await this.handleFileCreate(file); + }) + ); + }); + } // Register live reload for edge vocabulary file this.registerEvent( @@ -440,7 +415,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin { private async createNoteFromProfileAndOpen( result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string }, preferredBasename?: string, - folderPath?: string + folderPath?: string, + isUnresolvedClick?: boolean ): Promise { try { const config = await this.ensureInterviewConfigLoaded(); @@ -499,32 +475,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin { // Show notice new Notice(`Note created: ${result.title}`); - // Auto-start interview if setting enabled - if (this.settings.autoStartInterviewOnCreate) { - try { - console.log("Start wizard", { - profileKey: result.profile.key, - file: file.path, - }); - - new InterviewWizardModal( - this.app, - result.profile, - file, - content, - async (wizardResult: WizardResult) => { - new Notice("Interview completed and changes applied"); - }, - async (wizardResult: WizardResult) => { - 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); + // Start wizard with Templater compatibility + await startWizardAfterCreate( + this.app, + this.settings, + file, + result.profile, + content, + isUnresolvedClick || false, + async (wizardResult: WizardResult) => { + new Notice("Interview completed and changes applied"); + }, + async (wizardResult: WizardResult) => { + new Notice("Interview saved and changes applied"); } - } + ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); new Notice(`Failed to create note: ${msg}`); @@ -540,6 +505,372 @@ export default class MindnetCausalAssistantPlugin extends Plugin { 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 { + // 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 { + 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 { // nothing yet } diff --git a/src/settings.ts b/src/settings.ts index 9da1708..febea56 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -8,6 +8,14 @@ export interface MindnetSettings { interviewConfigPath: string; // vault-relativ autoStartInterviewOnCreate: 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 mappingWrapperCalloutType: string; // default: "abstract" mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping" @@ -28,6 +36,14 @@ export interface MindnetSettings { interviewConfigPath: "_system/dictionary/interview_config.yaml", autoStartInterviewOnCreate: false, interceptUnresolvedLinkClicks: true, + autoStartOnUnresolvedClick: true, + bypassModifier: "Alt", + editorFollowModifier: "Ctrl", + waitForFirstModifyAfterCreate: true, + waitForModifyTimeoutMs: 1200, + debugLogging: false, + adoptNewNotesInEditor: true, + adoptMaxChars: 200, mappingWrapperCalloutType: "abstract", mappingWrapperTitle: "🕸️ Semantic Mapping", mappingWrapperFolded: true, diff --git a/src/tests/interview/extractTargetFromAnchor.test.ts b/src/tests/interview/extractTargetFromAnchor.test.ts index 1543132..e7c9e4e 100644 --- a/src/tests/interview/extractTargetFromAnchor.test.ts +++ b/src/tests/interview/extractTargetFromAnchor.test.ts @@ -99,4 +99,19 @@ describe("extractTargetFromData", () => { const result = extractTargetFromData(null, " 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"); + }); }); diff --git a/src/tests/unresolvedLink/adoptHelpers.test.ts b/src/tests/unresolvedLink/adoptHelpers.test.ts new file mode 100644 index 0000000..820a541 --- /dev/null +++ b/src/tests/unresolvedLink/adoptHelpers.test.ts @@ -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"); + }); +}); diff --git a/src/tests/unresolvedLink/linkHelpers.test.ts b/src/tests/unresolvedLink/linkHelpers.test.ts new file mode 100644 index 0000000..c6532e8 --- /dev/null +++ b/src/tests/unresolvedLink/linkHelpers.test.ts @@ -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; + 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; + 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 +}); diff --git a/src/ui/AdoptNoteModal.ts b/src/ui/AdoptNoteModal.ts new file mode 100644 index 0000000..1deddfa --- /dev/null +++ b/src/ui/AdoptNoteModal.ts @@ -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 { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } +} diff --git a/src/ui/MindnetSettingTab.ts b/src/ui/MindnetSettingTab.ts index dff971e..7e05183 100644 --- a/src/ui/MindnetSettingTab.ts +++ b/src/ui/MindnetSettingTab.ts @@ -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 containerEl.createEl("h2", { text: "Semantic Mapping Builder" }); diff --git a/src/unresolvedLink/adoptHelpers.ts b/src/unresolvedLink/adoptHelpers.ts new file mode 100644 index 0000000..224968a --- /dev/null +++ b/src/unresolvedLink/adoptHelpers.ts @@ -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 { + const map: Record = {}; + 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; +} diff --git a/src/unresolvedLink/linkHelpers.ts b/src/unresolvedLink/linkHelpers.ts new file mode 100644 index 0000000..f9ea476 --- /dev/null +++ b/src/unresolvedLink/linkHelpers.ts @@ -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); + }); +} diff --git a/src/unresolvedLink/unresolvedLinkHandler.ts b/src/unresolvedLink/unresolvedLinkHandler.ts new file mode 100644 index 0000000..76c3f84 --- /dev/null +++ b/src/unresolvedLink/unresolvedLinkHandler.ts @@ -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 { + // 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); + } +}