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.
This commit is contained in:
parent
556145e76d
commit
90eafb62f4
501
src/main.ts
501
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<void> {
|
||||
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<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 {
|
||||
// nothing yet
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
148
src/tests/unresolvedLink/adoptHelpers.test.ts
Normal file
148
src/tests/unresolvedLink/adoptHelpers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
183
src/tests/unresolvedLink/linkHelpers.test.ts
Normal file
183
src/tests/unresolvedLink/linkHelpers.test.ts
Normal 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
71
src/ui/AdoptNoteModal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
146
src/unresolvedLink/adoptHelpers.ts
Normal file
146
src/unresolvedLink/adoptHelpers.ts
Normal 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;
|
||||
}
|
||||
160
src/unresolvedLink/linkHelpers.ts
Normal file
160
src/unresolvedLink/linkHelpers.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
116
src/unresolvedLink/unresolvedLinkHandler.ts
Normal file
116
src/unresolvedLink/unresolvedLinkHandler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user