fix markdown project links in relationships bases

This commit is contained in:
callumalpass 2026-05-20 17:27:44 +10:00
parent f92f88027c
commit 2ddb43d2f0
5 changed files with 94 additions and 160 deletions

View file

@ -697,7 +697,7 @@ views:
filters:
and:
- file.hasTag("task")
- note.projects.contains(this.file.asLink())
- file.hasLink(this.file) && list(note.projects).map(file(value.replace(/^\[[^\]]+\]\((.*)\)$/, "$1").replace(/%20/g, " ")).asLink()).contains(this.file.asLink())
order:
- status
- priority
@ -721,7 +721,7 @@ views:
name: "Projects"
filters:
and:
- list(this.projects).contains(file.asLink())
- list(this.projects).map(file(value.replace(/^\[[^\]]+\]\((.*)\)$/, "$1").replace(/%20/g, " ")).asLink()).contains(file.asLink())
order:
- status
- priority

View file

@ -60,75 +60,6 @@ export class ProjectSubtasksService {
return linkingSources;
}
private getMarkdownFiles(): TFile[] {
if (typeof this.plugin.app.vault.getMarkdownFiles !== "function") {
return [];
}
return this.plugin.app.vault.getMarkdownFiles();
}
private getTaskProjectValues(sourceFilePath: string): string[] {
const sourceFile = this.plugin.app.vault.getAbstractFileByPath(sourceFilePath);
const metadata =
sourceFile instanceof TFile
? this.plugin.app.metadataCache.getFileCache(sourceFile)
: this.plugin.app.metadataCache.getCache(sourceFilePath);
if (!metadata?.frontmatter) return [];
if (
this.plugin.cacheManager?.isTaskFile &&
!this.plugin.cacheManager.isTaskFile(metadata.frontmatter)
) {
return [];
}
const projectsFieldName = this.plugin.fieldMapper.toUserField("projects");
const projects = metadata.frontmatter[projectsFieldName];
if (Array.isArray(projects)) {
return projects.filter((project): project is string => typeof project === "string");
}
if (typeof projects === "string") {
return [projects];
}
return [];
}
private resolveProjectReference(project: string, sourceFilePath: string): TFile | null {
const trimmedProject = project.trim();
if (!trimmedProject) return null;
// Parse the link to extract the path (handles wikilinks, markdown links, and angle paths)
const linkPath = parseLinkToPath(trimmedProject);
// Skip plain text project names; this service only follows actual links.
if (
linkPath === trimmedProject &&
!trimmedProject.startsWith("[[") &&
!trimmedProject.startsWith("[") &&
!trimmedProject.startsWith("<")
) {
return null;
}
return this.plugin.app.metadataCache.getFirstLinkpathDest(linkPath, sourceFilePath);
}
private async getFilesReferencingProjectInFrontmatter(projectPath: string): Promise<string[]> {
const sources: string[] = [];
for (const file of this.getMarkdownFiles()) {
if (await this.isLinkFromProjectsField(file.path, projectPath)) {
sources.push(file.path);
}
}
return sources;
}
/**
* Check for unresolved project references (broken links)
* Useful for debugging and maintenance
@ -151,10 +82,7 @@ export class ProjectSubtasksService {
*/
async getTasksLinkedToProject(projectFile: TFile): Promise<TaskInfo[]> {
try {
const linkingSources = new Set([
...this.getFilesLinkingToProject(projectFile.path),
...(await this.getFilesReferencingProjectInFrontmatter(projectFile.path)),
]);
const linkingSources = this.getFilesLinkingToProject(projectFile.path);
const linkedTasks: TaskInfo[] = [];
for (const sourcePath of linkingSources) {
@ -195,9 +123,36 @@ export class ProjectSubtasksService {
targetFilePath: string
): Promise<boolean> {
try {
const sourceFile = this.plugin.app.vault.getAbstractFileByPath(sourceFilePath);
if (!(sourceFile instanceof TFile)) return false;
const metadata = this.plugin.app.metadataCache.getFileCache(sourceFile);
// Use the user's configured field mapping for projects
const projectsFieldName = this.plugin.fieldMapper.toUserField("projects");
if (!metadata?.frontmatter?.[projectsFieldName]) return false;
const projects = metadata.frontmatter[projectsFieldName];
if (!Array.isArray(projects)) return false;
// Check if any project reference resolves to our target
for (const project of this.getTaskProjectValues(sourceFilePath)) {
const resolvedFile = this.resolveProjectReference(project, sourceFilePath);
for (const project of projects) {
if (!project || typeof project !== "string") continue;
// Parse the link to extract the path (handles both wikilinks and markdown links)
const linkPath = parseLinkToPath(project);
// Skip if not a link format
if (linkPath === project && !project.startsWith("[[")) {
continue; // Plain text, not a link
}
// Resolve the link to get the actual file
const resolvedFile = this.plugin.app.metadataCache.getFirstLinkpathDest(
linkPath,
sourceFilePath
);
if (resolvedFile && resolvedFile.path === targetFilePath) {
return true;
}
@ -237,18 +192,45 @@ export class ProjectSubtasksService {
this.stats.indexBuilds++;
try {
const sourcePaths = new Set<string>(Object.keys(this.plugin.app.metadataCache.resolvedLinks));
const resolvedLinks = this.plugin.app.metadataCache.resolvedLinks;
const projectPaths = new Set<string>();
for (const file of this.getMarkdownFiles()) {
sourcePaths.add(file.path);
}
// Single pass through all resolved links to find project targets
for (const [sourcePath, targets] of Object.entries(resolvedLinks)) {
if (!targets) continue;
// Check if source has projects frontmatter
const metadata = this.plugin.app.metadataCache.getCache(sourcePath);
// Validate that the source file is actually a task (issue #953)
// Only tasks should be able to create project relationships
if (!metadata?.frontmatter) continue;
if (!this.plugin.cacheManager.isTaskFile(metadata.frontmatter)) continue;
// Use the user's configured field mapping for projects
const projectsFieldName = this.plugin.fieldMapper.toUserField("projects");
const projects = metadata.frontmatter[projectsFieldName];
if (!Array.isArray(projects)) continue;
// Single pass through candidate task files to find project targets.
for (const sourcePath of sourcePaths) {
// Check if any project reference resolves to our target
for (const project of this.getTaskProjectValues(sourcePath)) {
const resolvedFile = this.resolveProjectReference(project, sourcePath);
for (const project of projects) {
if (!project || typeof project !== "string") continue;
// Parse the link to extract the path (handles both wikilinks and markdown links)
const linkPath = parseLinkToPath(project);
// Skip if not a link format
if (linkPath === project && !project.startsWith("[[")) {
continue; // Plain text, not a link
}
// Resolve the link to get the actual file
const resolvedFile = this.plugin.app.metadataCache.getFirstLinkpathDest(
linkPath,
sourcePath
);
if (resolvedFile) {
projectPaths.add(resolvedFile.path);
}

View file

@ -32,6 +32,10 @@ function formatDependencyEntryLinkExpression(entryExpression: string): string {
return `${formatDependencyEntryFileExpression(entryExpression)}.asLink()`;
}
function formatProjectEntryLinkExpression(entryExpression: string): string {
return `file(${entryExpression}.replace(/^\\[[^\\]]+\\]\\((.*)\\)$/, "$1").replace(/%20/g, " ")).asLink()`;
}
/**
* Generate a task filter expression based on the task identification method
* Returns the filter condition string (not the full YAML structure)
@ -928,7 +932,7 @@ views:
filters:
and:
- ${taskFilterCondition}
- list(note.${projectsProperty}).contains(this.file.asLink())
- file.hasLink(this.file) && list(note.${projectsProperty}).map(${formatProjectEntryLinkExpression("value")}).contains(this.file.asLink())
order:
${orderYaml}
sort:
@ -941,7 +945,7 @@ ${orderYaml}
name: "Projects"
filters:
and:
- list(this.${projectsProperty}).contains(file.asLink())
- list(this.${projectsProperty}).map(${formatProjectEntryLinkExpression("value")}).contains(file.asLink())
order:
${orderYaml}
- type: tasknotesTaskList

View file

@ -42,10 +42,29 @@ function createMockPlugin() {
}
describe("Issue #1535: relationships project filters handle single Link values", () => {
it("wraps the Subtasks projects property with list() before contains()", () => {
it("normalizes the Subtasks projects property before contains()", () => {
const template = generateBasesFileTemplate("relationships", createMockPlugin() as any);
const normalizedProjectLink = String.raw`file(value.replace(/^\[[^\]]+\]\((.*)\)$/, "$1").replace(/%20/g, " ")).asLink()`;
expect(template).toContain("list(note.projects).contains(this.file.asLink())");
expect(template).toContain(
`file.hasLink(this.file) && list(note.projects).map(${normalizedProjectLink}).contains(this.file.asLink())`
);
expect(template).not.toContain("note.projects.contains(this.file.asLink())");
});
});
describe("Issue #1902: relationships project filters handle Markdown project links", () => {
it("normalizes task project entries in both relationship directions", () => {
const template = generateBasesFileTemplate("relationships", createMockPlugin() as any);
const normalizedProjectLink = String.raw`file(value.replace(/^\[[^\]]+\]\((.*)\)$/, "$1").replace(/%20/g, " ")).asLink()`;
expect(template).toContain(
`file.hasLink(this.file) && list(note.projects).map(${normalizedProjectLink}).contains(this.file.asLink())`
);
expect(template).toContain(
`list(this.projects).map(${normalizedProjectLink}).contains(file.asLink())`
);
expect(template).not.toContain("list(note.projects).contains(this.file.asLink())");
expect(template).not.toContain("list(this.projects).contains(file.asLink())");
});
});

View file

@ -1,71 +0,0 @@
import { TFile } from "obsidian";
import { ProjectSubtasksService } from "../../../src/services/ProjectSubtasksService";
function createFile(path: string): TFile {
return new TFile(path);
}
describe("Issue #1902: markdown project links in frontmatter", () => {
it("finds subtasks when frontmatter markdown links are absent from resolvedLinks", async () => {
const taskFile = createFile("Tasks/filament-order.md");
const projectFile = createFile("projects/3D Printing.md");
const taskInfo = {
path: taskFile.path,
title: "Order filament",
status: "open",
priority: "normal",
archived: false,
projects: ["[3D Printing](../projects/3D%20Printing.md)"],
};
const taskFrontmatter = {
tags: ["task"],
projects: ["[3D Printing](../projects/3D%20Printing.md)"],
};
const plugin = {
app: {
vault: {
getMarkdownFiles: jest.fn(() => [taskFile, projectFile]),
getAbstractFileByPath: jest.fn((path: string) => {
if (path === taskFile.path) return taskFile;
if (path === projectFile.path) return projectFile;
return null;
}),
},
metadataCache: {
resolvedLinks: {},
unresolvedLinks: {},
getCache: jest.fn((path: string) =>
path === taskFile.path ? { frontmatter: taskFrontmatter } : null
),
getFileCache: jest.fn((file: TFile) =>
file.path === taskFile.path ? { frontmatter: taskFrontmatter } : null
),
getFirstLinkpathDest: jest.fn((linkpath: string) =>
linkpath.includes("3D Printing.md") ? projectFile : null
),
},
},
cacheManager: {
isTaskFile: jest.fn((frontmatter: Record<string, unknown>) =>
Array.isArray(frontmatter.tags) && frontmatter.tags.includes("task")
),
getTaskInfo: jest.fn(async (path: string) => (path === taskFile.path ? taskInfo : null)),
},
fieldMapper: {
toUserField: jest.fn((field: string) => field),
},
statusManager: {
isCompletedStatus: jest.fn(() => false),
},
priorityManager: {
getPriorityWeight: jest.fn(() => 0),
},
};
const service = new ProjectSubtasksService(plugin as never);
await expect(service.getTasksLinkedToProject(projectFile)).resolves.toEqual([taskInfo]);
expect(service.isTaskUsedAsProjectSync(projectFile.path)).toBe(true);
});
});