mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Add project file path folder variables
This commit is contained in:
parent
7c87781ca2
commit
7b58399d7b
7 changed files with 123 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue