refactor: improve link handling with Obsidian API

Replace manual wikilink generation and parsing with Obsidian's native
APIs throughout the codebase for better compatibility and correctness.

Changes:
- Create linkUtils.ts with centralized link generation helpers
- Use FileManager.generateMarkdownLink() for all link creation
  - Respects user's link format settings (wikilink vs markdown)
  - Handles relative paths according to user preferences
  - Future-proof against Obsidian link format changes
- Use parseLinktext() for proper wikilink parsing
  - Correctly handles [[link|alias]] syntax
  - Eliminates manual regex patterns

Files updated:
- TaskContextMenu: project references and subtask creation
- TaskModal/TaskEditModal: project and subtask list rendering
- dependencyUtils: link formatting and resolution
- ProjectNoteDecorations: new subtask creation
- TaskCard: project comparison with alias support
- StatsView: wikilink path extraction

Benefits:
- Better compatibility with user settings
- Proper alias handling throughout
- Less code to maintain
- More robust link parsing
This commit is contained in:
callumalpass 2025-09-30 19:31:12 +10:00
parent 0329945061
commit 918297a2f6
8 changed files with 113 additions and 29 deletions

View file

@ -15,6 +15,7 @@ import {
formatDependencyLink,
normalizeDependencyEntry,
} from "../utils/dependencyUtils";
import { generateLink } from "../utils/linkUtils";
export interface TaskContextMenuOptions {
task: TaskInfo;
@ -572,7 +573,7 @@ export class TaskContextMenu {
item.onClick(() => {
const taskFile = plugin.app.vault.getAbstractFileByPath(task.path);
if (taskFile instanceof TFile) {
const projectReference = `[[${taskFile.basename}]]`;
const projectReference = generateLink(plugin.app, taskFile, task.path);
plugin.openTaskCreationModal({
projects: [projectReference],
});
@ -904,7 +905,7 @@ export class TaskContextMenu {
return;
}
const projectReference = this.buildProjectReference(projectFile, task.path, plugin);
const projectReference = generateLink(plugin.app, projectFile, task.path);
const legacyReference = `[[${projectFile.basename}]]`;
const currentProjects = Array.isArray(task.projects) ? task.projects : [];
@ -939,7 +940,7 @@ export class TaskContextMenu {
return;
}
const projectReference = this.buildProjectReference(currentTaskFile, subtask.path, plugin);
const projectReference = generateLink(plugin.app, currentTaskFile, subtask.path);
const legacyReference = `[[${currentTaskFile.basename}]]`;
const subtaskProjects = Array.isArray(subtask.projects) ? subtask.projects : [];
@ -968,8 +969,7 @@ export class TaskContextMenu {
}
private buildProjectReference(targetFile: TFile, sourcePath: string, plugin: TaskNotesPlugin): string {
const linkText = plugin.app.metadataCache.fileToLinktext(targetFile, sourcePath, true);
return `[[${linkText}]]`;
return generateLink(plugin.app, targetFile, sourcePath);
}
private updateMainMenuIconColors(task: TaskInfo, plugin: TaskNotesPlugin): void {

View file

@ -35,6 +35,7 @@ import { GroupingUtils } from "../utils/GroupingUtils";
import { ProjectSubtasksService } from "../services/ProjectSubtasksService";
import TaskNotesPlugin from "../main";
import { createTaskCard } from "../ui/TaskCard";
import { generateLink } from "../utils/linkUtils";
// Define a state effect for project subtasks updates
const projectSubtasksUpdateEffect = StateEffect.define<{ forceUpdate?: boolean }>();
@ -691,8 +692,8 @@ export class ProjectSubtasksWidget extends WidgetType {
return;
}
// Create wikilink format for the project reference
const projectReference = `[[${currentFile.basename}]]`;
// Create link using Obsidian's API for proper format
const projectReference = generateLink(this.plugin.app, currentFile, currentFile.path);
// Open task creation modal with project pre-populated
this.plugin.openTaskCreationModal({

View file

@ -25,6 +25,7 @@ import {
} from "../utils/helpers";
import { splitListPreservingLinksAndQuotes } from "../utils/stringSplit";
import { ReminderContextMenu } from "../components/ReminderContextMenu";
import { generateLinkWithDisplay } from "../utils/linkUtils";
export interface TaskEditOptions {
task: TaskInfo;
@ -953,11 +954,13 @@ export class TaskEditModal extends TaskModal {
}
this.selectedProjectFiles.forEach((file) => {
if (!(file instanceof TFile)) return;
const projectItem = this.projectsList.createDiv({ cls: "task-project-item" });
const infoEl = projectItem.createDiv({ cls: "task-project-info" });
const nameEl = infoEl.createDiv({ cls: "task-project-name clickable-project" });
const projectAsWikilink = `[[${file.path}|${file.name}]]`;
const projectAsWikilink = generateLinkWithDisplay(this.app, file, this.task.path, file.name);
this.renderProjectLinksWithoutPrefix(nameEl, [projectAsWikilink]);
if (file.path !== file.name) {

View file

@ -30,6 +30,7 @@ import {
type LinkServices,
} from "../ui/renderers/linkRenderer";
import { TaskSelectorModal } from "./TaskSelectorModal";
import { generateLink, generateLinkWithDisplay } from "../utils/linkUtils";
interface DependencyItem {
dependency: TaskDependency;
@ -98,14 +99,19 @@ export abstract class TaskModal extends Modal {
};
}
// For unresolved dependencies, create a minimal file-like object to generate link
const basename = path.split("/").pop() || path;
const nameWithoutExt = basename.replace(/\.md$/i, "");
// Use a simple wikilink format for unresolved dependencies
// This will be resolved when the file is created
return {
dependency: {
uid: `[[${basename.replace(/\.md$/i, "")}]]`,
uid: `[[${nameWithoutExt}]]`,
reltype: DEFAULT_DEPENDENCY_RELTYPE,
},
path,
name: basename.replace(/\.md$/i, ""),
name: nameWithoutExt,
unresolved: true,
};
}
@ -1378,8 +1384,7 @@ export abstract class TaskModal extends Modal {
}
protected buildProjectReference(targetFile: TFile, sourcePath: string): string {
const linkText = this.app.metadataCache.fileToLinktext(targetFile, sourcePath, true);
return `[[${linkText}]]`;
return generateLink(this.app, targetFile, sourcePath);
}
protected initializeProjectsFromStrings(projects: string[]): void {
@ -1431,11 +1436,13 @@ export abstract class TaskModal extends Modal {
}
this.selectedProjectFiles.forEach((file) => {
if (!(file instanceof TFile)) return;
const projectItem = this.projectsList.createDiv({ cls: "task-project-item" });
const infoEl = projectItem.createDiv({ cls: "task-project-info" });
const nameEl = infoEl.createDiv({ cls: "task-project-name clickable-project" });
const projectAsWikilink = `[[${file.path}|${file.name}]]`;
const projectAsWikilink = generateLinkWithDisplay(this.app, file, this.getCurrentTaskPath() || "", file.name);
this.renderProjectLinksWithoutPrefix(nameEl, [projectAsWikilink]);
if (file.path !== file.name) {
@ -1515,11 +1522,13 @@ export abstract class TaskModal extends Modal {
}
this.selectedSubtaskFiles.forEach((file) => {
if (!(file instanceof TFile)) return;
const subtaskItem = this.subtasksList.createDiv({ cls: "task-project-item" });
const infoEl = subtaskItem.createDiv({ cls: "task-project-info" });
const nameEl = infoEl.createDiv({ cls: "task-project-name clickable-project" });
const taskLink = `[[${file.path}|${file.name}]]`;
const taskLink = generateLinkWithDisplay(this.app, file, this.getCurrentTaskPath() || "", file.name);
this.renderProjectLinksWithoutPrefix(nameEl, [taskLink]);
if (file.path !== file.name) {

View file

@ -1,5 +1,5 @@
/* eslint-disable no-console */
import { TFile, setIcon, Notice, Modal, App, setTooltip } from "obsidian";
import { TFile, setIcon, Notice, Modal, App, setTooltip, parseLinktext } from "obsidian";
import { TaskInfo } from "../types";
import TaskNotesPlugin from "../main";
import { TaskContextMenu } from "../components/TaskContextMenu";
@ -2300,7 +2300,8 @@ export async function refreshParentTaskSubtasks(
project.startsWith("[[") &&
project.endsWith("]]")
) {
const linkedNoteName = project.slice(2, -2).trim();
const linkContent = project.slice(2, -2).trim();
const linkedNoteName = parseLinktext(linkContent).path;
// Check both exact match and resolved file match
const resolvedFile = plugin.app.metadataCache.getFirstLinkpathDest(
linkedNoteName,

View file

@ -1,6 +1,7 @@
import { App, TFile } from "obsidian";
import { App, TFile, parseLinktext } from "obsidian";
import type { TaskDependency, TaskDependencyRelType } from "../types";
import { splitListPreservingLinksAndQuotes } from "./stringSplit";
import { generateLink } from "./linkUtils";
export const DEFAULT_DEPENDENCY_RELTYPE: TaskDependencyRelType = "FINISHTOSTART";
@ -127,11 +128,12 @@ export function resolveDependencyEntry(
return null;
}
// Use Obsidian's parseLinktext for proper wikilink parsing
// This handles both [[link]] and [[link|alias]] formats correctly
let target = trimmed;
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
const inner = trimmed.slice(2, -2).trim();
const pipeIndex = inner.indexOf("|");
target = pipeIndex >= 0 ? inner.substring(0, pipeIndex).trim() : inner;
target = parseLinktext(inner).path;
}
if (!target) {
@ -154,10 +156,10 @@ export function resolveDependencyEntry(
export function formatDependencyLink(app: App, sourcePath: string, targetPath: string): string {
const target = app.vault.getAbstractFileByPath(targetPath);
if (target instanceof TFile) {
const linktext = app.metadataCache.fileToLinktext(target, sourcePath, false);
return `[[${linktext}]]`;
return generateLink(app, target, sourcePath);
}
// For unresolved files, create a simple wikilink with the basename
const basename = targetPath.split("/").pop() || targetPath;
return `[[${basename.replace(/\.md$/i, "")}]]`;
}

73
src/utils/linkUtils.ts Normal file
View file

@ -0,0 +1,73 @@
import { App, TFile } from "obsidian";
/**
* Generate a properly formatted markdown link using Obsidian's API.
* This respects user's link format settings (wikilink vs markdown, relative paths, etc.)
*
* @param app - Obsidian app instance
* @param targetFile - The file to link to
* @param sourcePath - The path of the file containing the link (for relative paths)
* @param subpath - Optional subpath (e.g., heading anchor)
* @param alias - Optional display alias
* @returns A properly formatted link string
*/
export function generateLink(
app: App,
targetFile: TFile,
sourcePath: string,
subpath?: string,
alias?: string
): string {
return app.fileManager.generateMarkdownLink(
targetFile,
sourcePath,
subpath || "",
alias || ""
);
}
/**
* Generate a link with the file's basename as the alias.
* Useful for creating links that display the file name.
*
* @param app - Obsidian app instance
* @param targetFile - The file to link to
* @param sourcePath - The path of the file containing the link
* @returns A link with basename as alias
*/
export function generateLinkWithBasename(
app: App,
targetFile: TFile,
sourcePath: string
): string {
return app.fileManager.generateMarkdownLink(
targetFile,
sourcePath,
"",
targetFile.basename
);
}
/**
* Generate a link with a custom path display as the alias.
* Useful for showing full paths in UI.
*
* @param app - Obsidian app instance
* @param targetFile - The file to link to
* @param sourcePath - The path of the file containing the link
* @param displayName - Custom display name
* @returns A link with custom display name as alias
*/
export function generateLinkWithDisplay(
app: App,
targetFile: TFile,
sourcePath: string,
displayName: string
): string {
return app.fileManager.generateMarkdownLink(
targetFile,
sourcePath,
"",
displayName
);
}

View file

@ -1,4 +1,4 @@
import { ItemView, WorkspaceLeaf, Setting, EventRef } from "obsidian";
import { ItemView, WorkspaceLeaf, Setting, EventRef, parseLinktext } from "obsidian";
import {
format,
startOfWeek,
@ -462,13 +462,8 @@ export class StatsView extends ItemView {
const linkContent = projectValue.slice(2, -2);
// Handle alias syntax: [[path|alias]]
const pipeIndex = linkContent.indexOf("|");
if (pipeIndex !== -1) {
return linkContent.substring(0, pipeIndex).trim();
}
return linkContent;
// Use Obsidian's parseLinktext to handle aliases properly
return parseLinktext(linkContent).path;
}
/**