161 lines
4.3 KiB
TypeScript
161 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|
||
|
|
}
|