Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# fix/angle-brackets

## Link Resolution Follow‑ups

Tightens link parsing and display resolution after the [initial angle‑bracket support](https://github.com/callumalpass/tasknotes/pull/1413). Anchors are stripped for file resolution while still preserved for navigation, and relative project links resolve against the source file where possible.

Examples (illustrative):

- `[My Note](Folder/My Note.md#Section)` resolves to `Folder/My Note.md` for lookup, but still opens `#Section`.
- `[[Folder/My Note#Section]]` displays as `My Note` while keeping the fragment for navigation.
- Relative project links display the correct title when resolved from the task's path.

## Changelog

- Strip `#heading` / `^block` fragments when resolving link paths.
- Preserve fragments for navigation and hover in `linkRenderer`.
- Resolve project display names using `sourcePath` where available.
- Pass `sourcePath` through stats/filter helpers for consistent relative link handling.

## Tests

- `npm run i18n:sync`
- `npm run lint` (warnings only; matches `main`)
- `node generate-release-notes-import.mjs`
- `npm run typecheck`
- `npm run test:ci -- --verbose` (fails: `due-date-timezone-inconsistency` test, same as `main`)
- `./node_modules/.bin/jest tests/unit/utils/linkUtils.test.ts tests/unit/issues/issue-814-markdown-project-links.test.ts --runInBand`
- `npm run test:integration`
- `npm run test:performance` (no tests found)
- `npm run build` (missing OAuth IDs warning, same as `main`)
- `npm run test:build`
12 changes: 7 additions & 5 deletions src/services/FilterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,8 @@ export class FilterService extends EventEmitter {
const result = this.evaluateProjectsCondition(
taskValue,
operator as FilterOperator,
value
value,
task.path
);
return result;
}
Expand All @@ -950,7 +951,8 @@ export class FilterService extends EventEmitter {
private evaluateProjectsCondition(
taskValue: TaskPropertyValue,
operator: FilterOperator,
conditionValue: TaskPropertyValue
conditionValue: TaskPropertyValue,
sourcePath?: string
): boolean {
if (!Array.isArray(taskValue)) {
return false;
Expand All @@ -973,7 +975,7 @@ export class FilterService extends EventEmitter {
return false;
}

const taskProjectName = this.extractProjectName(taskProject);
const taskProjectName = this.extractProjectName(taskProject, sourcePath);
if (!taskProjectName) {
return false;
}
Expand All @@ -993,11 +995,11 @@ export class FilterService extends EventEmitter {
/**
* Extract clean project name from various formats ([[Name]], Name, [[path/Name]], etc.)
*/
private extractProjectName(projectValue: string): string | null {
private extractProjectName(projectValue: string, sourcePath?: string): string | null {
if (!projectValue || typeof projectValue !== "string") {
return null;
}
const displayName = getProjectDisplayName(projectValue, this.plugin?.app);
const displayName = getProjectDisplayName(projectValue, this.plugin?.app, sourcePath);
return displayName ? displayName : null;
}

Expand Down
46 changes: 38 additions & 8 deletions src/ui/renderers/linkRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// Link and tag rendering utilities for UI components

import { App, TFile, Notice } from "obsidian";
import { App, TFile, Notice, parseLinktext } from "obsidian";
import { parseLinkToPath } from "../../utils/linkUtils";

export type LinkNavigateHandler = (
normalizedPath: string,
linkText: string,
event: MouseEvent
) => Promise<boolean | void> | boolean | void;

Expand Down Expand Up @@ -34,6 +34,21 @@ interface HoverLinkEvent {
const LINK_REGEX =
/\[\[([^[\]]+)\]\]|\[([^\]]+)\]\(([^)]+)\)|<(https?:\/\/[^\s>]+)>|\[([^\]]+)\]\s*\[([^\]]*)\]/g;

function extractLinkFragment(rawPath: string): string {
if (!rawPath) return "";
let cleaned = rawPath.trim();
if (cleaned.startsWith("<") && cleaned.endsWith(">")) {
cleaned = cleaned.slice(1, -1).trim();
}
try {
cleaned = decodeURIComponent(cleaned);
} catch {
// keep original when decoding fails
}
const parsed = parseLinktext(cleaned);
if (!parsed.subpath) return "";
return parsed.subpath.startsWith("#") ? parsed.subpath : `#${parsed.subpath}`;
}
/** Enhanced internal link creation with better error handling and accessibility */
export function appendInternalLink(
container: HTMLElement,
Expand All @@ -56,12 +71,15 @@ export function appendInternalLink(

const sourcePath = deps.sourcePath ?? "";
const normalizedPath = parseLinkToPath(filePath);
const fragment = extractLinkFragment(filePath);
const linkText = fragment ? `${normalizedPath}${fragment}` : normalizedPath;

const linkEl = container.createEl("a", {
cls: cssClass,
text: displayText,
attr: {
"data-href": normalizedPath,
...(fragment ? { "data-href-fragment": fragment } : {}),
role: "link",
tabindex: "0",
},
Expand All @@ -73,17 +91,22 @@ export function appendInternalLink(
try {
if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+Click opens in new tab
deps.workspace.openLinkText(normalizedPath, sourcePath, true);
deps.workspace.openLinkText(linkText, sourcePath, true);
return;
}

if (onPrimaryNavigate) {
const handled = await onPrimaryNavigate(normalizedPath, e);
const handled = await onPrimaryNavigate(linkText, e);
if (handled !== false) {
return;
}
}

if (fragment) {
deps.workspace.openLinkText(linkText, sourcePath, false);
return;
}

const file =
deps.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) ||
deps.metadataCache.getFirstLinkpathDest(normalizedPath, "");
Expand All @@ -106,6 +129,11 @@ export function appendInternalLink(
e.preventDefault();
e.stopPropagation();
try {
if (fragment) {
deps.workspace.openLinkText(linkText, sourcePath, true);
return;
}

const file =
deps.metadataCache.getFirstLinkpathDest(normalizedPath, sourcePath) ||
deps.metadataCache.getFirstLinkpathDest(normalizedPath, "");
Expand Down Expand Up @@ -135,7 +163,7 @@ export function appendInternalLink(
source: hoverSource,
hoverParent: container,
targetEl: linkEl,
linktext: normalizedPath,
linktext: linkText,
sourcePath: sourcePath || file.path,
};
deps.workspace.trigger("hover-link", hoverEvent);
Expand Down Expand Up @@ -310,10 +338,12 @@ function parseMarkdownLink(text: string): { displayText: string; filePath: strin
if (!match) return null;

const displayText = match[1].trim();
const rawPath = match[2].trim();
const filePath = parseLinkToPath(rawPath);
let rawPath = match[2].trim();
if (rawPath.startsWith("<") && rawPath.endsWith(">")) {
rawPath = rawPath.slice(1, -1).trim();
}

return { displayText, filePath };
return { displayText, filePath: rawPath };
}

function resolveProjectDisplayText(
Expand Down
34 changes: 24 additions & 10 deletions src/utils/linkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,27 @@ export function parseLinkToPath(linkText: string): string {
if (!linkText) return linkText;

const trimmed = linkText.trim();
const stripAnchor = (value: string): string => {
const hashIndex = value.indexOf("#");
const blockIndex = value.indexOf("^");
if (hashIndex === -1 && blockIndex === -1) return value;
if (hashIndex === -1) return value.slice(0, blockIndex);
if (blockIndex === -1) return value.slice(0, hashIndex);
return value.slice(0, Math.min(hashIndex, blockIndex));
};

// Handle plain angle-bracket autolink style: <path/to/note.md>
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
let inner = trimmed.slice(1, -1).trim();
const hasMdExt = /\.md$/i.test(inner);
try {
inner = decodeURIComponent(inner);
} catch (error) {
console.debug("Failed to decode URI component:", inner, error);
}

const parsed = parseLinktext(inner);
return hasMdExt ? inner : parsed.path || inner;
const path = parsed.path || inner;
return stripAnchor(path);
}

// Handle wikilinks: [[path]] or [[path|alias]]
Expand All @@ -36,7 +44,8 @@ export function parseLinkToPath(linkText: string): string {
const pathOnly = pipeIndex !== -1 ? inner.substring(0, pipeIndex) : inner;
const parsed = parseLinktext(pathOnly);

return parsed.path;
const path = parsed.path || pathOnly;
return stripAnchor(path);
}

// Handle markdown links: [text](path)
Expand All @@ -49,8 +58,6 @@ export function parseLinkToPath(linkText: string): string {
linkPath = linkPath.slice(1, -1).trim();
}

const hasMdExt = /\.md$/i.test(linkPath);

// URL decode the link path - crucial for paths with spaces like Car%20Maintenance.md
try {
linkPath = decodeURIComponent(linkPath);
Expand All @@ -61,11 +68,12 @@ export function parseLinkToPath(linkText: string): string {

// Use parseLinktext to handle subpaths/headings
const parsed = parseLinktext(linkPath);
return hasMdExt ? linkPath : parsed.path;
const path = parsed.path || linkPath;
return stripAnchor(path);
}

// Not a link format, return as-is
return trimmed;
return stripAnchor(trimmed);
}

/**
Expand All @@ -74,11 +82,17 @@ export function parseLinkToPath(linkText: string): string {
*
* @param projectValue - The raw project value
* @param app - Optional Obsidian app for resolving to basename
* @param sourcePath - Optional source path to resolve relative links
*/
export function getProjectDisplayName(projectValue: string, app?: App): string {
export function getProjectDisplayName(
projectValue: string,
app?: App,
sourcePath?: string
): string {
if (!projectValue) return "";

const trimmed = projectValue.trim();
const resolvedSourcePath = sourcePath ?? "";

// Handle markdown links: [text](path)
const markdownMatch = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
Expand All @@ -89,7 +103,7 @@ export function getProjectDisplayName(projectValue: string, app?: App): string {
return displayText;
}
const linkPath = parseLinkToPath(rawPath);
const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, "");
const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, resolvedSourcePath);
if (resolved) return resolved.basename;
const cleanPath = linkPath.replace(/\.md$/i, "");
const parts = cleanPath.split("/");
Expand All @@ -106,7 +120,7 @@ export function getProjectDisplayName(projectValue: string, app?: App): string {
}
const parsed = parseLinktext(linkContent.split("|")[0] || linkContent);
const linkPath = parsed.path || parseLinkToPath(trimmed);
const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, "");
const resolved = app?.metadataCache.getFirstLinkpathDest(linkPath, resolvedSourcePath);
if (resolved) return resolved.basename;
const cleanPath = linkPath.replace(/\.md$/i, "");
const parts = cleanPath.split("/");
Expand Down
14 changes: 8 additions & 6 deletions src/views/StatsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,26 +406,28 @@ export class StatsView extends ItemView {
* Returns a canonical project name that represents all variations.
* Based on the implementation from PR #486
*/
private consolidateProjectName(projectValue: string): string {
private consolidateProjectName(projectValue: string, sourcePath?: string): string {
if (!projectValue || typeof projectValue !== "string") {
return projectValue;
}

const resolvedSourcePath = sourcePath ?? "";

// For wikilink format, try to resolve to actual file
if (projectValue.startsWith("[[") && projectValue.endsWith("]]")) {
const linkPath = this.extractWikilinkPath(projectValue);
if (linkPath && this.plugin?.app) {
const resolvedFile = this.plugin.app.metadataCache.getFirstLinkpathDest(
linkPath,
""
resolvedSourcePath
);
if (resolvedFile) {
// Return the file basename as the canonical name
return resolvedFile.basename;
}

// If file doesn't exist, extract clean name from path
const cleanName = this.extractProjectName(projectValue);
const cleanName = this.extractProjectName(projectValue, sourcePath);
if (cleanName) {
return cleanName;
}
Expand Down Expand Up @@ -471,9 +473,9 @@ export class StatsView extends ItemView {
/**
* Extract clean project name from various formats
*/
private extractProjectName(projectValue: string): string | null {
private extractProjectName(projectValue: string, sourcePath?: string): string | null {
if (!projectValue) return null;
const displayName = getProjectDisplayName(projectValue, this.plugin?.app);
const displayName = getProjectDisplayName(projectValue, this.plugin?.app, sourcePath);
return displayName || null;
}

Expand Down Expand Up @@ -804,7 +806,7 @@ export class StatsView extends ItemView {
const filteredProjects = filterEmptyProjects(task.projects);
if (filteredProjects.length > 0) {
return filteredProjects
.map((project) => this.consolidateProjectName(project))
.map((project) => this.consolidateProjectName(project, task.path))
.filter((project) => typeof project === "string" && project.length > 0);
}
return [this.plugin.i18n.translate("views.stats.noProject")];
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/ui/linkRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { appendInternalLink } from "../../../src/ui/renderers/linkRenderer";

jest.mock("obsidian");

describe("linkRenderer - anchors", () => {
const createDeps = () => {
const openLinkText = jest.fn();
return {
metadataCache: {
getFirstLinkpathDest: jest.fn(() => null),
},
workspace: {
openLinkText,
getLeaf: jest.fn(() => ({ openFile: jest.fn() })),
trigger: jest.fn(),
},
sourcePath: "Projects/Parent.md",
};
};

it("preserves anchor fragments on internal links", () => {
const container = document.createElement("div");
const deps = createDeps();

appendInternalLink(container, "Folder/Note#Section", "Note", deps as any);

const linkEl = container.querySelector("a") as HTMLAnchorElement;
expect(linkEl).toBeTruthy();
expect(linkEl.getAttribute("data-href")).toBe("Folder/Note");
expect(linkEl.getAttribute("data-href-fragment")).toBe("#Section");

linkEl.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(deps.workspace.openLinkText).toHaveBeenCalledWith(
"Folder/Note#Section",
"Projects/Parent.md",
false
);
});

it("does not add fragment attribute when no anchor is present", () => {
const container = document.createElement("div");
const deps = createDeps();

appendInternalLink(container, "Folder/Note", "Note", deps as any);

const linkEl = container.querySelector("a") as HTMLAnchorElement;
expect(linkEl).toBeTruthy();
expect(linkEl.getAttribute("data-href")).toBe("Folder/Note");
expect(linkEl.hasAttribute("data-href-fragment")).toBe(false);
});
});
Loading
Loading