Add project file path folder variables

This commit is contained in:
callumalpass 2026-05-18 06:43:48 +10:00
parent 7c87781ca2
commit 7b58399d7b
7 changed files with 123 additions and 16 deletions

View file

@ -33,6 +33,8 @@ Variables for task-specific data.
| `{{contexts}}` | All contexts (comma-separated in body, `/` in folder) | Yes | Yes | `work, home` or `work/home` |
| `{{project}}` | First project from projects array | No | Yes | `ProjectA` |
| `{{projects}}` | All projects joined by `/` | No | Yes | `ProjectA/ProjectB` |
| `{{projectFilePath}}` | Full path of the first project, without `.md` | No | Yes | `Work/Projects/ProjectA` |
| `{{projectFilePaths}}` | Full paths of all projects, joined by `/` | No | Yes | `Work/Alpha/Personal/Beta` |
| `{{dueDate}}` | Task due date | Yes | Yes | `2025-01-15` |
| `{{scheduledDate}}` | Task scheduled date | Yes | Yes | `2025-01-10` |
@ -202,7 +204,7 @@ When used in YAML frontmatter, values containing special characters (colons, bra
### Folder Path Sanitization
In folder templates, title values have filesystem-unsafe characters (`<>:"/\|?*`) replaced with underscores to ensure valid paths.
In folder templates, title values have filesystem-unsafe characters (`<>:"/\|?*`) replaced with underscores to ensure valid paths. Project file path variables preserve folder separators and sanitize unsafe characters within each path segment.
### Empty Values

View file

@ -52,6 +52,7 @@ Example:
- ([#1353](https://github.com/callumalpass/tasknotes/issues/1353)) Focused the Create Task modal's natural-language input when the modal opens, with a longer mobile delay so the keyboard can open after layout. Thanks to @GiovanH for suggesting this.
- ([#1641](https://github.com/callumalpass/tasknotes/issues/1641)) Added support for list-valued property-based Calendar start and end dates, so one note can render multiple property events without extra view settings. Thanks to @jhoogeboom for suggesting this.
- ([#1664](https://github.com/callumalpass/tasknotes/pull/1664)) Added project-based custom filename template variables, including the first project, all projects, and a short `projectId`. Thanks to @bendavis987 for the contribution.
- ([#923](https://github.com/callumalpass/tasknotes/issues/923)) Added `{{projectFilePath}}` and `{{projectFilePaths}}` folder template variables, so task folders can mirror project note locations in the vault. Thanks to @PacoTaco2 for suggesting this.
- ([#1697](https://github.com/callumalpass/tasknotes/issues/1697)) Added cached Google Calendar, Microsoft Calendar, and ICS events to Mini Calendar days, with compact colored dots and event details from the existing calendar connection. Thanks to @RPGArchivist for suggesting this.
- ([#1754](https://github.com/callumalpass/tasknotes/issues/1754)) Added optional icons for priority values, so task cards can show a configured priority icon instead of only a colored dot. Thanks to @BrucePlumb for suggesting this and @prepare4robots for the follow-up feedback.
- ([#1761](https://github.com/callumalpass/tasknotes/issues/1761)) Added TaskNotes edit and quick-action entries to Obsidian's native file context menu for recognized task notes. Thanks to @delzero for suggesting this.

View file

@ -24,6 +24,8 @@ The **Default Tasks Folder** setting supports dynamic folder creation using temp
- `{{contexts}}` - All contexts from the task's contexts array, joined by `/`
- `{{project}}` - First project from the task's projects array
- `{{projects}}` - All projects from the task's projects array, joined by `/`
- `{{projectFilePath}}` - Full path of the first project, without `.md`
- `{{projectFilePaths}}` - Full paths of all projects, without `.md`, joined by `/`
- `{{priority}}` - Task priority (e.g., "high", "medium", "low")
- `{{status}}` - Task status (e.g., "todo", "in-progress", "done")
- `{{title}}` - Task title (sanitized for folder names)

View file

@ -852,7 +852,7 @@ export const en: TranslationTree = {
defaultFolder: {
name: "Default tasks folder",
description:
"Default location for new tasks. Supports folder template variables like {{currentNotePath}} and {{currentNoteTitle}}, plus Daily Notes-style date tokens like YYYY/MM/DD.",
"Default location for new tasks. Supports folder template variables like {{currentNotePath}}, {{currentNoteTitle}}, and {{projectFilePath}}, plus Daily Notes-style date tokens like YYYY/MM/DD.",
},
moveArchived: {
name: "Move archived tasks to folder",

View file

@ -30,7 +30,7 @@ import {
resolveDependencyEntry,
serializeDependencies,
} from "../utils/dependencyUtils";
import { getProjectDisplayName } from "../utils/linkUtils";
import { getProjectDisplayName, parseLinkToPath } from "../utils/linkUtils";
import {
formatDateForStorage,
getDatePart,
@ -277,6 +277,7 @@ export class TaskService {
date,
taskData: templateData,
extractProjectBasename: (project) => this.extractProjectBasename(project),
extractProjectFilePath: (project) => this.extractProjectFilePath(project),
});
}
@ -1861,4 +1862,10 @@ export class TaskService {
private extractProjectBasename(project: string): string {
return getProjectDisplayName(project, this.plugin.app);
}
private extractProjectFilePath(project: string): string {
const linkPath = parseLinkToPath(project);
const resolved = this.plugin.app.metadataCache.getFirstLinkpathDest?.(linkPath, "");
return (resolved?.path ?? linkPath).replace(/\.md$/i, "");
}
}

View file

@ -47,6 +47,12 @@ export interface FolderTemplateOptions {
* Used to handle wikilink formatting and path resolution
*/
extractProjectBasename?: (project: string) => string;
/**
* Optional function to extract the full file path from a project string
* Used to handle wikilink formatting and path resolution
*/
extractProjectFilePath?: (project: string) => string;
}
const DAILY_NOTES_DATE_TOKEN_FORMATS: Record<string, string> = {
@ -97,6 +103,59 @@ function normalizeRelativeFolderPath(folderPath: string): string {
return preserveTrailingSlash && normalizedPath ? `${normalizedPath}/` : normalizedPath;
}
function stripMarkdownExtension(path: string): string {
return path.replace(/\.md$/i, "");
}
function parseProjectFilePath(project: string): string {
const trimmed = project.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) {
const linkContent = trimmed.slice(2, -2).trim();
const pipeIndex = linkContent.indexOf("|");
const path = pipeIndex !== -1 ? linkContent.slice(0, pipeIndex) : linkContent;
return stripMarkdownExtension(path.trim());
}
const markdownMatch = trimmed.match(/^\[[^\]]*\]\(([^)]+)\)$/);
if (markdownMatch) {
let linkPath = markdownMatch[1].trim();
if (linkPath.startsWith("<") && linkPath.endsWith(">")) {
linkPath = linkPath.slice(1, -1).trim();
}
try {
linkPath = decodeURIComponent(linkPath);
} catch (error) {
console.debug("Failed to decode project path:", linkPath, error);
}
return stripMarkdownExtension(linkPath);
}
return stripMarkdownExtension(trimmed);
}
function sanitizeProjectFilePath(path: string): string {
const normalizedPath = stripMarkdownExtension(path.trim()).replace(/\\/g, "/");
return normalizedPath
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => segment.replace(/[<>:"|?*]/g, "_"))
.join("/");
}
function getProjectFilePath(
project: string,
extractProjectFilePath?: (project: string) => string
): string {
const rawPath = extractProjectFilePath
? extractProjectFilePath(project)
: parseProjectFilePath(project);
return sanitizeProjectFilePath(rawPath);
}
/**
* Process a folder path template by replacing template variables with actual values
*
@ -120,6 +179,7 @@ function normalizeRelativeFolderPath(folderPath: string): string {
* Task variables (when taskData is provided):
* - {{context}}, {{contexts}} - First context or all contexts joined with /
* - {{project}}, {{projects}} - First project or all projects joined with /
* - {{projectFilePath}}, {{projectFilePaths}} - First project path or all project paths joined with /
* - {{priority}}, {{priorityShort}}
* - {{status}}, {{statusShort}}
* - {{title}}, {{titleLower}}, {{titleUpper}}, {{titleSnake}}, {{titleKebab}}, {{titleCamel}}, {{titlePascal}}
@ -162,7 +222,13 @@ export function processFolderTemplate(
return folderTemplate;
}
const { date = new Date(), taskData, icsData, extractProjectBasename } = options;
const {
date = new Date(),
taskData,
icsData,
extractProjectBasename,
extractProjectFilePath,
} = options;
let processedPath = folderTemplate;
const shouldNormalizeRelativeSegments = hasRelativePathSegments(folderTemplate);
@ -196,6 +262,22 @@ export function processFolderTemplate(
: "";
processedPath = processedPath.replace(/\{\{projects\}\}/g, projects);
// Handle full project file paths while preserving path separators
const projectFilePath =
Array.isArray(taskData.projects) && taskData.projects.length > 0
? getProjectFilePath(taskData.projects[0], extractProjectFilePath)
: "";
processedPath = processedPath.replace(/\{\{projectFilePath\}\}/g, projectFilePath);
const projectFilePaths =
Array.isArray(taskData.projects) && taskData.projects.length > 0
? taskData.projects
.map((proj) => getProjectFilePath(proj, extractProjectFilePath))
.filter((path) => path.length > 0)
.join("/")
: "";
processedPath = processedPath.replace(/\{\{projectFilePaths\}\}/g, projectFilePaths);
// Handle multiple contexts
const contexts =
Array.isArray(taskData.contexts) && taskData.contexts.length > 0

View file

@ -25,7 +25,7 @@ import { processFolderTemplate, TaskTemplateData } from '../../../src/utils/fold
describe('Issue #923: Project file path template variable', () => {
describe('{{projectFilePath}} variable', () => {
it.skip('reproduces issue #923: should support {{projectFilePath}} for full project path', () => {
it('supports {{projectFilePath}} for full project path', () => {
// User wants to organize tasks into project-specific folders
// matching the project's location in the vault
const taskData: TaskTemplateData = {
@ -48,7 +48,7 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('Tasks/Work/Projects/ProjectA');
});
it.skip('reproduces issue #923: should handle plain project paths without wikilinks', () => {
it('handles plain project paths without wikilinks', () => {
const taskData: TaskTemplateData = {
title: 'Fix bug',
projects: ['Personal/ProjectB'],
@ -61,7 +61,7 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('Personal/ProjectB/Tasks');
});
it.skip('reproduces issue #923: should handle display name wikilinks', () => {
it('handles display name wikilinks by using the file path', () => {
// Wikilinks can have display names: [[path|Display Name]]
const taskData: TaskTemplateData = {
title: 'Update README',
@ -76,7 +76,7 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('Projects/Work/ClientProject');
});
it.skip('reproduces issue #923: should use first project path when multiple projects exist', () => {
it('uses first project path when multiple projects exist', () => {
const taskData: TaskTemplateData = {
title: 'Multi-project task',
projects: ['[[Work/Alpha]]', '[[Personal/Beta]]'],
@ -92,7 +92,7 @@ describe('Issue #923: Project file path template variable', () => {
});
describe('{{projectFilePaths}} variable (plural)', () => {
it.skip('reproduces issue #923: should support {{projectFilePaths}} for all project paths', () => {
it('supports {{projectFilePaths}} for all project paths', () => {
const taskData: TaskTemplateData = {
title: 'Shared task',
projects: ['[[Work/Alpha]]', '[[Personal/Beta]]'],
@ -132,7 +132,7 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('MyProject');
});
it.skip('reproduces issue #923: {{projectFilePath}} should return full path unlike {{project}}', () => {
it('{{projectFilePath}} returns full path unlike {{project}}', () => {
const taskData: TaskTemplateData = {
projects: ['[[Work/Projects/MyProject]]'],
};
@ -151,7 +151,6 @@ describe('Issue #923: Project file path template variable', () => {
const filePathResult = processFolderTemplate('{{projectFilePath}}', {
taskData,
// New option would be needed: extractProjectFilePath
});
expect(projectResult).toBe('MyProject');
@ -160,7 +159,7 @@ describe('Issue #923: Project file path template variable', () => {
});
describe('edge cases', () => {
it.skip('reproduces issue #923: should return empty string when no projects', () => {
it('returns empty string when no projects', () => {
const taskData: TaskTemplateData = {
title: 'No project task',
projects: [],
@ -173,9 +172,9 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('Tasks/');
});
it.skip('reproduces issue #923: should sanitize paths for folder safety', () => {
it('sanitizes path segments for folder safety while preserving folder separators', () => {
const taskData: TaskTemplateData = {
projects: ['[[Work/Project<>:"/\\|?*Special]]'],
projects: ['[[Work/Project<>:"/Unsafe?*Special]]'],
};
const result = processFolderTemplate('{{projectFilePath}}', {
@ -183,10 +182,10 @@ describe('Issue #923: Project file path template variable', () => {
});
// Special characters should be sanitized
expect(result).toBe('Work/Project_________Special');
expect(result).toBe('Work/Project____/Unsafe__Special');
});
it.skip('reproduces issue #923: should handle root-level projects', () => {
it('handles root-level projects', () => {
// Project file is at vault root (no folder path)
const taskData: TaskTemplateData = {
projects: ['[[RootProject]]'],
@ -198,5 +197,19 @@ describe('Issue #923: Project file path template variable', () => {
expect(result).toBe('RootProject/Tasks');
});
it('allows callers to resolve short project links to full vault paths', () => {
const taskData: TaskTemplateData = {
projects: ['[[ProjectA]]'],
};
const result = processFolderTemplate('Tasks/{{projectFilePath}}', {
taskData,
extractProjectFilePath: (project) =>
project === '[[ProjectA]]' ? 'Work/Projects/ProjectA.md' : project,
});
expect(result).toBe('Tasks/Work/Projects/ProjectA');
});
});
});