From c8c45fd38c749bbde7ea170953988f2766cc3e91 Mon Sep 17 00:00:00 2001 From: Cary Pruitt Date: Wed, 8 Jul 2026 14:00:24 -0700 Subject: [PATCH] feat: add {{projectFolder}} folder template variable Adds {{projectFolder}} and {{projectFolders}} template variables for the Default tasks folder / folder-path settings. Each resolves to the folder *containing* a project note, i.e. the project file path with the note's own basename removed. This fills a gap between the two existing project path tokens: - {{project}} gives the note's basename (no folder), so it only lands a task beside its project note when the note happens to sit at the vault root in a folder matching its name. - {{projectFilePath}} gives the full path including the note basename, which nests an extra folder (a task for [[Area/Proj/Proj]] lands in Area/Proj/Proj/ rather than Area/Proj/). {{projectFolder}} resolves to the containing folder in all cases (Area/Proj/Proj -> Area/Proj), and to an empty string when the project note is at the top level. This lets tasks be stored beside their project note regardless of how deeply the note is nested. --- docs/features/template-variables.md | 2 + docs/settings/task-defaults.md | 2 + src/utils/folderTemplateProcessor.ts | 33 +++++++++++++ .../utils/folderTemplateProcessor.test.ts | 49 +++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/docs/features/template-variables.md b/docs/features/template-variables.md index d2e80ae27..2bd7f1224 100644 --- a/docs/features/template-variables.md +++ b/docs/features/template-variables.md @@ -35,6 +35,8 @@ Variables for task-specific data. | `{{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` | +| `{{projectFolder}}` | Folder containing the first project note (its file path without the note's own name) | No | Yes | `Work/Projects` | +| `{{projectFolders}}` | Folders of all projects, joined by `/` | No | Yes | `Work/Personal` | | `{{dueDate}}` | Task due date | Yes | Yes | `2025-01-15` | | `{{scheduledDate}}` | Task scheduled date | Yes | Yes | `2025-01-10` | diff --git a/docs/settings/task-defaults.md b/docs/settings/task-defaults.md index 89ea0ebe9..767bc02d1 100644 --- a/docs/settings/task-defaults.md +++ b/docs/settings/task-defaults.md @@ -26,6 +26,8 @@ The **Default Tasks Folder** setting supports dynamic folder creation using temp - `{{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 `/` +- `{{projectFolder}}` - Folder containing the first project note (the project file path without the note's own name); empty when the project note is at the top level. Use this to store tasks beside their project note, e.g. `{{projectFolder}}` stores a task for `[[Areas/EFC/EFC]]` in `Areas/EFC/` +- `{{projectFolders}}` - Folders of all projects, 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) diff --git a/src/utils/folderTemplateProcessor.ts b/src/utils/folderTemplateProcessor.ts index bacb9cf21..bb3089b8f 100644 --- a/src/utils/folderTemplateProcessor.ts +++ b/src/utils/folderTemplateProcessor.ts @@ -164,6 +164,22 @@ function getProjectFilePath( return sanitizeProjectFilePath(rawPath); } +/** + * Get the folder that contains a project note, i.e. the project file path with + * its final (basename) segment removed. Returns an empty string when the + * project note lives at the top level with no containing folder. + */ +function getProjectFolder( + project: string, + extractProjectFilePath?: (project: string) => string +): string { + const filePath = getProjectFilePath(project, extractProjectFilePath); + if (!filePath) { + return ""; + } + return filePath.split("/").slice(0, -1).join("/"); +} + /** * Process a folder path template by replacing template variables with actual values * @@ -188,6 +204,7 @@ function getProjectFilePath( * - {{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 / + * - {{projectFolder}}, {{projectFolders}} - Folder containing the first project note or all project folders joined with / * - {{priority}}, {{priorityShort}} * - {{status}}, {{statusShort}} * - {{title}}, {{titleLower}}, {{titleUpper}}, {{titleSnake}}, {{titleKebab}}, {{titleCamel}}, {{titlePascal}} @@ -288,6 +305,22 @@ export function processFolderTemplate( : ""; processedPath = processedPath.replace(/\{\{projectFilePaths\}\}/g, projectFilePaths); + // Handle project folder (folder containing the first project note) + const projectFolder = + Array.isArray(taskData.projects) && taskData.projects.length > 0 + ? getProjectFolder(taskData.projects[0], extractProjectFilePath) + : ""; + processedPath = processedPath.replace(/\{\{projectFolder\}\}/g, projectFolder); + + const projectFolders = + Array.isArray(taskData.projects) && taskData.projects.length > 0 + ? taskData.projects + .map((proj) => getProjectFolder(proj, extractProjectFilePath)) + .filter((folder) => folder.length > 0) + .join("/") + : ""; + processedPath = processedPath.replace(/\{\{projectFolders\}\}/g, projectFolders); + // Handle multiple contexts const contexts = Array.isArray(taskData.contexts) && taskData.contexts.length > 0 diff --git a/tests/unit/utils/folderTemplateProcessor.test.ts b/tests/unit/utils/folderTemplateProcessor.test.ts index acc756964..8ac2e0f5d 100644 --- a/tests/unit/utils/folderTemplateProcessor.test.ts +++ b/tests/unit/utils/folderTemplateProcessor.test.ts @@ -143,6 +143,55 @@ describe('processFolderTemplate', () => { expect(result).toBe('MyProject/OtherProject'); }); + describe('{{projectFolder}} and {{projectFolders}}', () => { + // Resolve a wikilink to its full file path, mirroring how the plugin + // wires extractProjectFilePath in production. + const extractFilePath = (project: string) => { + const match = project.match(/^\[\[([^\]]+)\]\]$/); + return match ? match[1] : project; + }; + + it('should return the folder containing a nested project note', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: ['[[Areas/EFC/EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('Areas/EFC'); + }); + + it('should return an empty string for a top-level project note', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: ['[[EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe(''); + }); + + it('should place a task beside its project note', () => { + const result = processFolderTemplate('{{projectFolder}}/{{title}}', { + taskData: { title: 'Practice plan', projects: ['[[EFC/EFC]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('EFC/Practice plan'); + }); + + it('should join folders and drop empties for {{projectFolders}}', () => { + const result = processFolderTemplate('{{projectFolders}}', { + taskData: { projects: ['[[EFC/EFC]]', '[[TopLevel]]', '[[a/b/c]]'] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe('EFC/a/b'); + }); + + it('should return an empty string when no projects are set', () => { + const result = processFolderTemplate('{{projectFolder}}', { + taskData: { projects: [] }, + extractProjectFilePath: extractFilePath, + }); + expect(result).toBe(''); + }); + }); + it('should handle empty task arrays gracefully', () => { const emptyTaskData: TaskTemplateData = { contexts: [],