Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions src/editor/ReadingModeTaskLinkProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarkdownPostProcessor>[1],
el: HTMLElement
Expand Down Expand Up @@ -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 =
Expand Down
147 changes: 147 additions & 0 deletions tests/unit/editor/ReadingModeTaskLinkProcessor.fragmentLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, TaskInfo> = {
[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<HTMLElement> {
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(
'<p><a href="#servers/db" class="tag" target="_blank" rel="noopener nofollow">#servers/db</a></p>'
);

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(
'<p><a class="internal-link" data-href="#Installation" href="#Installation" target="_blank" rel="noopener nofollow">MariaDB &gt; Installation</a></p>'
);

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('<p><a href="#Installation">Installation</a></p>');

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(
[
"<p>",
'<a href="#servers/db" class="tag">#servers/db</a>',
'<a class="internal-link" data-href="#Installation" href="#Installation">Installation</a>',
'<a class="internal-link" data-href="Other task" href="Other task">Other task</a>',
'<a class="internal-link" data-href="Other task#Setup" href="Other task#Setup">Other task &gt; Setup</a>',
"</p>",
].join("")
);

const widgets = el.querySelectorAll<HTMLElement>(".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();
});
});