diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..75cb1134 --- /dev/null +++ b/PR.md @@ -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` diff --git a/src/services/FilterService.ts b/src/services/FilterService.ts index 3afec89b..5be09db2 100644 --- a/src/services/FilterService.ts +++ b/src/services/FilterService.ts @@ -927,7 +927,8 @@ export class FilterService extends EventEmitter { const result = this.evaluateProjectsCondition( taskValue, operator as FilterOperator, - value + value, + task.path ); return result; } @@ -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; @@ -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; } @@ -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; } diff --git a/src/ui/renderers/linkRenderer.ts b/src/ui/renderers/linkRenderer.ts index a0a96728..8fcd6b2a 100644 --- a/src/ui/renderers/linkRenderer.ts +++ b/src/ui/renderers/linkRenderer.ts @@ -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; @@ -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, @@ -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", }, @@ -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, ""); @@ -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, ""); @@ -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); @@ -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( diff --git a/src/utils/linkUtils.ts b/src/utils/linkUtils.ts index 850af60d..7b44954a 100644 --- a/src/utils/linkUtils.ts +++ b/src/utils/linkUtils.ts @@ -11,11 +11,18 @@ 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: if (trimmed.startsWith("<") && trimmed.endsWith(">")) { let inner = trimmed.slice(1, -1).trim(); - const hasMdExt = /\.md$/i.test(inner); try { inner = decodeURIComponent(inner); } catch (error) { @@ -23,7 +30,8 @@ export function parseLinkToPath(linkText: string): string { } const parsed = parseLinktext(inner); - return hasMdExt ? inner : parsed.path || inner; + const path = parsed.path || inner; + return stripAnchor(path); } // Handle wikilinks: [[path]] or [[path|alias]] @@ -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) @@ -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); @@ -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); } /** @@ -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(/^\[([^\]]+)\]\(([^)]+)\)$/); @@ -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("/"); @@ -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("/"); diff --git a/src/views/StatsView.ts b/src/views/StatsView.ts index 40e99bb9..19b2ea8f 100644 --- a/src/views/StatsView.ts +++ b/src/views/StatsView.ts @@ -406,18 +406,20 @@ 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 @@ -425,7 +427,7 @@ export class StatsView extends ItemView { } // 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; } @@ -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; } @@ -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")]; diff --git a/tests/unit/ui/linkRenderer.test.ts b/tests/unit/ui/linkRenderer.test.ts new file mode 100644 index 00000000..059eed74 --- /dev/null +++ b/tests/unit/ui/linkRenderer.test.ts @@ -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); + }); +}); diff --git a/tests/unit/utils/linkUtils.test.ts b/tests/unit/utils/linkUtils.test.ts index a4c8e7cb..6f85febe 100644 --- a/tests/unit/utils/linkUtils.test.ts +++ b/tests/unit/utils/linkUtils.test.ts @@ -104,11 +104,36 @@ describe('linkUtils - frontmatter link format', () => { expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); }); + it('should strip headings from markdown links with .md paths', () => { + const link = '[My Note](Folder/My Note.md#Section)'; + expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); + }); + it('should parse plain angle bracket autolinks', () => { const link = ''; expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); }); + it('should strip headings from plain angle bracket autolinks', () => { + const link = ''; + expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); + }); + + it('should strip headings from wikilinks', () => { + const link = '[[Folder/My Note#Section]]'; + expect(parseLinkToPath(link)).toBe('Folder/My Note'); + }); + + it('should strip block refs from markdown links', () => { + const link = '[My Note](Folder/My Note.md^blockid)'; + expect(parseLinkToPath(link)).toBe('Folder/My Note.md'); + }); + + it('should strip block refs from wikilinks', () => { + const link = '[[Folder/My Note^blockid]]'; + expect(parseLinkToPath(link)).toBe('Folder/My Note'); + }); + it('should decode markdown link paths without .md extension', () => { const link = '[My Note](Folder/My%20Note)'; expect(parseLinkToPath(link)).toBe('Folder/My Note');