mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
fix markdown project links in relationships bases
This commit is contained in:
parent
f92f88027c
commit
2ddb43d2f0
5 changed files with 94 additions and 160 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue