mindnet_obsidian/src/unresolvedLink/linkHelpers.ts
Lars 725adb5302
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Enhance UI and functionality for Chain Workbench and related features
- Introduced a wide two-column layout for the Chain Workbench modal, improving user experience and accessibility.
- Added new styles for workbench components, including headers, filters, and main containers, to enhance visual organization.
- Updated chain templates to allow for multiple distinct matches per template, improving flexibility in template matching.
- Enhanced documentation to clarify the new settings and commands related to the Chain Workbench and edge detection features.
- Implemented logging for better tracking of missing configurations, ensuring users are informed about any loading issues.
2026-02-05 11:41:15 +01:00

192 lines
5.7 KiB
TypeScript

/**
* 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);
});
}