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: [],