diff --git a/docs/views/default-base-templates.md b/docs/views/default-base-templates.md index ffc2026c..54271470 100644 --- a/docs/views/default-base-templates.md +++ b/docs/views/default-base-templates.md @@ -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 diff --git a/src/services/ProjectSubtasksService.ts b/src/services/ProjectSubtasksService.ts index 264f494c..98110a9b 100644 --- a/src/services/ProjectSubtasksService.ts +++ b/src/services/ProjectSubtasksService.ts @@ -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 { - 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 { 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 { 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(Object.keys(this.plugin.app.metadataCache.resolvedLinks)); + const resolvedLinks = this.plugin.app.metadataCache.resolvedLinks; const projectPaths = new Set(); - 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); } diff --git a/src/templates/defaultBasesFiles.ts b/src/templates/defaultBasesFiles.ts index 10e41906..03964594 100644 --- a/src/templates/defaultBasesFiles.ts +++ b/src/templates/defaultBasesFiles.ts @@ -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 diff --git a/tests/unit/issues/issue-1535-relationships-projects-list.test.ts b/tests/unit/issues/issue-1535-relationships-projects-list.test.ts index 15d0be08..a418ac8d 100644 --- a/tests/unit/issues/issue-1535-relationships-projects-list.test.ts +++ b/tests/unit/issues/issue-1535-relationships-projects-list.test.ts @@ -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())"); + }); +}); diff --git a/tests/unit/issues/issue-1902-markdown-project-frontmatter-links.test.ts b/tests/unit/issues/issue-1902-markdown-project-frontmatter-links.test.ts deleted file mode 100644 index e8e25a0e..00000000 --- a/tests/unit/issues/issue-1902-markdown-project-frontmatter-links.test.ts +++ /dev/null @@ -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) => - 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); - }); -});