Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/features/template-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
2 changes: 2 additions & 0 deletions docs/settings/task-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions src/utils/folderTemplateProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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}}
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/utils/folderTemplateProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down