diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0a687915c..6d85084db 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -40,6 +40,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2397) Fixed tags and same-note heading links such as `[[#Installation]]` being rendered as task links in Reading View when the note is a task. Thanks to @hm-24 for reporting this. + - (#2389) Added the missing public [v4-to-v5 beta upgrade guide](https://tasknotes.dev/migration-v4-to-v5/), including synced-device and metadata-recovery instructions, and corrected the HTTP API authentication documentation. Thanks to @m4to-3pe for reporting the broken link. - (#2382) Fixed Kanban settings being ignored in existing `.base` files when nested under `options:` or `config:`, including empty-column hiding and pinned columns. Newly generated default files and exported v3 views now write settings directly on each view; existing files remain unchanged. See [Kanban View](https://tasknotes.dev/views/kanban-view/#configuration). Thanks to @techwiththiru for reporting this. diff --git a/src/editor/ReadingModeTaskLinkProcessor.ts b/src/editor/ReadingModeTaskLinkProcessor.ts index ec7afb819..a7d86a03d 100644 --- a/src/editor/ReadingModeTaskLinkProcessor.ts +++ b/src/editor/ReadingModeTaskLinkProcessor.ts @@ -181,6 +181,17 @@ export function shouldSkipReadingModeTaskLinkOverlay(options: { return currentText !== options.originalLinkPath && currentText !== options.taskTitle; } +/** + * Tags (`a.tag`) and same-note heading links (`[[#Heading]]`, `[text](#Heading)`) + * render as anchors whose target starts with `#`. They have no link path, so they + * would otherwise resolve to the current note and be replaced by its task widget. + */ +function isSameNoteFragmentLink(linkEl: HTMLAnchorElement): boolean { + if (linkEl.classList.contains("tag")) return true; + const target = linkEl.getAttribute("data-href") || linkEl.getAttribute("href") || ""; + return target.startsWith("#"); +} + function getMarkdownSectionText( ctx: Parameters[1], el: HTMLElement @@ -228,6 +239,7 @@ export class ReadingModeTaskLinkProcessor { for (const link of Array.from(allLinks)) { const linkEl = link; + if (isSameNoteFragmentLink(linkEl)) continue; const href = linkEl.getAttribute("href"); const linkTarget = linkEl.getAttribute("data-href") || href || ""; const sourceLink = diff --git a/tests/unit/editor/ReadingModeTaskLinkProcessor.fragmentLinks.test.ts b/tests/unit/editor/ReadingModeTaskLinkProcessor.fragmentLinks.test.ts new file mode 100644 index 000000000..33686eb27 --- /dev/null +++ b/tests/unit/editor/ReadingModeTaskLinkProcessor.fragmentLinks.test.ts @@ -0,0 +1,147 @@ +/** + * Issue #2397: tags and same-note heading links must not be rendered as + * TaskNotes task widgets in Reading View when the current note is a task. + * + * @see https://github.com/callumalpass/tasknotes/issues/2397 + */ + +import { describe, expect, it, jest } from "@jest/globals"; +import type { MarkdownPostProcessorContext } from "obsidian"; +import { ReadingModeTaskLinkProcessor } from "../../../src/editor/ReadingModeTaskLinkProcessor"; +import type { TaskInfo } from "../../../src/types"; +import type TaskNotesPlugin from "../../../src/main"; + +jest.mock("../../../src/editor/TaskLinkWidget", () => { + class TaskLinkWidget { + constructor( + private taskInfo: TaskInfo, + private plugin: TaskNotesPlugin, + private originalText: string, + private displayText?: string + ) {} + + toDOM(): HTMLElement { + const element = document.createElement("span"); + element.className = "tasknotes-plugin tasknotes-inline-widget"; + element.textContent = this.displayText ?? this.originalText; + return element; + } + } + + return { TaskLinkWidget }; +}); + +const CURRENT_NOTE_PATH = "Tasks/MariaDB.md"; +const OTHER_TASK_PATH = "Tasks/Other task.md"; + +function createTask(path: string, title: string): TaskInfo { + return { + path, + title, + status: "open", + priority: "normal", + archived: false, + tags: [], + contexts: [], + projects: [], + timeEntries: [], + complete_instances: [], + skipped_instances: [], + } as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + const tasks: Record = { + [CURRENT_NOTE_PATH]: createTask(CURRENT_NOTE_PATH, "MariaDB"), + [OTHER_TASK_PATH]: createTask(OTHER_TASK_PATH, "Other task"), + }; + + return { + settings: { + enableTaskLinkOverlay: true, + disableOverlayOnAlias: false, + inlineVisibleProperties: [], + }, + emitter: { on: jest.fn() }, + registerEvent: jest.fn(), + register: jest.fn(), + cacheManager: { + getCachedTaskInfoSync: jest.fn((path: string) => tasks[path] ?? null), + }, + app: { + workspace: { getLeavesOfType: jest.fn(() => []) }, + metadataCache: { + // Like Obsidian, an empty link path resolves to the source note. + getFirstLinkpathDest: jest.fn((linkPath: string, sourcePath: string) => { + if (linkPath === "") return { path: sourcePath }; + if (linkPath === "Other task") return { path: OTHER_TASK_PATH }; + return null; + }), + }, + }, + } as unknown as TaskNotesPlugin; +} + +async function render(html: string): Promise { + const el = document.createElement("div"); + el.innerHTML = html; + + const processor = new ReadingModeTaskLinkProcessor(createPlugin()).createPostProcessor(); + await processor(el, { + sourcePath: CURRENT_NOTE_PATH, + getSectionInfo: () => null, + } as unknown as MarkdownPostProcessorContext); + // processLink is fire-and-forget; flush its microtasks. + await Promise.resolve(); + await Promise.resolve(); + + return el; +} + +describe("Issue #2397: Reading View tags and same-note heading links", () => { + it("leaves tags untouched", async () => { + const el = await render( + '

#servers/db

' + ); + + expect(el.querySelector(".tasknotes-inline-widget")).toBeNull(); + expect(el.querySelector("a.tag")?.textContent).toBe("#servers/db"); + }); + + it("leaves same-note heading wikilinks untouched", async () => { + const el = await render( + '

MariaDB > Installation

' + ); + + expect(el.querySelector(".tasknotes-inline-widget")).toBeNull(); + expect(el.querySelector("a.internal-link")?.getAttribute("href")).toBe("#Installation"); + }); + + it("leaves same-note heading Markdown links untouched", async () => { + const el = await render('

Installation

'); + + expect(el.querySelector(".tasknotes-inline-widget")).toBeNull(); + expect(el.querySelector("a")?.getAttribute("href")).toBe("#Installation"); + }); + + it("still renders links to a task, including a heading in another task", async () => { + const el = await render( + [ + "

", + '#servers/db', + 'Installation', + 'Other task', + 'Other task > Setup', + "

", + ].join("") + ); + + const widgets = el.querySelectorAll(".task-inline-preview--reading-mode"); + expect(Array.from(widgets).map((widget) => widget.dataset.originalLinkPath)).toEqual([ + "Other task", + "Other task#Setup", + ]); + expect(el.querySelector("a.tag")).not.toBeNull(); + expect(el.querySelector('a.internal-link[href="#Installation"]')).not.toBeNull(); + }); +});