fix heading task link labels

This commit is contained in:
callumalpass 2026-06-24 19:03:09 +10:00
parent cac4f5e170
commit 77dd5d9db3
6 changed files with 290 additions and 25 deletions

View file

@ -51,6 +51,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
- (#2039) Fixed broken images on the Workflows and Calendar Setup documentation pages. Thanks to @chmac for reporting this.
- (#2045) Fixed custom modal fields assigned to Basic Information not appearing in task creation or edit modals. Thanks to @chmac for reporting this.
- (#2063) Fixed project autocomplete storing ambiguous project links without the selected note's folder path. Thanks to @chmac for reporting this.
- (#2072) Fixed inline task links to headings so they show both the task title and heading instead of only the heading. Thanks to @spasche for reporting this.
## Changed

View file

@ -11,6 +11,8 @@ import {
import { TaskLinkWidget } from "./TaskLinkWidget";
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
import {
extractTaskLinkSubpath,
formatTaskLinkSubpathDisplayText,
isImplicitTaskLinkDisplayText,
resolveTaskLinkDisplayText,
} from "./taskLinkDisplayText";
@ -20,6 +22,7 @@ const tasknotesLogger = createTaskNotesLogger({ tag: "Editor/ReadingModeTaskLink
export interface ReadingModeSourceLink {
target: string;
hasAlias: boolean;
subpath?: string;
}
export interface ReadingModeSourceLinkCursor {
@ -91,6 +94,7 @@ export function parseReadingModeSourceLinks(sourceText: string): ReadingModeSour
links.push({
target,
hasAlias: pipeIndex !== -1,
subpath: extractTaskLinkSubpath(rawTarget),
});
}
@ -149,6 +153,7 @@ export function shouldSkipReadingModeTaskLinkOverlay(options: {
hasExplicitAlias: boolean;
linkText: string;
originalLinkPath: string;
taskSubpath?: string;
taskPath?: string;
taskTitle: string;
}): boolean {
@ -156,7 +161,21 @@ export function shouldSkipReadingModeTaskLinkOverlay(options: {
if (options.hasExplicitAlias) return true;
const currentText = options.linkText.trim();
if (isImplicitTaskLinkDisplayText(currentText, options.taskPath, options.originalLinkPath)) {
const subpathDisplayText = formatTaskLinkSubpathDisplayText(
options.taskTitle,
options.taskSubpath ?? extractTaskLinkSubpath(options.originalLinkPath)
);
if (subpathDisplayText && currentText === subpathDisplayText) {
return false;
}
if (
isImplicitTaskLinkDisplayText(
currentText,
options.taskPath,
options.originalLinkPath,
options.taskTitle
)
) {
return false;
}
return currentText !== options.originalLinkPath && currentText !== options.taskTitle;
@ -222,7 +241,8 @@ export class ReadingModeTaskLinkProcessor {
linkEl,
ctx.sourcePath,
"internal",
sourceLink?.hasAlias ?? false
sourceLink?.hasAlias ?? false,
sourceLink?.subpath
);
}
// Process other links that might be markdown links to internal files
@ -236,7 +256,8 @@ export class ReadingModeTaskLinkProcessor {
linkEl,
ctx.sourcePath,
"external",
sourceLink?.hasAlias ?? false
sourceLink?.hasAlias ?? false,
sourceLink?.subpath
);
}
}
@ -250,7 +271,8 @@ export class ReadingModeTaskLinkProcessor {
linkEl: HTMLAnchorElement,
sourcePath: string,
linkType: "internal" | "external",
hasExplicitAlias: boolean
hasExplicitAlias: boolean,
implicitSubpath?: string
): Promise<void> {
try {
// Get the link path from the href attribute
@ -294,7 +316,13 @@ export class ReadingModeTaskLinkProcessor {
if (!taskInfo) return;
// Create a task widget and replace the link
await this.replaceWithTaskWidget(linkEl, taskInfo, linkPath, hasExplicitAlias);
await this.replaceWithTaskWidget(
linkEl,
taskInfo,
linkPath,
hasExplicitAlias,
implicitSubpath
);
} catch (error) {
tasknotesLogger.debug("Error processing link in reading mode:", {
category: "persistence",
@ -511,11 +539,16 @@ export class ReadingModeTaskLinkProcessor {
linkEl: HTMLAnchorElement,
taskInfo: TaskInfo,
originalLinkPath: string,
hasExplicitAlias: boolean
hasExplicitAlias: boolean,
implicitSubpath?: string
): Promise<void> {
try {
// Get the original link text for display
const originalText = linkEl.textContent || `[[${originalLinkPath}]]`;
const implicitSubpathDisplayText = formatTaskLinkSubpathDisplayText(
taskInfo.title,
implicitSubpath ?? extractTaskLinkSubpath(originalLinkPath)
);
// Check for alias exclusion
if (
@ -524,6 +557,7 @@ export class ReadingModeTaskLinkProcessor {
hasExplicitAlias,
linkText: linkEl.textContent || "",
originalLinkPath,
taskSubpath: implicitSubpath,
taskPath: taskInfo.path,
taskTitle: taskInfo.title,
})
@ -536,6 +570,8 @@ export class ReadingModeTaskLinkProcessor {
const linkContent = linkEl.textContent || "";
if (hasExplicitAlias) {
displayText = linkContent.trim() || undefined;
} else if (implicitSubpathDisplayText) {
displayText = implicitSubpathDisplayText;
} else if (linkContent !== originalLinkPath && linkContent !== taskInfo.title) {
displayText = resolveTaskLinkDisplayText(
linkContent,

View file

@ -18,13 +18,23 @@ import {
import { TaskLinkDetectionService } from "../services/TaskLinkDetectionService";
import { TaskLinkWidget } from "./TaskLinkWidget";
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
import { resolveTaskLinkDisplayText } from "./taskLinkDisplayText";
import {
formatTaskLinkSubpathDisplayText,
resolveTaskLinkDisplayText,
} from "./taskLinkDisplayText";
const tasknotesLogger = createTaskNotesLogger({ tag: "Editor/TaskLinkOverlay" });
// Define a state effect for task updates
const taskUpdateEffect = StateEffect.define<{ taskPath?: string }>();
interface ParsedTaskLink {
linkPath: string;
displayText?: string;
subpath?: string;
hasExplicitAlias: boolean;
}
// Create a ViewPlugin factory that takes the plugin as a parameter
export function createTaskLinkViewPlugin(plugin: TaskNotesPlugin) {
// Track widget instances for updates
@ -348,14 +358,23 @@ export function buildTaskLinkDecorations(
const widgetKey = `${resolvedPath}-${link.start}-${link.end}`;
// Always create a new widget with the current task info
const displayText =
link.type === "markdown"
let displayText: string | undefined;
if (link.type === "markdown") {
displayText = parsed.displayText
? resolveTaskLinkDisplayText(
parsed.displayText,
taskInfo.path,
linkPath
)
: parsed.displayText;
: formatTaskLinkSubpathDisplayText(taskInfo.title, parsed.subpath);
} else if (parsed.hasExplicitAlias) {
displayText = parsed.displayText;
} else {
displayText = formatTaskLinkSubpathDisplayText(
taskInfo.title,
parsed.subpath
);
}
const newWidget = new TaskLinkWidget(
taskInfo,
plugin,
@ -423,7 +442,7 @@ export function buildTaskLinkDecorations(
// Synchronous helper functions for State Field context
function parseWikilinkSync(
wikilinkText: string
): { linkPath: string; displayText?: string } | null {
): ParsedTaskLink | null {
// Validate input
if (!wikilinkText || typeof wikilinkText !== "string") {
return null;
@ -465,6 +484,8 @@ function parseWikilinkSync(
return {
linkPath: parsed.path,
displayText: aliasPart,
subpath: parsed.subpath || undefined,
hasExplicitAlias: true,
};
}
@ -478,13 +499,14 @@ function parseWikilinkSync(
return {
linkPath: parsed.path,
displayText: parsed.subpath || undefined,
subpath: parsed.subpath || undefined,
hasExplicitAlias: false,
};
}
function parseMarkdownLinkSync(
markdownLinkText: string
): { linkPath: string; displayText?: string } | null {
): ParsedTaskLink | null {
// Validate input
if (!markdownLinkText || typeof markdownLinkText !== "string") {
return null;
@ -528,7 +550,9 @@ function parseMarkdownLinkSync(
return {
linkPath: parsed.path,
displayText: displayText || parsed.subpath || undefined,
displayText: displayText || undefined,
subpath: parsed.subpath || undefined,
hasExplicitAlias: Boolean(displayText),
};
}

View file

@ -22,18 +22,46 @@ function normalizePathLikeLabel(value: string | undefined): string {
return withoutLeadingSlash.replace(/\.md$/i, "").trim();
}
function normalizeSubpathLabel(value: string | undefined): string {
if (!value) return "";
const decoded = safeDecodeURIComponent(value).trim();
return decoded.replace(/^[#^]+/, "").trim();
}
function basename(value: string): string {
const normalized = normalizePathLikeLabel(value);
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
export function extractTaskLinkSubpath(linkPath: string | undefined): string | undefined {
if (!linkPath) return undefined;
const decoded = safeDecodeURIComponent(stripMarkdownPathBrackets(linkPath));
const subpathIndex = decoded.search(/[#^]/);
if (subpathIndex === -1) return undefined;
return normalizeSubpathLabel(decoded.slice(subpathIndex)) || undefined;
}
export function formatTaskLinkSubpathDisplayText(
taskTitle: string | undefined,
subpath: string | undefined
): string | undefined {
const normalizedSubpath = normalizeSubpathLabel(subpath);
if (!normalizedSubpath) return undefined;
const normalizedTitle = taskTitle?.trim();
if (!normalizedTitle) return normalizedSubpath;
return `${normalizedTitle} > ${normalizedSubpath}`;
}
export function isImplicitTaskLinkDisplayText(
displayText: string | undefined,
taskPath: string | undefined,
linkPath?: string
linkPath?: string,
taskTitle?: string
): boolean {
const normalizedDisplay = normalizePathLikeLabel(displayText);
const normalizedDisplay = normalizePathLikeLabel(displayText) || normalizeSubpathLabel(displayText);
if (!normalizedDisplay || !taskPath) return false;
const normalizedTaskPath = normalizePathLikeLabel(taskPath);
@ -48,6 +76,15 @@ export function isImplicitTaskLinkDisplayText(
candidates.add(basename(normalizedLinkPath));
}
const normalizedSubpath = extractTaskLinkSubpath(linkPath);
if (normalizedSubpath) {
candidates.add(normalizedSubpath);
const formattedSubpath = formatTaskLinkSubpathDisplayText(taskTitle, normalizedSubpath);
if (formattedSubpath) {
candidates.add(formattedSubpath);
}
}
return candidates.has(normalizedDisplay);
}

View file

@ -2,7 +2,10 @@ import { TFile, parseLinktext } from "obsidian";
import { TaskInfo } from "../types";
import TaskNotesPlugin from "../main";
import { createTaskNotesLogger, type TaskNotesLogger } from "../utils/tasknotesLogger";
import { resolveTaskLinkDisplayText } from "../editor/taskLinkDisplayText";
import {
formatTaskLinkSubpathDisplayText,
resolveTaskLinkDisplayText,
} from "../editor/taskLinkDisplayText";
export interface TaskLinkInfo {
isValidTaskLink: boolean;
@ -11,6 +14,13 @@ export interface TaskLinkInfo {
displayText?: string;
}
interface ParsedTaskLink {
linkPath: string;
displayText?: string;
subpath?: string;
hasExplicitAlias: boolean;
}
export class TaskLinkDetectionService {
private plugin: TaskNotesPlugin;
private linkCache = new Map<string, { result: TaskLinkInfo; lastModified: number }>();
@ -75,10 +85,13 @@ export class TaskLinkDetectionService {
try {
const taskInfo = await this.plugin.cacheManager.getTaskInfo(resolvedPath);
if (taskInfo) {
const displayText =
linkType === "markdown"
? resolveTaskLinkDisplayText(parsedDisplayText, taskInfo.path, linkPath)
: parsedDisplayText;
const displayText = this.resolveDisplayText(
parsed,
linkType,
taskInfo,
linkPath,
parsedDisplayText
);
const result: TaskLinkInfo = {
isValidTaskLink: true,
taskPath: resolvedPath,
@ -102,10 +115,31 @@ export class TaskLinkDetectionService {
return result;
}
private resolveDisplayText(
parsed: ParsedTaskLink,
linkType: "wikilink" | "markdown",
taskInfo: TaskInfo,
linkPath: string,
parsedDisplayText?: string
): string | undefined {
if (linkType === "markdown") {
if (parsedDisplayText) {
return resolveTaskLinkDisplayText(parsedDisplayText, taskInfo.path, linkPath);
}
return formatTaskLinkSubpathDisplayText(taskInfo.title, parsed.subpath);
}
if (parsed.hasExplicitAlias) {
return parsedDisplayText;
}
return formatTaskLinkSubpathDisplayText(taskInfo.title, parsed.subpath);
}
/**
* Parse wikilink syntax to extract link path and display text
*/
private parseWikilink(wikilinkText: string): { linkPath: string; displayText?: string } | null {
private parseWikilink(wikilinkText: string): ParsedTaskLink | null {
// Remove the [[ and ]] brackets
const content = wikilinkText.slice(2, -2).trim();
if (!content) return null;
@ -123,6 +157,8 @@ export class TaskLinkDetectionService {
return {
linkPath: parsed.path,
displayText: aliasPart,
subpath: parsed.subpath || undefined,
hasExplicitAlias: true,
};
}
@ -130,7 +166,8 @@ export class TaskLinkDetectionService {
const parsed = parseLinktext(content);
return {
linkPath: parsed.path,
displayText: parsed.subpath || undefined,
subpath: parsed.subpath || undefined,
hasExplicitAlias: false,
};
}
@ -139,7 +176,7 @@ export class TaskLinkDetectionService {
*/
private parseMarkdownLink(
markdownLinkText: string
): { linkPath: string; displayText?: string } | null {
): ParsedTaskLink | null {
// Parse markdown link: [text](path)
const match = markdownLinkText.match(/^\[([^\]]*)\]\(([^)]+)\)$/);
if (!match) return null;
@ -172,7 +209,9 @@ export class TaskLinkDetectionService {
return {
linkPath: parsed.path,
displayText: displayText || parsed.subpath || undefined,
displayText: displayText || undefined,
subpath: parsed.subpath || undefined,
hasExplicitAlias: Boolean(displayText),
};
}

View file

@ -0,0 +1,128 @@
import { EditorSelection, EditorState } from "@codemirror/state";
import { TFile } from "obsidian";
import { buildTaskLinkDecorations } from "../../../src/editor/TaskLinkOverlay";
import { TaskLinkWidget } from "../../../src/editor/TaskLinkWidget";
import { TaskLinkDetectionService } from "../../../src/services/TaskLinkDetectionService";
import { PluginFactory, TaskFactory } from "../../helpers/mock-factories";
import type TaskNotesPlugin from "../../../src/main";
import type { TaskInfo } from "../../../src/types";
jest.mock("../../../src/editor/TaskLinkWidget");
const MockTaskLinkWidget = TaskLinkWidget as jest.MockedClass<typeof TaskLinkWidget>;
describe("Issue #2072: heading task links keep the task name visible", () => {
let plugin: TaskNotesPlugin;
let task: TaskInfo;
let activeWidgets: Map<string, TaskLinkWidget>;
let displayTexts: Array<string | undefined>;
beforeEach(() => {
jest.clearAllMocks();
displayTexts = [];
task = TaskFactory.createTask({
path: "Tasks/Buy groceries.md",
title: "Buy groceries",
status: "todo",
});
plugin = PluginFactory.createMockPlugin({
settings: {
enableTaskLinkOverlay: true,
},
app: {
workspace: {
getActiveViewOfType: jest.fn().mockReturnValue({
file: {
path: "Notes/Daily.md",
},
}),
},
metadataCache: {
getFirstLinkpathDest: jest.fn().mockImplementation((linkPath: string) => {
if (linkPath === "Buy groceries") {
return { path: task.path };
}
return null;
}),
},
vault: {
getAbstractFileByPath: jest.fn().mockImplementation((path: string) => {
if (path === task.path) {
const file = new TFile(path);
Object.defineProperty(file, "stat", {
value: { mtime: 1 },
configurable: true,
});
return file;
}
return null;
}),
},
},
cacheManager: {
...PluginFactory.createMockPlugin().cacheManager,
getCachedTaskInfoSync: jest.fn().mockImplementation((path: string) => {
return path === task.path ? task : null;
}),
getTaskInfo: jest.fn().mockImplementation(async (path: string) => {
return path === task.path ? task : null;
}),
},
});
activeWidgets = new Map();
MockTaskLinkWidget.mockImplementation((taskInfo, mockPlugin, originalText, displayText) => {
displayTexts.push(displayText);
return {
toDOM: jest.fn(),
eq: jest.fn().mockReturnValue(false),
taskInfo,
plugin: mockPlugin,
originalText,
displayText,
} as unknown as TaskLinkWidget;
});
});
it("labels a no-alias heading wikilink with task title and heading", () => {
const state = EditorState.create({
doc: "link to heading: [[Buy groceries#sample heading]]",
selection: EditorSelection.single(0),
});
const decorations = buildTaskLinkDecorations(state, plugin, activeWidgets);
expect(decorations.size).toBe(1);
expect(displayTexts).toEqual(["Buy groceries > sample heading"]);
});
it("continues to honor explicit aliases on heading wikilinks", () => {
const state = EditorState.create({
doc: "link to heading: [[Buy groceries#sample heading|shopping note]]",
selection: EditorSelection.single(0),
});
const decorations = buildTaskLinkDecorations(state, plugin, activeWidgets);
expect(decorations.size).toBe(1);
expect(displayTexts).toEqual(["shopping note"]);
});
it("returns the same label through the async detection service", async () => {
const service = new TaskLinkDetectionService(plugin);
const result = await service.detectTaskLink(
"[[Buy groceries#sample heading]]",
"Notes/Daily.md"
);
expect(result).toMatchObject({
isValidTaskLink: true,
taskPath: task.path,
displayText: "Buy groceries > sample heading",
});
});
});