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 { GraphSchemaLoader } from "./schema/GraphSchemaLoader";
|
||||||
import type { GraphSchema } from "./mapping/graphSchema";
|
import type { GraphSchema } from "./mapping/graphSchema";
|
||||||
import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers";
|
import { joinFolderAndBasename, ensureUniqueFilePath, ensureFolderExists } from "./mapping/folderHelpers";
|
||||||
|
import {
|
||||||
|
extractLinkTargetFromAnchor,
|
||||||
|
isUnresolvedLink,
|
||||||
|
waitForFileModify,
|
||||||
|
parseWikilinkAtPosition,
|
||||||
|
normalizeLinkTarget,
|
||||||
|
} from "./unresolvedLink/linkHelpers";
|
||||||
|
import {
|
||||||
|
isBypassModifierPressed,
|
||||||
|
startWizardAfterCreate,
|
||||||
|
type UnresolvedLinkClickContext,
|
||||||
|
} from "./unresolvedLink/unresolvedLinkHandler";
|
||||||
|
import {
|
||||||
|
isAdoptCandidate,
|
||||||
|
matchesPendingHint,
|
||||||
|
mergeFrontmatter,
|
||||||
|
type PendingCreateHint,
|
||||||
|
} from "./unresolvedLink/adoptHelpers";
|
||||||
|
import { AdoptNoteModal } from "./ui/AdoptNoteModal";
|
||||||
|
|
||||||
export default class MindnetCausalAssistantPlugin extends Plugin {
|
export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
settings: MindnetSettings;
|
settings: MindnetSettings;
|
||||||
private vocabulary: Vocabulary | null = null;
|
private vocabulary: Vocabulary | null = null;
|
||||||
private reloadDebounceTimer: number | null = null;
|
private reloadDebounceTimer: number | null = null;
|
||||||
|
private pendingCreateHint: PendingCreateHint | null = null;
|
||||||
private interviewConfig: InterviewConfig | null = null;
|
private interviewConfig: InterviewConfig | null = null;
|
||||||
private interviewConfigReloadDebounceTimer: number | null = null;
|
private interviewConfigReloadDebounceTimer: number | null = null;
|
||||||
private graphSchema: GraphSchema | null = null;
|
private graphSchema: GraphSchema | null = null;
|
||||||
|
|
@ -39,66 +59,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
// Add settings tab
|
// Add settings tab
|
||||||
this.addSettingTab(new MindnetSettingTab(this.app, this));
|
this.addSettingTab(new MindnetSettingTab(this.app, this));
|
||||||
|
|
||||||
// Register click handler for unresolved links
|
// Register unresolved link handlers for Reading View and Live Preview
|
||||||
this.registerDomEvent(document, "click", async (evt: MouseEvent) => {
|
if (this.settings.interceptUnresolvedLinkClicks) {
|
||||||
if (!this.settings.interceptUnresolvedLinkClicks) {
|
this.registerUnresolvedLinkHandlers();
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const target = evt.target as HTMLElement;
|
// Register vault create handler for adopting new notes (after layout ready to avoid startup events)
|
||||||
if (!target) return;
|
if (this.settings.adoptNewNotesInEditor) {
|
||||||
|
this.app.workspace.onLayoutReady(() => {
|
||||||
// Find closest unresolved internal link
|
this.registerEvent(
|
||||||
const anchor = target.closest("a.internal-link.is-unresolved");
|
this.app.vault.on("create", async (file: TFile) => {
|
||||||
if (!anchor || !(anchor instanceof HTMLElement)) {
|
await this.handleFileCreate(file);
|
||||||
return;
|
})
|
||||||
}
|
);
|
||||||
|
});
|
||||||
// Prevent default link behavior
|
}
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
|
|
||||||
// Extract target basename (preserves spaces and case)
|
|
||||||
const basename = extractTargetFromAnchor(anchor);
|
|
||||||
if (!basename) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use basename directly as title (Option 1: exact match)
|
|
||||||
const title = basename;
|
|
||||||
|
|
||||||
// Load interview config and open profile selection
|
|
||||||
try {
|
|
||||||
const config = await this.ensureInterviewConfigLoaded();
|
|
||||||
if (!config) {
|
|
||||||
new Notice("Interview config not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.profiles.length === 0) {
|
|
||||||
new Notice("No profiles available in interview config");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine default folder (from profile defaults or settings)
|
|
||||||
const defaultFolder = ""; // Will be set per profile selection
|
|
||||||
|
|
||||||
// Open profile selection modal
|
|
||||||
new ProfileSelectionModal(
|
|
||||||
this.app,
|
|
||||||
config,
|
|
||||||
async (result) => {
|
|
||||||
await this.createNoteFromProfileAndOpen(result, basename, result.folderPath);
|
|
||||||
},
|
|
||||||
title,
|
|
||||||
defaultFolder
|
|
||||||
).open();
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
new Notice(`Failed to handle link click: ${msg}`);
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register live reload for edge vocabulary file
|
// Register live reload for edge vocabulary file
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
|
|
@ -440,7 +415,8 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
private async createNoteFromProfileAndOpen(
|
private async createNoteFromProfileAndOpen(
|
||||||
result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string },
|
result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath?: string },
|
||||||
preferredBasename?: string,
|
preferredBasename?: string,
|
||||||
folderPath?: string
|
folderPath?: string,
|
||||||
|
isUnresolvedClick?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = await this.ensureInterviewConfigLoaded();
|
const config = await this.ensureInterviewConfigLoaded();
|
||||||
|
|
@ -499,32 +475,21 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
// Show notice
|
// Show notice
|
||||||
new Notice(`Note created: ${result.title}`);
|
new Notice(`Note created: ${result.title}`);
|
||||||
|
|
||||||
// Auto-start interview if setting enabled
|
// Start wizard with Templater compatibility
|
||||||
if (this.settings.autoStartInterviewOnCreate) {
|
await startWizardAfterCreate(
|
||||||
try {
|
this.app,
|
||||||
console.log("Start wizard", {
|
this.settings,
|
||||||
profileKey: result.profile.key,
|
file,
|
||||||
file: file.path,
|
result.profile,
|
||||||
});
|
content,
|
||||||
|
isUnresolvedClick || false,
|
||||||
new InterviewWizardModal(
|
async (wizardResult: WizardResult) => {
|
||||||
this.app,
|
new Notice("Interview completed and changes applied");
|
||||||
result.profile,
|
},
|
||||||
file,
|
async (wizardResult: WizardResult) => {
|
||||||
content,
|
new Notice("Interview saved and changes applied");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
new Notice(`Failed to create note: ${msg}`);
|
new Notice(`Failed to create note: ${msg}`);
|
||||||
|
|
@ -540,6 +505,372 @@ export default class MindnetCausalAssistantPlugin extends Plugin {
|
||||||
return this.createNoteFromProfileAndOpen(result, result.title);
|
return this.createNoteFromProfileAndOpen(result, result.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register unresolved link handlers for Reading View and Live Preview.
|
||||||
|
*/
|
||||||
|
private registerUnresolvedLinkHandlers(): void {
|
||||||
|
// Reading View: Markdown Post Processor
|
||||||
|
this.registerMarkdownPostProcessor((el, ctx) => {
|
||||||
|
if (!this.settings.interceptUnresolvedLinkClicks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = ctx.sourcePath;
|
||||||
|
const links = Array.from(el.querySelectorAll("a.internal-link"));
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
if (!(link instanceof HTMLElement)) continue;
|
||||||
|
|
||||||
|
// Check if already processed (avoid duplicate listeners)
|
||||||
|
if (link.dataset.mindnetProcessed === "true") continue;
|
||||||
|
link.dataset.mindnetProcessed = "true";
|
||||||
|
|
||||||
|
// Extract link target
|
||||||
|
const linkTarget = extractLinkTargetFromAnchor(link);
|
||||||
|
if (!linkTarget) continue;
|
||||||
|
|
||||||
|
// Check if unresolved
|
||||||
|
const unresolved = isUnresolvedLink(this.app, linkTarget, sourcePath);
|
||||||
|
if (!unresolved) continue;
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
link.addEventListener("click", async (evt: MouseEvent) => {
|
||||||
|
// Check bypass modifier
|
||||||
|
if (
|
||||||
|
isBypassModifierPressed(evt, this.settings.bypassModifier)
|
||||||
|
) {
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.log("[Mindnet] Bypass modifier pressed, skipping intercept");
|
||||||
|
}
|
||||||
|
return; // Let Obsidian handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.ensureInterviewConfigLoaded();
|
||||||
|
if (!config) {
|
||||||
|
new Notice("Interview config not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.profiles.length === 0) {
|
||||||
|
new Notice("No profiles available in interview config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: UnresolvedLinkClickContext = {
|
||||||
|
mode: "reading",
|
||||||
|
linkText: linkTarget,
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
resolved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.log("[Mindnet] Unresolved link click (Reading View):", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open profile selection
|
||||||
|
new ProfileSelectionModal(
|
||||||
|
this.app,
|
||||||
|
config,
|
||||||
|
async (result) => {
|
||||||
|
await this.createNoteFromProfileAndOpen(
|
||||||
|
result,
|
||||||
|
linkTarget,
|
||||||
|
result.folderPath,
|
||||||
|
true // isUnresolvedClick
|
||||||
|
);
|
||||||
|
},
|
||||||
|
linkTarget,
|
||||||
|
""
|
||||||
|
).open();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
new Notice(`Failed to handle link click: ${msg}`);
|
||||||
|
console.error("[Mindnet] Failed to handle link click:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Preview / Source Mode: CodeMirror-based click handler
|
||||||
|
this.registerDomEvent(document, "click", async (evt: MouseEvent) => {
|
||||||
|
if (!this.settings.interceptUnresolvedLinkClicks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a markdown editor view
|
||||||
|
const activeView = this.app.workspace.getActiveViewOfType(
|
||||||
|
require("obsidian").MarkdownView
|
||||||
|
);
|
||||||
|
if (!activeView) {
|
||||||
|
return; // Not in a markdown view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get editor instance (works for both Live Preview and Source mode)
|
||||||
|
const editor = (activeView as any).editor;
|
||||||
|
if (!editor || !editor.cm) {
|
||||||
|
return; // No CodeMirror editor available
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = editor.cm;
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
// Check if editor follow modifier is pressed
|
||||||
|
const modifierPressed = isBypassModifierPressed(
|
||||||
|
evt,
|
||||||
|
this.settings.editorFollowModifier
|
||||||
|
);
|
||||||
|
if (!modifierPressed) {
|
||||||
|
return; // Modifier not pressed, don't intercept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get position from mouse coordinates
|
||||||
|
const pos = view.posAtCoords({ x: evt.clientX, y: evt.clientY });
|
||||||
|
if (pos === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get line at position
|
||||||
|
const line = view.state.doc.lineAt(pos);
|
||||||
|
const lineText = line.text;
|
||||||
|
const posInLine = pos - line.from;
|
||||||
|
|
||||||
|
// Parse wikilink at cursor position
|
||||||
|
const rawTarget = parseWikilinkAtPosition(lineText, posInLine);
|
||||||
|
if (!rawTarget) {
|
||||||
|
return; // No wikilink found at cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize target (remove alias/heading)
|
||||||
|
const linkTarget = normalizeLinkTarget(rawTarget);
|
||||||
|
if (!linkTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source path
|
||||||
|
const activeFile = this.app.workspace.getActiveFile();
|
||||||
|
const sourcePath = activeFile?.path || "";
|
||||||
|
|
||||||
|
// Check if unresolved
|
||||||
|
const unresolved = isUnresolvedLink(this.app, linkTarget, sourcePath);
|
||||||
|
if (!unresolved) {
|
||||||
|
return; // Link is resolved, don't intercept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.ensureInterviewConfigLoaded();
|
||||||
|
if (!config) {
|
||||||
|
new Notice("Interview config not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.profiles.length === 0) {
|
||||||
|
new Notice("No profiles available in interview config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: UnresolvedLinkClickContext = {
|
||||||
|
mode: "live",
|
||||||
|
linkText: linkTarget,
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
resolved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.log("[Mindnet] Unresolved link click (Editor):", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pending create hint for correlation with vault create event
|
||||||
|
if (this.settings.adoptNewNotesInEditor) {
|
||||||
|
this.pendingCreateHint = {
|
||||||
|
basename: linkTarget,
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open profile selection
|
||||||
|
new ProfileSelectionModal(
|
||||||
|
this.app,
|
||||||
|
config,
|
||||||
|
async (result) => {
|
||||||
|
await this.createNoteFromProfileAndOpen(
|
||||||
|
result,
|
||||||
|
linkTarget,
|
||||||
|
result.folderPath,
|
||||||
|
true // isUnresolvedClick
|
||||||
|
);
|
||||||
|
},
|
||||||
|
linkTarget,
|
||||||
|
""
|
||||||
|
).open();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
new Notice(`Failed to handle link click: ${msg}`);
|
||||||
|
console.error("[Mindnet] Failed to handle link click:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file create event for adopting new notes.
|
||||||
|
*/
|
||||||
|
private async handleFileCreate(file: TFile): Promise<void> {
|
||||||
|
// Only consider markdown files
|
||||||
|
if (file.extension !== "md") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore files under .obsidian/
|
||||||
|
if (file.path.startsWith(".obsidian/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read file content
|
||||||
|
const content = await this.app.vault.cachedRead(file);
|
||||||
|
|
||||||
|
// Check if adopt candidate
|
||||||
|
if (!isAdoptCandidate(content, this.settings.adoptMaxChars)) {
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.log(`[Mindnet] File ${file.path} is not an adopt candidate (too large or has id)`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if matches pending hint (high confidence)
|
||||||
|
const highConfidence = matchesPendingHint(file, this.pendingCreateHint);
|
||||||
|
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.log(`[Mindnet] Adopt candidate detected: ${file.path}`, {
|
||||||
|
highConfidence,
|
||||||
|
pendingHint: this.pendingCreateHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending hint (used once)
|
||||||
|
this.pendingCreateHint = null;
|
||||||
|
|
||||||
|
// Show confirm modal if not high confidence
|
||||||
|
if (!highConfidence) {
|
||||||
|
const adoptModal = new AdoptNoteModal(this.app, file.basename);
|
||||||
|
const result = await adoptModal.show();
|
||||||
|
if (!result.adopt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load interview config
|
||||||
|
const config = await this.ensureInterviewConfigLoaded();
|
||||||
|
if (!config) {
|
||||||
|
new Notice("Interview config not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.profiles.length === 0) {
|
||||||
|
new Notice("No profiles available in interview config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open profile selection
|
||||||
|
new ProfileSelectionModal(
|
||||||
|
this.app,
|
||||||
|
config,
|
||||||
|
async (result) => {
|
||||||
|
await this.adoptNote(file, result, content);
|
||||||
|
},
|
||||||
|
file.basename,
|
||||||
|
""
|
||||||
|
).open();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (this.settings.debugLogging) {
|
||||||
|
console.error(`[Mindnet] Failed to handle file create: ${msg}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a note: convert to Mindnet format and start wizard.
|
||||||
|
*/
|
||||||
|
private async adoptNote(
|
||||||
|
file: TFile,
|
||||||
|
result: { profile: import("./interview/types").InterviewProfile; title: string; folderPath: string },
|
||||||
|
existingContent: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Generate unique ID
|
||||||
|
const id = `note_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
|
// Write frontmatter
|
||||||
|
const newFrontmatter = writeFrontmatter({
|
||||||
|
id,
|
||||||
|
title: result.title,
|
||||||
|
noteType: result.profile.note_type,
|
||||||
|
interviewProfile: result.profile.key,
|
||||||
|
defaults: result.profile.defaults,
|
||||||
|
frontmatterWhitelist: (await this.ensureInterviewConfigLoaded())?.frontmatterWhitelist || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge frontmatter into existing content
|
||||||
|
const mergedContent = mergeFrontmatter(existingContent, newFrontmatter);
|
||||||
|
|
||||||
|
// Determine folder path
|
||||||
|
const finalFolderPath = result.folderPath ||
|
||||||
|
(result.profile.defaults?.folder as string) ||
|
||||||
|
this.settings.defaultNotesFolder ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
// Move file if folder changed
|
||||||
|
let targetFile = file;
|
||||||
|
if (finalFolderPath && file.parent?.path !== finalFolderPath) {
|
||||||
|
const newPath = joinFolderAndBasename(finalFolderPath, `${file.basename}.md`);
|
||||||
|
await ensureFolderExists(this.app, finalFolderPath);
|
||||||
|
await this.app.vault.rename(file, newPath);
|
||||||
|
const renamedFile = this.app.vault.getAbstractFileByPath(newPath);
|
||||||
|
if (renamedFile instanceof TFile) {
|
||||||
|
targetFile = renamedFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write merged content
|
||||||
|
await this.app.vault.modify(targetFile, mergedContent);
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
await this.app.workspace.openLinkText(file.path, "", true);
|
||||||
|
|
||||||
|
// Show notice
|
||||||
|
new Notice(`Note adopted: ${result.title}`);
|
||||||
|
|
||||||
|
// Start wizard
|
||||||
|
await startWizardAfterCreate(
|
||||||
|
this.app,
|
||||||
|
this.settings,
|
||||||
|
targetFile,
|
||||||
|
result.profile,
|
||||||
|
mergedContent,
|
||||||
|
true, // isUnresolvedClick
|
||||||
|
async (wizardResult: WizardResult) => {
|
||||||
|
new Notice("Interview completed and changes applied");
|
||||||
|
},
|
||||||
|
async (wizardResult: WizardResult) => {
|
||||||
|
new Notice("Interview saved and changes applied");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
new Notice(`Failed to adopt note: ${msg}`);
|
||||||
|
console.error("[Mindnet] Failed to adopt note:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onunload(): void {
|
onunload(): void {
|
||||||
// nothing yet
|
// nothing yet
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ export interface MindnetSettings {
|
||||||
interviewConfigPath: string; // vault-relativ
|
interviewConfigPath: string; // vault-relativ
|
||||||
autoStartInterviewOnCreate: boolean;
|
autoStartInterviewOnCreate: boolean;
|
||||||
interceptUnresolvedLinkClicks: boolean;
|
interceptUnresolvedLinkClicks: boolean;
|
||||||
|
autoStartOnUnresolvedClick: boolean; // Auto-start interview when creating note from unresolved link
|
||||||
|
bypassModifier: "Alt" | "Ctrl" | "Shift" | "None"; // Modifier key to bypass intercept (Reading View)
|
||||||
|
editorFollowModifier: "Alt" | "Ctrl" | "Shift" | "None"; // Modifier key required for editor intercept (Live Preview/Source)
|
||||||
|
waitForFirstModifyAfterCreate: boolean; // Wait for Templater to modify file before starting wizard
|
||||||
|
waitForModifyTimeoutMs: number; // Timeout in ms for waiting for modify event
|
||||||
|
debugLogging: boolean; // Enable debug logging for unresolved link handling
|
||||||
|
adoptNewNotesInEditor: boolean; // Auto-adopt newly created notes in editor
|
||||||
|
adoptMaxChars: number; // Max content length to consider note as adopt-candidate
|
||||||
// Semantic mapping builder settings
|
// Semantic mapping builder settings
|
||||||
mappingWrapperCalloutType: string; // default: "abstract"
|
mappingWrapperCalloutType: string; // default: "abstract"
|
||||||
mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping"
|
mappingWrapperTitle: string; // default: "🕸️ Semantic Mapping"
|
||||||
|
|
@ -28,6 +36,14 @@ export interface MindnetSettings {
|
||||||
interviewConfigPath: "_system/dictionary/interview_config.yaml",
|
interviewConfigPath: "_system/dictionary/interview_config.yaml",
|
||||||
autoStartInterviewOnCreate: false,
|
autoStartInterviewOnCreate: false,
|
||||||
interceptUnresolvedLinkClicks: true,
|
interceptUnresolvedLinkClicks: true,
|
||||||
|
autoStartOnUnresolvedClick: true,
|
||||||
|
bypassModifier: "Alt",
|
||||||
|
editorFollowModifier: "Ctrl",
|
||||||
|
waitForFirstModifyAfterCreate: true,
|
||||||
|
waitForModifyTimeoutMs: 1200,
|
||||||
|
debugLogging: false,
|
||||||
|
adoptNewNotesInEditor: true,
|
||||||
|
adoptMaxChars: 200,
|
||||||
mappingWrapperCalloutType: "abstract",
|
mappingWrapperCalloutType: "abstract",
|
||||||
mappingWrapperTitle: "🕸️ Semantic Mapping",
|
mappingWrapperTitle: "🕸️ Semantic Mapping",
|
||||||
mappingWrapperFolded: true,
|
mappingWrapperFolded: true,
|
||||||
|
|
|
||||||
|
|
@ -99,4 +99,19 @@ describe("extractTargetFromData", () => {
|
||||||
const result = extractTargetFromData(null, " test-note ");
|
const result = extractTargetFromData(null, " test-note ");
|
||||||
expect(result).toBe("test-note");
|
expect(result).toBe("test-note");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles Live Preview format (data-href with spaces)", () => {
|
||||||
|
const result = extractTargetFromData("My Live Preview Note", null);
|
||||||
|
expect(result).toBe("My Live Preview Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Live Preview format with alias", () => {
|
||||||
|
const result = extractTargetFromData("My Note|Display Text", null);
|
||||||
|
expect(result).toBe("My Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Live Preview format with heading", () => {
|
||||||
|
const result = extractTargetFromData("My Note#Section", null);
|
||||||
|
expect(result).toBe("My Note");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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
|
// Semantic Mapping Builder section
|
||||||
containerEl.createEl("h2", { text: "Semantic Mapping Builder" });
|
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