/** * Helper functions for unresolved link detection and handling. */ import { App, TFile } from "obsidian"; /** * Normalize heading string for comparison only (Inspect Chains / Chain Workbench). * Maps "Überschrift", "Überschrift ^Block", "Überschrift Block" to the same canonical form * so that Obsidian UI links (no ^) and plugin/sectionParser variants match. * - Strip trailing block-id with caret: \s+\^[a-zA-Z0-9_-]+$ * - Strip one trailing word (Obsidian stores "Überschrift Block" without ^) * Use only for equality checks, not for display or stored links. */ export function normalizeHeadingForMatch(heading: string | null): string | null { if (heading === null || heading === undefined) return null; let s = heading.trim(); if (!s) return null; // 1) Remove optional block-id suffix with caret: "Überschrift ^Block" -> "Überschrift" s = s.replace(/\s+\^[a-zA-Z0-9_-]+\s*$/, "").trim(); // 2) Remove one trailing word (Obsidian link form "Überschrift Block" -> "Überschrift") s = s.replace(/\s+[a-zA-Z0-9_-]+\s*$/, "").trim(); return s || null; } /** * Compare two headings for equality using normalized form (block-id suffix and trailing word stripped). * Use for Inspect Chains / Chain Workbench matching only. */ export function headingsMatch(a: string | null, b: string | null): boolean { const na = normalizeHeadingForMatch(a); const nb = normalizeHeadingForMatch(b); if (na === null && nb === null) return true; if (na === null || nb === null) return false; return na === nb; } /** * 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); }); }