From 5f279681cf2870c84f8b1ed9f44f72e63a48cd7c Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:09:13 -0700 Subject: [PATCH 1/6] feat: inline task folder allow relative path - eg. meetings/../tasks (#1254) --- src/services/TaskService.ts | 15 +++- src/utils/helpers.ts | 49 +++++++++++ tests/unit/utils/helpers.test.ts | 142 ++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 3 deletions(-) diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index a57caeb2..ed505884 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -23,6 +23,7 @@ import { addDTSTARTToRecurrenceRule, updateDTSTARTInRecurrenceRule, ensureFolderExists, + resolveRelativePath, updateToNextScheduledOccurrence, splitFrontmatterAndBody, } from "../utils/helpers"; @@ -243,15 +244,21 @@ export class TaskService { } // Process task and date variables in the inline folder path folder = this.processFolderTemplate(folder, taskData); + // Resolve relative paths after all template substitution + folder = resolveRelativePath(folder); } else { // Fallback to default tasks folder when inline folder is empty (#128) const tasksFolder = this.plugin.settings.tasksFolder || ""; folder = this.processFolderTemplate(tasksFolder, taskData); + // Resolve relative paths after all template substitution + folder = resolveRelativePath(folder); } } else { // For manual creation and other contexts, use the general tasks folder const tasksFolder = this.plugin.settings.tasksFolder || ""; folder = this.processFolderTemplate(tasksFolder, taskData); + // Resolve relative paths after all template substitution + folder = resolveRelativePath(folder); } // Ensure folder exists @@ -770,13 +777,15 @@ export class TaskService { // Archiving: Move to archive folder const archiveFolderTemplate = this.plugin.settings.archiveFolder.trim(); // Process template variables in archive folder path - const archiveFolder = this.processFolderTemplate(archiveFolderTemplate, { + let archiveFolder = this.processFolderTemplate(archiveFolderTemplate, { title: updatedTask.title || "", priority: updatedTask.priority, status: updatedTask.status, contexts: updatedTask.contexts, projects: updatedTask.projects, }); + // Resolve relative paths after all template substitution + archiveFolder = resolveRelativePath(archiveFolder); // Ensure archive folder exists await ensureFolderExists(this.plugin.app.vault, archiveFolder); @@ -803,7 +812,9 @@ export class TaskService { this.plugin.cacheManager.clearCacheEntry(task.path); } else if (isCurrentlyArchived && this.plugin.settings.tasksFolder?.trim()) { // Unarchiving: Move to default tasks folder - const tasksFolder = this.plugin.settings.tasksFolder.trim(); + let tasksFolder = this.plugin.settings.tasksFolder.trim(); + // Resolve relative paths for consistency + tasksFolder = resolveRelativePath(tasksFolder); // Ensure tasks folder exists await ensureFolderExists(this.plugin.app.vault, tasksFolder); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 47ae25da..c72ecf0d 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -73,6 +73,55 @@ export async function ensureFolderExists(vault: Vault, folderPath: string): Prom } } +/** + * Resolves relative path segments (../ and ./) in a path string + * Throws an error if the path tries to navigate beyond the vault root + * + * @param path - Path string potentially containing ../ or ./ segments + * @returns Resolved path with relative segments removed + * @throws Error if path attempts to navigate beyond vault root + * + * @example + * resolveRelativePath("Project/Meetings/../Tasks") // "Project/Tasks" + * resolveRelativePath("A/B/C/../../D") // "A/D" + * resolveRelativePath("../Tasks") // throws Error + */ +export function resolveRelativePath(path: string): string { + if (!path) return path; + + // Normalize slashes first (handle Windows paths) + const normalized = path.replace(/\\/g, "/"); + + // Split into segments, filtering out empty ones + const segments = normalized.split("/").filter((s) => s.length > 0); + + // Process segments, resolving .. and . + const resolved: string[] = []; + for (const segment of segments) { + if (segment === "..") { + // Try to go up one level + if (resolved.length > 0) { + resolved.pop(); + } else { + // Trying to go beyond vault root + throw new Error( + `Cannot resolve path "${path}": Attempts to navigate beyond vault root. ` + + `Check your folder template for too many '../' segments.` + ); + } + } else if (segment === ".") { + // Skip current directory reference + continue; + } else { + // Normal segment - add to path + resolved.push(segment); + } + } + + // Join back together + return resolved.join("/"); +} + /** * Calculate duration in minutes between two ISO timestamp strings */ diff --git a/tests/unit/utils/helpers.test.ts b/tests/unit/utils/helpers.test.ts index 709587b5..f83f6027 100644 --- a/tests/unit/utils/helpers.test.ts +++ b/tests/unit/utils/helpers.test.ts @@ -14,6 +14,7 @@ import { ensureFolderExists, + resolveRelativePath, calculateDuration, calculateTotalTimeSpent, getActiveTimeEntry, @@ -131,7 +132,7 @@ describe('Helpers', () => { if (!path) return ''; return path.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/*/, '').replace(/\/*$/, ''); }); - + mockVault = { getAbstractFileByPath: jest.fn(), createFolder: jest.fn().mockResolvedValue(undefined) @@ -191,6 +192,145 @@ describe('Helpers', () => { }); }); + describe('resolveRelativePath', () => { + describe('Basic parent directory navigation', () => { + it('should resolve single parent directory', () => { + expect(resolveRelativePath('Project/Meetings/../Tasks')).toBe('Project/Tasks'); + }); + + it('should resolve multiple parent directories', () => { + expect(resolveRelativePath('A/B/C/../../D')).toBe('A/D'); + expect(resolveRelativePath('A/B/C/D/../../../E')).toBe('A/E'); + }); + + it('should resolve complex paths with multiple parent references', () => { + expect(resolveRelativePath('Project/Team/Sprint5/../../Archive/Done')).toBe('Project/Archive/Done'); + }); + }); + + describe('Current directory handling', () => { + it('should skip current directory references', () => { + expect(resolveRelativePath('./A/B')).toBe('A/B'); + expect(resolveRelativePath('A/./B')).toBe('A/B'); + expect(resolveRelativePath('A/B/.')).toBe('A/B'); + }); + + it('should handle mixed current and parent directory references', () => { + expect(resolveRelativePath('./A/./B/../C')).toBe('A/C'); + expect(resolveRelativePath('A/./B/./C/../../D')).toBe('A/D'); + }); + }); + + describe('Windows path handling', () => { + it('should convert backslashes to forward slashes', () => { + expect(resolveRelativePath('A\\B\\C')).toBe('A/B/C'); + expect(resolveRelativePath('A\\B\\..\\C')).toBe('A/C'); + }); + + it('should handle mixed slashes', () => { + expect(resolveRelativePath('A\\B/C\\..\\D')).toBe('A/B/D'); + }); + }); + + describe('Edge cases', () => { + it('should handle empty path', () => { + expect(resolveRelativePath('')).toBe(''); + }); + + it('should handle path with only relative references', () => { + // '..' at root level should throw error + expect(() => resolveRelativePath('..')).toThrow(/Attempts to navigate beyond vault root/); + // '.' should resolve to empty + expect(resolveRelativePath('.')).toBe(''); + // './..' should also throw error as it goes beyond root + expect(() => resolveRelativePath('./..')).toThrow(/Attempts to navigate beyond vault root/); + }); + + it('should handle paths without relative segments', () => { + expect(resolveRelativePath('Tasks/Work')).toBe('Tasks/Work'); + expect(resolveRelativePath('A/B/C/D/E')).toBe('A/B/C/D/E'); + }); + + it('should handle trailing slashes', () => { + expect(resolveRelativePath('A/B/../C/')).toBe('A/C'); + expect(resolveRelativePath('A/B/C/')).toBe('A/B/C'); + }); + + it('should filter out empty segments', () => { + expect(resolveRelativePath('A//B///C')).toBe('A/B/C'); + expect(resolveRelativePath('A/..//B')).toBe('B'); + }); + }); + + describe('Vault root boundary protection', () => { + it('should throw error when trying to go beyond root', () => { + expect(() => resolveRelativePath('../Tasks')).toThrow( + 'Cannot resolve path "../Tasks": Attempts to navigate beyond vault root' + ); + }); + + it('should throw error for multiple parent references beyond root', () => { + expect(() => resolveRelativePath('../../Tasks')).toThrow( + 'Cannot resolve path "../../Tasks": Attempts to navigate beyond vault root' + ); + }); + + it('should throw error when path resolves beyond root mid-path', () => { + expect(() => resolveRelativePath('A/../../B')).toThrow( + 'Cannot resolve path "A/../../B": Attempts to navigate beyond vault root' + ); + }); + + it('should succeed when staying at root level', () => { + expect(resolveRelativePath('A/../B')).toBe('B'); + expect(resolveRelativePath('A/B/../../C')).toBe('C'); + }); + }); + + describe('Real-world use cases', () => { + it('should resolve meeting to sibling tasks folder', () => { + // User's exact use case + expect(resolveRelativePath('Project/Meetings/../Tasks/MeetingA')).toBe('Project/Tasks/MeetingA'); + }); + + it('should resolve to company-wide archive', () => { + // TeamA/Sprint5/../../Archive/2025 → TeamA → root → Archive/2025 + expect(resolveRelativePath('TeamA/Sprint5/../../Archive/2025')).toBe('Archive/2025'); + }); + + it('should resolve category-based with parent navigation', () => { + expect(resolveRelativePath('Work/2025/Planning/../../Done/2025/11')).toBe('Work/Done/2025/11'); + }); + + it('should handle deeply nested navigation', () => { + expect(resolveRelativePath('A/B/C/D/E/../../../F/G')).toBe('A/B/F/G'); + }); + }); + + describe('Performance', () => { + it('should handle long paths efficiently', () => { + const longPath = 'A/B/C/D/E/F/G/H/I/J/K/L/M/N/O/P/Q/R/S/T/U/V/W/X/Y/Z'; + const startTime = Date.now(); + const result = resolveRelativePath(longPath); + const endTime = Date.now(); + + expect(result).toBe(longPath); + expect(endTime - startTime).toBeLessThan(10); // Should be nearly instant + }); + + it('should handle many relative references efficiently', () => { + const pathWithManyRefs = 'A/B/C/D/E/../../../../F/G/H/I/J/../../../K'; + const startTime = Date.now(); + const result = resolveRelativePath(pathWithManyRefs); + const endTime = Date.now(); + + // A/B/C/D/E → A (after 4x ..) → A/F/G/H/I/J → A/F/G (after 3x ..) → A/F/G/K + expect(result).toBe('A/F/G/K'); + expect(endTime - startTime).toBeLessThan(10); + }); + }); + }); + describe('Time Calculation Functions', () => { describe('calculateDuration', () => { it('should calculate duration in minutes', () => { From c894843ce453239fd113fdde19158dd4e5b06e8c Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:18:51 -0700 Subject: [PATCH 2/6] feat: inline task folder allow inline javascript for folder path and additional variables in path --- src/services/TaskService.ts | 14 ++ src/utils/folderTemplateProcessor.ts | 131 ++++++++++- .../utils/folderTemplateProcessor.test.ts | 221 ++++++++++++++++++ 3 files changed, 365 insertions(+), 1 deletion(-) diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index ed505884..6a8ebe72 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -162,10 +162,24 @@ export class TaskService { } : undefined; + // Build fullTaskInfo from TaskCreationData for advanced templating + const fullTaskInfo: Partial | undefined = taskData + ? { + ...taskData, + // Ensure required fields are present + title: taskData.title || "", + status: taskData.status || this.plugin.settings.defaultTaskStatus, + priority: taskData.priority || this.plugin.settings.defaultTaskPriority, + path: "", // Will be set after folder is determined + archived: taskData.archived || false, + } + : undefined; + // Use the shared folder template processor utility return processFolderTemplate(folderTemplate, { date, taskData: templateData, + fullTaskInfo: fullTaskInfo as TaskInfo | undefined, extractProjectBasename: (project) => this.extractProjectBasename(project), }); } diff --git a/src/utils/folderTemplateProcessor.ts b/src/utils/folderTemplateProcessor.ts index cabf6f8f..adcfa7a7 100644 --- a/src/utils/folderTemplateProcessor.ts +++ b/src/utils/folderTemplateProcessor.ts @@ -1,4 +1,5 @@ import { format } from "date-fns"; +import { TaskInfo } from "../types"; /** * Data for processing task-specific template variables @@ -47,11 +48,81 @@ export interface FolderTemplateOptions { * Used to handle wikilink formatting and path resolution */ extractProjectBasename?: (project: string) => string; + + /** + * Full TaskInfo object for accessing all task properties via {{variable}} syntax + * and making them available in ${...} JavaScript expressions + */ + fullTaskInfo?: TaskInfo; +} + +/** + * Safely evaluate a JavaScript expression in a controlled context + * @param expression - The JavaScript code to evaluate + * @param context - Variables available to the expression + * @returns The result of the expression as a string, or empty string on error + */ +function safeEvaluateJS(expression: string, context: Record): string { + try { + // Create a function with the context variables as parameters + const contextKeys = Object.keys(context); + const contextValues = contextKeys.map(key => context[key]); + + // Create a function that returns the evaluated expression + // Using Function constructor instead of eval for better control + const func = new Function(...contextKeys, `"use strict"; return (${expression});`); + + // Execute the function with the context values + const result = func(...contextValues); + + // Convert result to string, handling various types + if (result === null || result === undefined) { + return ""; + } + if (Array.isArray(result)) { + return result.join("/"); + } + return String(result); + } catch (error) { + console.warn(`Error evaluating JS expression "${expression}":`, error); + return ""; + } +} + +/** + * Replace all {{variable}} references with values from the context + * Supports all properties from TaskInfo + * @param template - Template string with {{variable}} placeholders + * @param context - Object containing variable values + * @returns Template with all {{variables}} replaced + */ +function replaceTemplateVariables(template: string, context: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + if (varName in context) { + const value = context[varName]; + if (value === null || value === undefined) { + return ""; + } + if (Array.isArray(value)) { + // Handle empty arrays + if (value.length === 0) { + return ""; + } + return value.join("/"); + } + return String(value); + } + return match; // Keep original if not found + }); } /** * Process a folder path template by replacing template variables with actual values * + * Supports two template syntaxes: + * 1. {{variable}} - Simple variable substitution + * 2. ${...} - JavaScript expression evaluation + * * Supported template variables: * * Date variables: @@ -74,11 +145,24 @@ export interface FolderTemplateOptions { * - {{title}}, {{titleLower}}, {{titleUpper}}, {{titleSnake}}, {{titleKebab}}, {{titleCamel}}, {{titlePascal}} * - {{dueDate}}, {{scheduledDate}} * + * All TaskInfo variables (when fullTaskInfo is provided): + * - All properties from TaskInfo interface accessible via {{propertyName}} + * - Examples: {{tags}}, {{archived}}, {{timeEstimate}}, {{dateCreated}}, etc. + * - Arrays like {{tags}} and {{contexts}} are joined with / + * * ICS event variables (when icsData is provided): * - {{icsEventTitle}}, {{icsEventTitleLower}}, {{icsEventTitleUpper}}, etc. * - {{icsEventLocation}} * - {{icsEventDescription}} * + * JavaScript expressions (when fullTaskInfo is provided): + * - ${...} expressions can contain any JavaScript code + * - All TaskInfo properties and date variables are available in scope + * - Examples: + * - ${tags.includes('urgent') ? 'urgent' : 'normal'} + * - ${priority === 'high' ? 'important' : 'regular'} + * - ${contexts.length > 0 ? contexts[0] : 'inbox'} + * * @param folderTemplate - The template string containing variables to replace * @param options - Options for processing the template * @returns The processed folder path with all variables replaced @@ -95,6 +179,18 @@ export interface FolderTemplateOptions { * }) * // => "Projects/MyProject/active" * + * // Using all TaskInfo variables + * processFolderTemplate("Tasks/{{priority}}/{{tags}}", { + * fullTaskInfo: { priority: "high", tags: ["work", "urgent"], ... } + * }) + * // => "Tasks/high/work/urgent" + * + * // Using JavaScript expressions + * processFolderTemplate("Tasks/${tags.includes('urgent') ? 'urgent' : 'normal'}", { + * fullTaskInfo: { tags: ["work", "urgent"], ... } + * }) + * // => "Tasks/urgent" + * * // ICS event template * processFolderTemplate("Events/{{year}}/{{icsEventTitle}}", { * date: new Date(), @@ -111,10 +207,43 @@ export function processFolderTemplate( return folderTemplate; } - const { date = new Date(), taskData, icsData, extractProjectBasename } = options; + const { date = new Date(), taskData, icsData, extractProjectBasename, fullTaskInfo } = options; let processedPath = folderTemplate; + // Step 1: Build context for JavaScript expressions and {{variable}} replacement + const jsContext: Record = { + // Date utilities + date, + year: format(date, "yyyy"), + month: format(date, "MM"), + day: format(date, "dd"), + }; + + // Add all TaskInfo properties to context if fullTaskInfo is provided + if (fullTaskInfo) { + // Add all properties from TaskInfo + Object.keys(fullTaskInfo).forEach(key => { + const value = (fullTaskInfo as any)[key]; + // Skip internal properties but keep undefined values for replacement + if (!key.startsWith('_')) { + jsContext[key] = value; + } + }); + } + + // Step 2: First pass - replace ${...} JavaScript expressions + // This happens before {{variable}} replacement so JS can use the raw template + processedPath = processedPath.replace(/\$\{([^}]+)\}/g, (_match, expression) => { + const result = safeEvaluateJS(expression.trim(), jsContext); + return result; + }); + + // Step 3: Replace {{variable}} for all TaskInfo properties if fullTaskInfo provided + if (fullTaskInfo) { + processedPath = replaceTemplateVariables(processedPath, jsContext); + } + // Replace task variables if taskData is provided if (taskData) { // Handle single context (first one if multiple) diff --git a/tests/unit/utils/folderTemplateProcessor.test.ts b/tests/unit/utils/folderTemplateProcessor.test.ts index acc75696..b0511b22 100644 --- a/tests/unit/utils/folderTemplateProcessor.test.ts +++ b/tests/unit/utils/folderTemplateProcessor.test.ts @@ -1,4 +1,5 @@ import { processFolderTemplate, TaskTemplateData, ICSTemplateData } from '../../../src/utils/folderTemplateProcessor'; +import { TaskInfo } from '../../../src/types'; describe('processFolderTemplate', () => { const testDate = new Date('2025-10-05T14:30:00'); @@ -291,4 +292,224 @@ describe('processFolderTemplate', () => { expect(result).toBe('Daily/2025/10/2025-10-02-Aangifte Omzetbelasting'); }); }); + + describe('fullTaskInfo variable support', () => { + const fullTaskInfo: Partial = { + title: 'My Task', + status: 'in-progress', + priority: 'high', + tags: ['work', 'urgent'], + contexts: ['@office', '@computer'], + projects: ['ProjectX'], + archived: false, + due: '2025-11-01', + scheduled: '2025-10-28', + timeEstimate: 120, + path: '/tasks/my-task.md', + } as TaskInfo; + + it('should access TaskInfo properties via {{variable}} syntax', () => { + const result = processFolderTemplate('Tasks/{{priority}}/{{status}}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/high/in-progress'); + }); + + it('should handle array properties like {{tags}} by joining with /', () => { + const result = processFolderTemplate('Tasks/{{tags}}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/work/urgent'); + }); + + it('should handle {{contexts}} array property', () => { + const result = processFolderTemplate('Tasks/{{contexts}}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/@office/@computer'); + }); + + it('should handle single value properties like {{timeEstimate}}', () => { + const result = processFolderTemplate('Tasks/{{timeEstimate}}min', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/120min'); + }); + + it('should handle boolean properties like {{archived}}', () => { + const result = processFolderTemplate('Tasks/archived-{{archived}}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/archived-false'); + }); + + it('should handle undefined properties by leaving them as-is when not in object', () => { + const taskWithMissing: Partial = { + title: 'Task', + status: 'todo', + priority: 'low', + path: '/task.md', + archived: false, + // tags and contexts are intentionally not defined + } as TaskInfo; + + const result = processFolderTemplate('Tasks/{{tags}}/{{contexts}}', { + fullTaskInfo: taskWithMissing as TaskInfo, + }); + // Variables not in the object remain as templates + expect(result).toBe('Tasks/{{tags}}/{{contexts}}'); + }); + + it('should handle empty arrays by replacing with empty string', () => { + const taskWithEmpty: Partial = { + title: 'Task', + status: 'todo', + priority: 'low', + tags: [], + contexts: [], + path: '/task.md', + archived: false, + } as TaskInfo; + + const result = processFolderTemplate('Tasks/{{tags}}/{{contexts}}', { + fullTaskInfo: taskWithEmpty as TaskInfo, + }); + expect(result).toBe('Tasks//'); + }); + }); + + describe('JavaScript expression evaluation with ${...}', () => { + const fullTaskInfo: Partial = { + title: 'My Task', + status: 'in-progress', + priority: 'high', + tags: ['work', 'urgent', 'important'], + contexts: ['@office'], + projects: ['ProjectX'], + archived: false, + path: '/tasks/my-task.md', + } as TaskInfo; + + it('should evaluate simple JavaScript expressions', () => { + const result = processFolderTemplate('Tasks/${priority === "high" ? "urgent" : "normal"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/urgent'); + }); + + it('should use array methods in expressions', () => { + const result = processFolderTemplate('Tasks/${tags.includes("urgent") ? "urgent" : "normal"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/urgent'); + }); + + it('should access array length in expressions', () => { + const result = processFolderTemplate('Tasks/${tags.length > 2 ? "many-tags" : "few-tags"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/many-tags'); + }); + + it('should support complex logical expressions', () => { + const result = processFolderTemplate('Tasks/${priority === "high" && tags.includes("urgent") ? "critical" : "normal"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/critical'); + }); + + it('should handle array indexing', () => { + const result = processFolderTemplate('Tasks/${contexts.length > 0 ? contexts[0] : "inbox"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/@office'); + }); + + it('should handle tasks without certain properties gracefully', () => { + const taskNoContexts: Partial = { + title: 'Task', + status: 'todo', + priority: 'low', + tags: [], + contexts: [], // Explicitly set to empty array + path: '/task.md', + archived: false, + } as TaskInfo; + + const result = processFolderTemplate('Tasks/${contexts && contexts.length > 0 ? contexts[0] : "inbox"}', { + fullTaskInfo: taskNoContexts as TaskInfo, + }); + expect(result).toBe('Tasks/inbox'); + }); + + it('should support string manipulation in expressions', () => { + const result = processFolderTemplate('Tasks/${priority.toUpperCase()}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/HIGH'); + }); + + it('should handle errors gracefully and return empty string', () => { + const result = processFolderTemplate('Tasks/${nonExistentVariable.method()}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/'); + }); + + it('should return empty string for null/undefined results', () => { + const result = processFolderTemplate('Tasks/${undefined}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/'); + }); + + it('should join array results with /', () => { + const result = processFolderTemplate('Tasks/${tags.filter(t => t.includes("u"))}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/urgent'); + }); + + it('should handle date variables in expressions', () => { + const result = processFolderTemplate('Tasks/${year}-${month}', { + date: testDate, + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/2025-10'); + }); + }); + + describe('combined {{variable}} and ${...} syntax', () => { + const fullTaskInfo: Partial = { + title: 'My Task', + status: 'in-progress', + priority: 'high', + tags: ['work', 'urgent'], + path: '/tasks/my-task.md', + archived: false, + } as TaskInfo; + + it('should process both syntaxes in the same template', () => { + const result = processFolderTemplate('Tasks/{{priority}}/${tags.includes("urgent") ? "critical" : "normal"}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('Tasks/high/critical'); + }); + + it('should process ${...} before {{...}}', () => { + // This ensures JS expressions are evaluated first + const result = processFolderTemplate('${priority}/{{status}}', { + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('high/in-progress'); + }); + + it('should combine with date variables', () => { + const result = processFolderTemplate('{{year}}/{{month}}/${tags.includes("work") ? "work" : "personal"}', { + date: testDate, + fullTaskInfo: fullTaskInfo as TaskInfo, + }); + expect(result).toBe('2025/10/work'); + }); + }); }); From c2f514bf366d7c885f1742bae421c2163dc20007 Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:11:47 -0700 Subject: [PATCH 3/6] Docs: documentation for inline task folder for converted tasks --- ...inline-tasks-folder-for-converted-tasks.md | 314 ++++++++++++++++++ docs/features/inline-tasks.md | 54 +-- 2 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 docs/features/inline-tasks-folder-for-converted-tasks.md diff --git a/docs/features/inline-tasks-folder-for-converted-tasks.md b/docs/features/inline-tasks-folder-for-converted-tasks.md new file mode 100644 index 00000000..69fa7a1e --- /dev/null +++ b/docs/features/inline-tasks-folder-for-converted-tasks.md @@ -0,0 +1,314 @@ +# Inline Task Folder For Converted Tasks + +Configure where converted inline tasks are saved using dynamic folder templates. + +Set the folder template in **Settings → TaskNotes → Features → Inline Task Convert Folder**. + +## Quick Start + +There are two ways to use variables in folder templates: + +- **`{{variable}}`** - Simple substitution for straightforward cases +- **`${...}`** - JavaScript expressions for complex logic + +**Examples:** + +**Simple date-based organization:** + +``` +Task: - [ ] Buy groceries +Template: Tasks/{{year}}/{{month}} +Result: Tasks/2025/11/Buy groceries.md +``` + +**Extract project from tags (advanced):** + +``` +Task: - [ ] Do chores #project/Personal +Template: (4) Projects/${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'Inbox'}/Tasks +Result: (4) Projects/Personal/Tasks/Do chores.md +``` + +--- + +##Basic Templates (Using `{{variable}}`) + +Use double curly braces for simple variable substitution. Variables are replaced with their text values. + +### How It Works + +Put variable names in `{{double curly braces}}` and they'll be replaced with their values: + +- `{{year}}` becomes `2025` +- `{{priority}}` becomes `high` +- `{{tags}}` becomes `work/urgent` (arrays automatically join with `/`) + +### Common Use Cases + +**Date-based organization:** + +``` +Task: - [ ] Weekly review +Template: {{year}}/{{month}} +Result: 2025/11/Weekly review.md +``` + +**Priority-based folders:** + +``` +Task: - [ ] Important meeting !high +Template: Tasks/{{priority}} +Result: Tasks/high/Important meeting.md +``` + +**Status-based folders:** + +``` +Task: - [ ] Draft proposal *in-progress +Template: {{year}}/{{status}} +Result: 2025/in-progress/Draft proposal.md +``` + +**Using tags (auto-joined):** + +``` +Task: - [ ] Review code #work #urgent +Template: Projects/{{tags}} +Result: Projects/work/urgent/Review code.md +``` + +**Multi-level organization:** + +``` +Task: - [ ] Q4 planning !high +Template: {{year}}/Q{{quarter}}/{{priority}} +Result: 2025/Q4/high/Q4 planning.md +``` + +### Available Variables + +**Date & Time (Always Available):** + +| Variable | Example Output | Description | +| -------------------- | ------------------- | -------------- | +| `{{year}}` | `2025` | Full year | +| `{{month}}` | `11` | Month (01-12) | +| `{{day}}` | `28` | Day (01-31) | +| `{{date}}` | `2025-11-28` | Full date | +| `{{quarter}}` | `4` | Quarter (1-4) | +| `{{monthName}}` | `November` | Month name | +| `{{monthNameShort}}` | `Nov` | Short month | +| `{{dayName}}` | `Thursday` | Day name | +| `{{dayNameShort}}` | `Thu` | Short day | +| `{{week}}` | `48` | Week number | +| `{{time}}` | `143025` | HHMMSS | +| `{{timestamp}}` | `2025-11-28-143025` | Full timestamp | +| `{{hour}}` | `14` | Hour | +| `{{minute}}` | `30` | Minute | +| `{{second}}` | `25` | Second | + +**Task Properties (Available During Creation):** + +| Variable | Example Output | Notes | +| ------------------ | ---------------------- | ---------------- | +| `{{title}}` | `Buy groceries` | Always available | +| `{{status}}` | `in-progress` | Always available | +| `{{priority}}` | `high` | Always available | +| `{{due}}` | `2025-12-01` | If set | +| `{{scheduled}}` | `2025-11-28` | If set | +| `{{recurrence}}` | `FREQ=DAILY` | If recurring | +| `{{timeEstimate}}` | `60` | Minutes, if set | +| `{{archived}}` | `false` | Always available | +| `{{dateCreated}}` | `2025-11-28T14:30:25Z` | Always available | +| `{{dateModified}}` | `2025-11-28T14:30:25Z` | Always available | + +**Array Properties (Auto-Join with `/`):** + +**Important:** These automatically join multiple values with `/` + +| Variable | Example Output | Notes | +| -------------- | ------------------- | ------------------------------------------------------------------------------------------- | +| `{{tags}}` | `work/urgent` | From #tag syntax. If you have `["work", "urgent"]`, outputs `"work/urgent"` | +| `{{contexts}}` | `office/computer` | From @context syntax. If you have `["office", "computer"]`, outputs `"office/computer"` | +| `{{projects}}` | `ProjectA/ProjectB` | From +project syntax. If you have `["ProjectA", "ProjectB"]`, outputs `"ProjectA/ProjectB"` | + +**Example:** + +``` +Task: - [ ] Write report @office @computer +Using {{contexts}} outputs: "office/computer" +Result: office/computer/Write report.md +``` + +--- + +# Advanced Templates (Using `${...}`) + +Use JavaScript expressions for complex logic and transformations. All variables are available as JavaScript values. + +### How It Works + +Put JavaScript code in `${curly braces with dollar sign}`: + +- Variables work like normal JavaScript +- Arrays stay as arrays (don't auto-join) +- Use array methods like `.find()`, `.filter()`, `.includes()` +- Return values are converted to strings + +### Common Use Cases + +**Extract project from tags:** + +``` +Task: - [ ] Do chores #project/Personal +Template: (4) Projects/${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'Inbox'}/Tasks +Result: (4) Projects/Personal/Tasks/Do chores.md + +Task: - [ ] Random task (no project tag) +Result: (4) Projects/Inbox/Tasks/Random task.md +``` + +**Priority-based routing:** + +``` +Task: - [ ] Fix critical bug !high +Template: Tasks/${priority === 'high' ? 'urgent' : priority === 'medium' ? 'normal' : 'later'} +Result: Tasks/urgent/Fix critical bug.md + +Task: - [ ] Update docs !low +Result: Tasks/later/Update docs.md +``` + +**Get first context:** + +``` +Task: - [ ] Call client @office @phone +Template: ${contexts && contexts.length > 0 ? contexts[0] : 'inbox'} +Result: office/Call client.md + +Task: - [ ] General task (no context) +Result: inbox/General task.md +``` + +**Check if tag exists:** + +``` +Task: - [ ] Review PR #work +Template: ${tags.includes('work') ? 'Work' : 'Personal'} +Result: Work/Review PR.md +``` + +**Filter and transform tags:** + +``` +Task: - [ ] Planning #project-alpha #project-beta +Template: ${tags.filter(t => t.startsWith('project-')).map(t => t.replace('project-', '')).join('/')} +Result: alpha/beta/Planning.md +``` + +**Multiple conditions:** + +``` +Task: - [ ] Emergency fix !high #urgent +Template: ${priority === 'high' && tags.includes('urgent') ? 'critical' : 'normal'} +Result: critical/Emergency fix.md +``` + +**Date & status combination:** + +``` +Task: - [ ] Weekly review *in-progress +Template: ${year}/Q${quarter}/${status} +Result: 2025/Q4/in-progress/Weekly review.md +``` + +**Context with fallback:** + +``` +Task: - [ ] Write report @office +Template: ${contexts && contexts.length > 0 ? contexts[0] : 'inbox'} +Result: office/Write report.md +``` + +### Safe Navigation + +Always check if arrays exist before using them: + +```javascript +${tags && tags.length > 0 ? tags[0] : 'default'} +${contexts?.length > 0 ? contexts[0] : 'inbox'} +``` + +### Available Variables + +**All Date & Time Variables (String Type):** + +| Variable | Example | Description | +| ---------------- | ------------------- | -------------- | +| `year` | `2025` | Full year | +| `month` | `11` | Month (01-12) | +| `day` | `28` | Day (01-31) | +| `date` | `2025-11-28` | Full date | +| `monthName` | `November` | Month name | +| `monthNameShort` | `Nov` | Short month | +| `dayName` | `Thursday` | Day name | +| `dayNameShort` | `Thu` | Short day | +| `week` | `48` | Week number | +| `quarter` | `4` | Quarter (1-4) | +| `time` | `143025` | HHMMSS | +| `timestamp` | `2025-11-28-143025` | Full timestamp | +| `hour` | `14` | Hour | +| `minute` | `30` | Minute | +| `second` | `25` | Second | + +**All Task Properties:** + +| Variable | Type | Example | Notes | +| -------------- | ------- | ---------------------- | ---------------- | +| `title` | string | `Buy groceries` | Always available | +| `status` | string | `in-progress` | Always available | +| `priority` | string | `high` | Always available | +| `due` | string | `2025-12-01` | If set | +| `scheduled` | string | `2025-11-28` | If set | +| `details` | string | `Task description` | If provided | +| `recurrence` | string | `FREQ=DAILY` | If recurring | +| `timeEstimate` | number | `60` | Minutes, if set | +| `archived` | boolean | `false` | Always available | +| `dateCreated` | string | `2025-11-28T14:30:25Z` | Always available | +| `dateModified` | string | `2025-11-28T14:30:25Z` | Always available | + +**Array Properties (Stay as Arrays):** + +**Important:** Unlike `{{variable}}` syntax, arrays remain as JavaScript arrays + +| Variable | Type | Example | Notes | +| ---------- | -------- | ------------------------ | ----------------------------------------------------------- | +| `tags` | string[] | `["work", "urgent"]` | From #tag syntax. Use `.find()`, `.filter()`, `.includes()` | +| `contexts` | string[] | `["office", "computer"]` | From @context syntax | +| `projects` | string[] | `["ProjectA"]` | From +project syntax | + +**Array methods you can use:** + +- `tags.includes('work')` - Check if tag exists +- `tags.find(t => t.startsWith('project/'))` - Find specific tag +- `tags.filter(t => t.startsWith('prefix'))` - Filter tags +- `tags[0]` - Get first tag +- `tags.length` - Count tags +- `tags.join('/')` - Join with separator + +--- + +**Best Practices** + +1. **Start Simple** - Use `{{variable}}` for straightforward cases +2. **Test First** - Try your template with a test task before committing +3. **Handle Undefined** - Always check arrays exist: `${tags && tags.length > 0 ? ... : 'default'}` +4. **Keep Paths Short** - Long folder paths can cause issues on some systems +5. **Use Defaults** - Provide fallback values with `|| 'default'` syntax + +**Error Handling** + +- **JavaScript errors** return empty string (check console for details) +- **Undefined variables** in `{{}}` syntax are left as-is +- **Empty arrays** in `{{}}` syntax become empty strings +- **Folder names** are automatically sanitized for filesystem safety diff --git a/docs/features/inline-tasks.md b/docs/features/inline-tasks.md index 034b48bc..f1510c04 100644 --- a/docs/features/inline-tasks.md +++ b/docs/features/inline-tasks.md @@ -10,11 +10,11 @@ When a wikilink to a task note is created, TaskNotes can replace it with an inte ![Task Link Overlays in Live Preview mode](../assets/2025-07-17_21-03-55.png) -*Task link overlays in Live Preview mode show interactive widgets with status, dates, and quick actions* +_Task link overlays in Live Preview mode show interactive widgets with status, dates, and quick actions_ ![Task Link Overlays in Source mode](../assets/2025-07-17_21-04-24.png) -*In Source mode, task links appear as standard wikilinks until rendered* +_In Source mode, task links appear as standard wikilinks until rendered_ ### Widget Features @@ -46,6 +46,8 @@ When you run the command, the current line is used as the title of the new task. The **Instant Task Conversion** feature transforms lines in your notes into TaskNotes files. This works with both checkbox tasks and regular lines of text. Turn the feature on or off from **Settings → TaskNotes → General → Instant task convert**. When enabled, a "convert" button appears next to the content in edit mode. Clicking this button creates a new task note using the line's text as the title and replaces the original line with a link to the new task file. +**Configure where converted tasks are saved:** You can use dynamic folder templates to organize converted tasks automatically. See [Inline Task Folder For Converted Tasks](inline-tasks-folder-for-converted-tasks.md) for detailed configuration options, including extracting projects from tags, priority-based routing, and date-based organization. + ### Supported Line Types The conversion feature works with: @@ -194,14 +196,14 @@ The NLP engine supports multiple languages, including English, Spanish, French, The NLP engine recognizes: -- **Tags and Contexts**: `#tag` and `@context` syntax (triggers are customizable). -- **Projects**: `+project` for simple projects or `+[[Project Name]]` for projects with spaces. -- **Priority Levels**: Keywords like "high," "normal," and "low". Also supports a trigger character (default: `!`). -- **Status Assignment**: Keywords like "open," "in-progress," and "done". Also supports a trigger character (default: `*`). -- **Dates and Times**: Phrases like "tomorrow," "next Friday," and "January 15th at 3pm". -- **Time Estimates**: Formats like "2h," "30min," and "1h30m". -- **Recurrence Patterns**: Phrases like "daily," "weekly," and "every Monday". -- **User-Defined Fields**: Custom fields can be assigned using configured triggers (e.g., `effort: high`). Supports quoted values for multi-word entries. +- **Tags and Contexts**: `#tag` and `@context` syntax (triggers are customizable). +- **Projects**: `+project` for simple projects or `+[[Project Name]]` for projects with spaces. +- **Priority Levels**: Keywords like "high," "normal," and "low". Also supports a trigger character (default: `!`). +- **Status Assignment**: Keywords like "open," "in-progress," and "done". Also supports a trigger character (default: `*`). +- **Dates and Times**: Phrases like "tomorrow," "next Friday," and "January 15th at 3pm". +- **Time Estimates**: Formats like "2h," "30min," and "1h30m". +- **Recurrence Patterns**: Phrases like "daily," "weekly," and "every Monday". +- **User-Defined Fields**: Custom fields can be assigned using configured triggers (e.g., `effort: high`). Supports quoted values for multi-word entries. ### Rich Markdown Editor @@ -209,13 +211,13 @@ The NLP engine recognizes: Features include: -- **Live Preview**: Rendered markdown preview as you type. -- **Syntax Highlighting**: Code blocks, links, and formatting are highlighted. -- **Wikilink Support**: Create links to other notes using `[[Note Name]]` syntax. -- **Keyboard Shortcuts**: +- **Live Preview**: Rendered markdown preview as you type. +- **Syntax Highlighting**: Code blocks, links, and formatting are highlighted. +- **Wikilink Support**: Create links to other notes using `[[Note Name]]` syntax. +- **Keyboard Shortcuts**: - `Ctrl/Cmd+Enter` saves the task - `Esc` or `Tab` to navigate out of the editor -- **Placeholder Text**: Shows an example task (e.g., "Buy groceries tomorrow at 3pm @home #errands") when the editor is empty. +- **Placeholder Text**: Shows an example task (e.g., "Buy groceries tomorrow at 3pm @home #errands") when the editor is empty. ### Customizable Triggers @@ -223,12 +225,12 @@ Features include: You can configure trigger characters or strings for: -- **Tags** (default: `#`) - When set to `#`, Obsidian's native tag suggester is used -- **Contexts** (default: `@`) -- **Projects** (default: `+`) -- **Status** (default: `*`) -- **Priority** (default: `!`, disabled by default) -- **User-Defined Fields** (default: `fieldname:`) - Each custom field can have its own trigger +- **Tags** (default: `#`) - When set to `#`, Obsidian's native tag suggester is used +- **Contexts** (default: `@`) +- **Projects** (default: `+`) +- **Status** (default: `*`) +- **Priority** (default: `!`, disabled by default) +- **User-Defined Fields** (default: `fieldname:`) - Each custom field can have its own trigger Triggers support up to 10 characters and can include trailing spaces (e.g., `"def: "` for a custom field). @@ -236,10 +238,10 @@ Triggers support up to 10 characters and can include trailing spaces (e.g., `"de **New in v4**: When typing a trigger in the NLP editor, an autocomplete menu appears with available values. -- Navigate suggestions with arrow keys -- Select with `Enter` or `Tab` -- Autocomplete works for tags, contexts, projects, status, priority, and user-defined fields -- Tag autocomplete uses Obsidian's native tag suggester when using the `#` trigger -- For user fields with multi-word values, wrap the value in quotes (e.g., `effort: "very high"`) +- Navigate suggestions with arrow keys +- Select with `Enter` or `Tab` +- Autocomplete works for tags, contexts, projects, status, priority, and user-defined fields +- Tag autocomplete uses Obsidian's native tag suggester when using the `#` trigger +- For user fields with multi-word values, wrap the value in quotes (e.g., `effort: "very high"`) The NLP engine is integrated with the task creation modal and bulk conversion features. Typing a natural language description populates the corresponding task fields automatically. From 94412a7b7d95b325a5c55ddf6b285f3d67347fc2 Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:10:42 -0700 Subject: [PATCH 4/6] feat: inline tasks - allow custom file for inline tasks - naming based on variables and inline javascript --- ...nline-task-filename-for-converted-tasks.md | 413 +++++++++++ docs/features/inline-tasks.md | 2 + src/i18n/resources/en.ts | 10 + src/services/TaskService.ts | 54 +- src/settings/defaults.ts | 2 + src/settings/tabs/featuresTab.ts | 26 + src/types/settings.ts | 2 + src/utils/filenameGenerator.ts | 31 + .../filenameGenerator.custom-filename.test.ts | 663 ++++++++++++++++++ 9 files changed, 1182 insertions(+), 21 deletions(-) create mode 100644 docs/features/inline-task-filename-for-converted-tasks.md create mode 100644 tests/unit/utils/filenameGenerator.custom-filename.test.ts diff --git a/docs/features/inline-task-filename-for-converted-tasks.md b/docs/features/inline-task-filename-for-converted-tasks.md new file mode 100644 index 00000000..d1d72ea5 --- /dev/null +++ b/docs/features/inline-task-filename-for-converted-tasks.md @@ -0,0 +1,413 @@ +# Inline Task Filename For Converted Tasks + +Configure custom filenames for converted inline tasks using dynamic filename templates. + +**Important**: This feature only changes the **file name**, not the **task name**. The task name always comes from the text you write in your task. + +Set the custom filename template in **Settings → TaskNotes → Features → Enable Custom File Name** (toggle ON) and enter your template in the text field below. + +## Quick Start + +There are two ways to use variables in filename templates: + +- **`{{variable}}`** - Simple substitution for straightforward cases +- **`${...}`** - JavaScript expressions for complex logic + +**Examples:** + +**Simple priority-based filename:** + +``` +Task: - [ ] Buy groceries !high +Template: {{priority}}-{{title}} +Result: + - File name: high-Buy groceries.md + - Task name in frontmatter: "Buy groceries" +``` + +**Extract project from tags (advanced):** + +``` +Task: - [ ] Do chores #project/Personal +Template: ${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'task'}-{{title}} +Result: + - File name: Personal-Do chores.md + - Task name in frontmatter: "Do chores" +``` + +--- + +## Important: Filename vs Task Name + +**The custom filename feature only changes the file name - it does NOT change the task name.** + +When you write a task like: +``` +- [ ] Buy groceries !high +``` + +And use a template like `{{priority}}-{{title}}`: + +- **File name**: `high-Buy groceries.md` (customized by template) +- **Task name**: "Buy groceries" (remains as you wrote it) + +The task name comes from the actual text you write in your task. The filename template only determines how the file is named on disk. Inside the note, the frontmatter will contain `title: Buy groceries` - your original task text without any template formatting. + +This is different from the default behavior where the title is stored in the filename and removed from frontmatter. + +--- + +## Basic Templates (Using `{{variable}}`) + +Use double curly braces for simple variable substitution. Variables are replaced with their text values. + +### How It Works + +Put variable names in `{{double curly braces}}` and they'll be replaced with their values: + +- `{{year}}` becomes `2025` +- `{{priority}}` becomes `high` +- `{{title}}` becomes `Buy groceries` + +### Common Use Cases + +**Priority prefix:** + +``` +Task: - [ ] Important meeting !high +Template: {{priority}}-{{title}} +Result: high-Important meeting.md +``` + +**Date-based filename:** + +``` +Task: - [ ] Weekly review +Template: {{date}}-{{title}} +Result: 2025-11-28-Weekly review.md +``` + +**Timestamp filename:** + +``` +Task: - [ ] Quick note +Template: {{timestamp}}-{{title}} +Result: 2025-11-28-143025-Quick note.md +``` + +**Status indicator:** + +``` +Task: - [ ] Draft proposal *in-progress +Template: ({{status}}) {{title}} +Result: (in-progress) Draft proposal.md +``` + +**Note**: Square brackets `[` `]` are removed from filenames for filesystem safety. Use parentheses `(` `)` instead. + +**Zettelkasten style:** + +``` +Task: - [ ] Research topic +Template: {{zettel}}-{{title}} +Result: 2511281m8n-Research topic.md +``` + +### Available Variables + +**Date & Time (Always Available):** + +| Variable | Example Output | Description | +| -------------------- | ------------------- | ------------------- | +| `{{year}}` | `2025` | Full year | +| `{{month}}` | `11` | Month (01-12) | +| `{{day}}` | `28` | Day (01-31) | +| `{{date}}` | `2025-11-28` | Full date | +| `{{quarter}}` | `4` | Quarter (1-4) | +| `{{monthName}}` | `November` | Month name | +| `{{monthNameShort}}` | `Nov` | Short month | +| `{{dayName}}` | `Thursday` | Day name | +| `{{dayNameShort}}` | `Thu` | Short day | +| `{{week}}` | `48` | Week number | +| `{{time}}` | `143025` | HHMMSS | +| `{{timestamp}}` | `2025-11-28-143025` | Full timestamp | +| `{{zettel}}` | `2511281m8n` | Zettelkasten ID | +| `{{hour}}` | `14` | Hour | +| `{{minute}}` | `30` | Minute | +| `{{second}}` | `25` | Second | + +**Task Properties (Available During Creation):** + +| Variable | Example Output | Notes | +| ------------------ | ---------------------- | ---------------- | +| `{{title}}` | `Buy groceries` | Always available | +| `{{status}}` | `in-progress` | Always available | +| `{{priority}}` | `high` | Always available | +| `{{due}}` | `2025-12-01` | If set | +| `{{scheduled}}` | `2025-11-28` | If set | +| `{{recurrence}}` | `FREQ=DAILY` | If recurring | +| `{{timeEstimate}}` | `60` | Minutes, if set | +| `{{archived}}` | `false` | Always available | +| `{{dateCreated}}` | `2025-11-28T14:30:25Z` | Always available | +| `{{dateModified}}` | `2025-11-28T14:30:25Z` | Always available | + +**Array Properties:** + +**Note:** These do NOT auto-join in filename templates (unlike folder templates). Arrays are converted to comma-separated values. + +| Variable | Example Output | Notes | +| -------------- | -------------- | -------------------------- | +| `{{tags}}` | `work,urgent` | From #tag syntax | +| `{{contexts}}` | `office` | From @context syntax | +| `{{projects}}` | `ProjectA` | From +project syntax | + +--- + +## Advanced Templates (Using `${...}`) + +Use JavaScript expressions for complex logic and transformations. All variables are available as JavaScript values. + +### How It Works + +Put JavaScript code in `${curly braces with dollar sign}`: + +- Variables work like normal JavaScript +- Arrays stay as arrays (use array methods) +- Use array methods like `.find()`, `.filter()`, `.includes()` +- Return values are converted to strings + +### Common Use Cases + +**Extract project from tags:** + +``` +Task: - [ ] Do chores #project/Personal +Template: ${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'task'}-{{title}} +Result: Personal-Do chores.md + +Task: - [ ] Random task (no project tag) +Result: task-Random task.md +``` + +**Priority-based prefix:** + +``` +Task: - [ ] Fix critical bug !high +Template: ${priority === 'high' ? 'URGENT' : priority === 'medium' ? 'NORMAL' : 'LATER'}-{{title}} +Result: URGENT-Fix critical bug.md + +Task: - [ ] Update docs !low +Result: LATER-Update docs.md +``` + +**Check if tag exists:** + +``` +Task: - [ ] Review PR #work +Template: ${tags.includes('work') ? 'WORK' : 'PERSONAL'}-{{title}} +Result: WORK-Review PR.md +``` + +**Get first context:** + +``` +Task: - [ ] Call client @office @phone +Template: ${contexts && contexts.length > 0 ? contexts[0] : 'general'}-{{title}} +Result: office-Call client.md + +Task: - [ ] General task (no context) +Result: general-General task.md +``` + +**Combine priority and date:** + +``` +Task: - [ ] Q4 planning !high +Template: ${priority === 'high' ? 'P1' : 'P2'}-{{year}}Q{{quarter}}-{{title}} +Result: P1-2025Q4-Q4 planning.md +``` + +**Multiple conditions:** + +``` +Task: - [ ] Emergency fix !high #urgent +Template: ${priority === 'high' && tags.includes('urgent') ? 'CRITICAL' : 'NORMAL'}-{{title}} +Result: CRITICAL-Emergency fix.md +``` + +**Filter and transform tags:** + +``` +Task: - [ ] Planning #area-dev #area-ops +Template: ${tags.filter(t => t.startsWith('area-')).map(t => t.replace('area-', '')).join('-')}-{{title}} +Result: dev-ops-Planning.md +``` + +**Extract category from nested tags:** + +``` +Task: - [ ] Meeting notes #meeting/weekly #team/engineering +Template: ${tags.find(t => t.startsWith('meeting/'))?.split('/')[1] || 'general'}-{{title}} +Result: weekly-Meeting notes.md +``` + +### Safe Navigation + +Always check if arrays exist before using them: + +```javascript +${tags && tags.length > 0 ? tags[0] : 'default'} +${contexts?.length > 0 ? contexts[0] : 'general'} +``` + +### Available Variables + +**All Date & Time Variables (String Type):** + +| Variable | Example | Description | +| ---------------- | ------------------- | ------------------- | +| `year` | `2025` | Full year | +| `month` | `11` | Month (01-12) | +| `day` | `28` | Day (01-31) | +| `date` | `2025-11-28` | Full date | +| `monthName` | `November` | Month name | +| `monthNameShort` | `Nov` | Short month | +| `dayName` | `Thursday` | Day name | +| `dayNameShort` | `Thu` | Short day | +| `week` | `48` | Week number | +| `quarter` | `4` | Quarter (1-4) | +| `time` | `143025` | HHMMSS | +| `timestamp` | `2025-11-28-143025` | Full timestamp | +| `zettel` | `2511281m8n` | Zettelkasten ID | +| `hour` | `14` | Hour | +| `minute` | `30` | Minute | +| `second` | `25` | Second | + +**All Task Properties:** + +| Variable | Type | Example | Notes | +| -------------- | ------- | ---------------------- | ---------------- | +| `title` | string | `Buy groceries` | Always available | +| `status` | string | `in-progress` | Always available | +| `priority` | string | `high` | Always available | +| `due` | string | `2025-12-01` | If set | +| `scheduled` | string | `2025-11-28` | If set | +| `details` | string | `Task description` | If provided | +| `recurrence` | string | `FREQ=DAILY` | If recurring | +| `timeEstimate` | number | `60` | Minutes, if set | +| `archived` | boolean | `false` | Always available | +| `dateCreated` | string | `2025-11-28T14:30:25Z` | Always available | +| `dateModified` | string | `2025-11-28T14:30:25Z` | Always available | + +**Array Properties (Stay as Arrays):** + +**Important:** Unlike `{{variable}}` syntax, arrays remain as JavaScript arrays + +| Variable | Type | Example | Notes | +| ---------- | -------- | ------------------------ | ----------------------------------------------------------- | +| `tags` | string[] | `["work", "urgent"]` | From #tag syntax. Use `.find()`, `.filter()`, `.includes()` | +| `contexts` | string[] | `["office", "computer"]` | From @context syntax | +| `projects` | string[] | `["ProjectA"]` | From +project syntax | + +**Array methods you can use:** + +- `tags.includes('work')` - Check if tag exists +- `tags.find(t => t.startsWith('project/'))` - Find specific tag +- `tags.filter(t => t.startsWith('prefix'))` - Filter tags +- `tags[0]` - Get first tag +- `tags.length` - Count tags +- `tags.join('-')` - Join with separator + +--- + +## Practical Examples + +### Example 1: Project-Based Organization + +``` +Task: - [ ] Write documentation #project/Kinross +Template: ${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'Inbox'}-{{title}} +Result: Kinross-Write documentation.md +``` + +### Example 2: Priority Indicator + +``` +Task: - [ ] Fix bug !high +Template: ${priority === 'high' ? '!!' : priority === 'medium' ? '!' : ''}-{{title}} +Result: !!-Fix bug.md +``` + +### Example 3: Date + Priority Combination + +``` +Task: - [ ] Weekly review !high +Template: {{date}}-${priority === 'high' ? 'PRIORITY' : 'normal'}-{{title}} +Result: 2025-11-28-PRIORITY-Weekly review.md +``` + +### Example 4: Context-Based Prefix + +``` +Task: - [ ] Call client @phone +Template: ${contexts && contexts[0] ? `[${contexts[0].toUpperCase()}]` : ''} {{title}} +Result: [PHONE] Call client.md +``` + +### Example 5: Team/Area Routing + +``` +Task: - [ ] Code review #team/frontend +Template: ${tags.find(t => t.startsWith('team/'))?.split('/')[1]?.toUpperCase() || 'GENERAL'}-{{title}} +Result: FRONTEND-Code review.md +``` + +--- + +## Best Practices + +1. **Start Simple** - Use `{{variable}}` for straightforward cases +2. **Test First** - Try your template with a test task before committing +3. **Handle Undefined** - Always check arrays exist: `${tags && tags.length > 0 ? ... : 'default'}` +4. **Keep Names Short** - Long filenames can cause issues on some systems +5. **Use Defaults** - Provide fallback values with `|| 'default'` syntax +6. **Sanitization** - Filenames are automatically sanitized for filesystem safety + +## Error Handling + +- **JavaScript errors** fall back to title-based filename (check console for details) +- **Undefined variables** in `{{}}` syntax are left as empty strings +- **Empty arrays** in `{{}}` syntax become empty strings +- **Filenames** are automatically sanitized (removes invalid characters) +- **Empty template** behaves as if feature is disabled (uses default naming) + +## Configuration Steps + +1. Open **Settings → TaskNotes → Features** +2. Scroll to **Inline Task Convert** section +3. Toggle ON **Enable Custom File Name** +4. Enter your template in the **Custom File Name** text field +5. Test with a sample task to verify behavior + +## Combining with Folder Templates + +You can use both folder templates and filename templates together: + +**Folder Template:** +``` +(4) Projects/${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'Inbox'}/Tasks +``` + +**Filename Template:** +``` +{{priority}}-{{title}} +``` + +**Result:** +``` +Task: - [ ] Do chores #project/Personal !high +Creates: (4) Projects/Personal/Tasks/high-Do chores.md +Title in frontmatter: "Do chores" +``` + +This gives you complete control over both the folder structure and filename format for converted tasks. diff --git a/docs/features/inline-tasks.md b/docs/features/inline-tasks.md index f1510c04..e102115f 100644 --- a/docs/features/inline-tasks.md +++ b/docs/features/inline-tasks.md @@ -48,6 +48,8 @@ The **Instant Task Conversion** feature transforms lines in your notes into Task **Configure where converted tasks are saved:** You can use dynamic folder templates to organize converted tasks automatically. See [Inline Task Folder For Converted Tasks](inline-tasks-folder-for-converted-tasks.md) for detailed configuration options, including extracting projects from tags, priority-based routing, and date-based organization. +**Configure custom filenames for converted tasks:** You can use custom filename templates to control how converted task files are named. See [Inline Task Filename For Converted Tasks](inline-task-filename-for-converted-tasks.md) for detailed configuration options, including using task properties, date formats, and advanced JavaScript expressions. The task title remains separate from the filename in the frontmatter. + ### Supported Line Types The conversion feature works with: diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 9ebebc4f..e2093cc7 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -433,6 +433,16 @@ export const en: TranslationTree = { description: "Folder where tasks converted from checkboxes will be created. Use {{currentNotePath}} for relative to current note, {{currentNoteTitle}} for current note title", }, + toggleCustomFileName: { + name: "Show custom file name option", + description: + "Enable option to specify custom file name when converting checkboxes to tasks", + }, + customFileName: { + name: "Custom file name for converted tasks", + description: + "File where tasks converted from checkboxes will be created. Supports template variables like {{title}}, {{date}}, {{time}}, etc.", + }, }, nlp: { header: "Natural Language Processing", diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 6a8ebe72..29f6d546 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -215,18 +215,6 @@ export class TaskService { } } - // Generate filename - const filenameContext: FilenameContext = { - title: title, - priority: priority, - status: status, - date: new Date(), - dueDate: taskData.due, - scheduledDate: taskData.scheduled, - }; - - const baseFilename = generateTaskFilename(filenameContext, this.plugin.settings); - // Determine folder based on creation context // Process folder templates with task and date variables for dynamic folder organization let folder = ""; @@ -280,14 +268,6 @@ export class TaskService { await ensureFolderExists(this.plugin.app.vault, folder); } - // Generate unique filename - const uniqueFilename = await generateUniqueFilename( - baseFilename, - folder, - this.plugin.app.vault - ); - const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; - // Create complete TaskInfo object with all the data const completeTaskData: Partial = { title: title, @@ -311,11 +291,43 @@ export class TaskService { icsEventId: taskData.icsEventId || undefined, }; + // Generate filename with full task info for advanced templating + const filenameContext: FilenameContext = { + title: title, + priority: priority, + status: status, + date: new Date(), + dueDate: taskData.due, + scheduledDate: taskData.scheduled, + creationContext: taskData.creationContext, // Pass directly for inline conversion check + fullTaskInfo: completeTaskData as TaskInfo, // Full task info for template variables + }; + + const baseFilename = generateTaskFilename(filenameContext, this.plugin.settings); + + // Generate unique filename + const uniqueFilename = await generateUniqueFilename( + baseFilename, + folder, + this.plugin.app.vault + ); + const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + + // Determine if we should store title in filename + // When using custom filenames for inline conversion, keep title in frontmatter + const usingCustomFilename = + taskData.creationContext === "inline-conversion" && + this.plugin.settings.toggleCustomFileName && + this.plugin.settings.customFileName && + this.plugin.settings.customFileName.trim(); + const shouldStoreTitleInFilename = + !usingCustomFilename && this.plugin.settings.storeTitleInFilename; + // Use field mapper to convert to frontmatter with proper field mapping const frontmatter = this.plugin.fieldMapper.mapToFrontmatter( completeTaskData, this.plugin.settings.taskTag, - this.plugin.settings.storeTitleInFilename + shouldStoreTitleInFilename ); // Handle task identification based on settings diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 696b338a..c19568cd 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -278,6 +278,8 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Inline task conversion defaults inlineTaskConvertFolder: "{{currentNotePath}}", + toggleCustomFileName: false, // Disabled by default (use existing filename format) + customFileName: "{{title}}", // Default to title-based naming when enabled // Performance defaults disableNoteIndexing: false, // Suggestion performance defaults diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index cd2d8f2f..03816eb9 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -108,6 +108,32 @@ export function renderFeaturesTab( }, }); + createToggleSetting(container, { + name: translate("settings.features.instantConvert.toggleCustomFileName.name"), + desc: translate("settings.features.instantConvert.toggleCustomFileName.description"), + getValue: () => plugin.settings.toggleCustomFileName, + setValue: async (value: boolean) => { + plugin.settings.toggleCustomFileName = value; + save(); + // Re-render to show/hide filename input + renderFeaturesTab(container, plugin, save); + }, + }); + + // Show filename input only when toggle is ON + if (plugin.settings.toggleCustomFileName) { + createTextSetting(container, { + name: translate("settings.features.instantConvert.customFileName.name"), + desc: translate("settings.features.instantConvert.customFileName.description"), + placeholder: "{{title}}", + getValue: () => plugin.settings.customFileName, + setValue: async (value: string) => { + plugin.settings.customFileName = value; + save(); + }, + }); + } + // Natural Language Processing Section createSectionHeader(container, translate("settings.features.nlp.header")); createHelpText(container, translate("settings.features.nlp.description")); diff --git a/src/types/settings.ts b/src/types/settings.ts index cd918702..9588c045 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -142,6 +142,8 @@ export interface TaskNotesSettings { doubleClickAction: "edit" | "openNote" | "none"; // Inline task conversion settings inlineTaskConvertFolder: string; // Folder for inline task conversion, supports {{currentNotePath}} and {{currentNoteTitle}} + toggleCustomFileName: boolean; // Enable custom filename template for instant conversion + customFileName: string; // Custom filename template with {{variable}} and ${...} support // Performance settings disableNoteIndexing: boolean; /** Optional debounce in milliseconds for inline file suggestions (0 = disabled) */ diff --git a/src/utils/filenameGenerator.ts b/src/utils/filenameGenerator.ts index fd9e50ac..c8fa2bc3 100644 --- a/src/utils/filenameGenerator.ts +++ b/src/utils/filenameGenerator.ts @@ -1,6 +1,8 @@ import { format } from "date-fns"; import { normalizePath } from "obsidian"; import { TaskNotesSettings } from "../types/settings"; +import { TaskInfo } from "../types"; +import { processFolderTemplate } from "./folderTemplateProcessor"; export interface FilenameContext { title: string; @@ -9,6 +11,8 @@ export interface FilenameContext { date?: Date; dueDate?: string; // YYYY-MM-DD format scheduledDate?: string; // YYYY-MM-DD format + creationContext?: string; // Context for determining filename generation (e.g., "inline-conversion") + fullTaskInfo?: TaskInfo; // Full task info for advanced templating } export interface ICSFilenameContext extends FilenameContext { @@ -127,6 +131,19 @@ export function generateTaskFilename( throw new Error("Invalid date provided in context"); } + // Priority 1: Custom filename for inline conversion + if (context.creationContext === "inline-conversion") { + const toggleEnabled = settings.toggleCustomFileName; + const templateHasContent = settings.customFileName && settings.customFileName.trim(); + + // BOTH must be true to use custom filename + if (toggleEnabled && templateHasContent) { + return generateCustomFilename(context, settings.customFileName, now); + } + // Otherwise fall through to existing logic + } + + // Priority 2: storeTitleInFilename setting if (settings.storeTitleInFilename) { return sanitizeForFilename(context.title); } @@ -182,6 +199,7 @@ function generateTimestampFilename(date: Date): string { /** * Generates a filename based on a custom template + * Supports both legacy {variable} syntax and new {{variable}} and ${...} syntax */ function generateCustomFilename( context: FilenameContext, @@ -203,6 +221,19 @@ function generateCustomFilename( } try { + // Check if template uses new {{variable}} or ${...} syntax + const usesNewSyntax = template.includes('{{') || template.includes('${'); + + if (usesNewSyntax && context.fullTaskInfo) { + // Use the advanced folder template processor + let result = processFolderTemplate(template, { + date, + fullTaskInfo: context.fullTaskInfo, + }); + + // Sanitize the result for filename use + return sanitizeForFilename(result); + } // Validate and sanitize context values const sanitizedTitle = sanitizeForFilename(context.title); const sanitizedPriority = diff --git a/tests/unit/utils/filenameGenerator.custom-filename.test.ts b/tests/unit/utils/filenameGenerator.custom-filename.test.ts new file mode 100644 index 00000000..b35cc386 --- /dev/null +++ b/tests/unit/utils/filenameGenerator.custom-filename.test.ts @@ -0,0 +1,663 @@ +/** + * Custom Filename Generation Tests + * + * Tests for the custom filename feature for inline task conversion. + * Validates all template examples from the documentation work correctly. + */ + +import { + generateTaskFilename, + FilenameContext, + sanitizeForFilename, +} from '../../../src/utils/filenameGenerator'; +import { TaskNotesSettings } from '../../../src/types/settings'; +import { TaskInfo } from '../../../src/types'; + +// Import format for date processing +import { format } from 'date-fns'; + +// Mock the folder template processor for advanced syntax +jest.mock('../../../src/utils/folderTemplateProcessor', () => ({ + processFolderTemplate: jest.fn((template: string, data: any) => { + // Simple mock implementation for testing + const { date, fullTaskInfo } = data; + let result = template; + + // Build context for both {{}} and ${} syntax + const context: Record = { + // Date variables + year: date ? format(date, 'yyyy') : '', + month: date ? format(date, 'MM') : '', + day: date ? format(date, 'dd') : '', + date: date ? format(date, 'yyyy-MM-dd') : '', + ...fullTaskInfo, + }; + + // Handle JavaScript expressions ${...} + if (template.includes('${')) { + result = result.replace(/\$\{([^}]+)\}/g, (match, expression) => { + try { + // Evaluate the expression + const func = new Function(...Object.keys(context), `"use strict"; return (${expression})`); + const value = func(...Object.values(context)); + + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.join('/'); + } + return String(value); + } catch (error) { + console.error('Error evaluating expression:', expression, error); + return ''; + } + }); + } + + // Handle {{variable}} replacements + result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => { + const value = context[key]; + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.join('/'); + } + return String(value); + }); + + return result; + }), +})); + +describe('Custom Filename Generation', () => { + const testDate = new Date('2025-11-28T14:30:25Z'); + + const createSettings = (overrides: Partial = {}): TaskNotesSettings => ({ + // Only include properties relevant to filename generation + toggleCustomFileName: false, + customFileName: '{{title}}', + storeTitleInFilename: false, + taskFilenameFormat: 'zettel', + customFilenameTemplate: '{{title}}', + ...overrides, + } as TaskNotesSettings); + + const createTaskInfo = (overrides: Partial = {}): TaskInfo => ({ + id: 'test-id', + title: 'Test Task', + status: 'open', + priority: 'normal', + tags: [], + contexts: [], + projects: [], + archived: false, + dateCreated: '2025-11-28T14:30:25Z', + dateModified: '2025-11-28T14:30:25Z', + path: '', + ...overrides, + } as TaskInfo); + + describe('Basic Templates ({{variable}} syntax)', () => { + describe('Priority prefix', () => { + it('should generate filename with priority prefix', () => { + const context: FilenameContext = { + title: 'Important meeting', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Important meeting', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '{{priority}}-{{title}}', + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('high-Important meeting'); + }); + }); + + describe('Date-based filename', () => { + it('should generate filename with date prefix', () => { + const context: FilenameContext = { + title: 'Weekly review', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Weekly review', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '{{date}}-{{title}}', + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('2025-11-28-Weekly review'); + }); + }); + + describe('Status indicator', () => { + it('should generate filename with status (brackets removed by sanitization)', () => { + const context: FilenameContext = { + title: 'Draft proposal', + priority: 'normal', + status: 'in-progress', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Draft proposal', + status: 'in-progress', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '[{{status}}] {{title}}', + }); + + const result = generateTaskFilename(context, settings); + // Note: Square brackets are removed by sanitizeForFilename for filesystem safety + expect(result).toBe('in-progress Draft proposal'); + }); + + it('should generate filename with status using parentheses instead', () => { + const context: FilenameContext = { + title: 'Draft proposal', + priority: 'normal', + status: 'in-progress', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Draft proposal', + status: 'in-progress', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '({{status}}) {{title}}', + }); + + const result = generateTaskFilename(context, settings); + // Parentheses are allowed in filenames + expect(result).toBe('(in-progress) Draft proposal'); + }); + }); + }); + + describe('Advanced Templates (${...} syntax)', () => { + describe('Extract project from tags', () => { + it('should extract project name from tag', () => { + const context: FilenameContext = { + title: 'Do chores', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Do chores', + tags: ['project/Personal', 'urgent'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'task'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('Personal-Do chores'); + }); + + it('should use fallback when no project tag exists', () => { + const context: FilenameContext = { + title: 'Random task', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Random task', + tags: ['urgent'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'task'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('task-Random task'); + }); + }); + + describe('Priority-based prefix', () => { + it('should use URGENT for high priority', () => { + const context: FilenameContext = { + title: 'Fix critical bug', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Fix critical bug', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${priority === 'high' ? 'URGENT' : priority === 'medium' ? 'NORMAL' : 'LATER'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('URGENT-Fix critical bug'); + }); + + it('should use LATER for low priority', () => { + const context: FilenameContext = { + title: 'Update docs', + priority: 'low', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Update docs', + priority: 'low', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${priority === 'high' ? 'URGENT' : priority === 'medium' ? 'NORMAL' : 'LATER'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('LATER-Update docs'); + }); + }); + + describe('Check if tag exists', () => { + it('should use WORK when work tag exists', () => { + const context: FilenameContext = { + title: 'Review PR', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Review PR', + tags: ['work', 'code-review'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.includes('work') ? 'WORK' : 'PERSONAL'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('WORK-Review PR'); + }); + + it('should use PERSONAL when work tag does not exist', () => { + const context: FilenameContext = { + title: 'Buy groceries', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Buy groceries', + tags: ['shopping'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.includes('work') ? 'WORK' : 'PERSONAL'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('PERSONAL-Buy groceries'); + }); + }); + + describe('Get first context', () => { + it('should use first context when contexts exist', () => { + const context: FilenameContext = { + title: 'Call client', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Call client', + contexts: ['office', 'phone'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '${contexts && contexts.length > 0 ? contexts[0] : "general"}-{{title}}', + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('office-Call client'); + }); + + it('should use fallback when no contexts exist', () => { + const context: FilenameContext = { + title: 'General task', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'General task', + contexts: [], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '${contexts && contexts.length > 0 ? contexts[0] : "general"}-{{title}}', + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('general-General task'); + }); + }); + + describe('Multiple conditions', () => { + it('should use CRITICAL for high priority with urgent tag', () => { + const context: FilenameContext = { + title: 'Emergency fix', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Emergency fix', + priority: 'high', + tags: ['urgent'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${priority === 'high' && tags.includes('urgent') ? 'CRITICAL' : 'NORMAL'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('CRITICAL-Emergency fix'); + }); + + it('should use NORMAL when conditions not met', () => { + const context: FilenameContext = { + title: 'Regular task', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Regular task', + priority: 'normal', + tags: [], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${priority === 'high' && tags.includes('urgent') ? 'CRITICAL' : 'NORMAL'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('NORMAL-Regular task'); + }); + }); + + describe('Filter and transform tags', () => { + it('should filter tags starting with area- and join them', () => { + const context: FilenameContext = { + title: 'Planning', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Planning', + tags: ['area-dev', 'area-ops', 'urgent'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.filter(t => t.startsWith('area-')).map(t => t.replace('area-', '')).join('-')}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('dev-ops-Planning'); + }); + }); + + describe('Extract category from nested tags', () => { + it('should extract meeting type from nested tag', () => { + const context: FilenameContext = { + title: 'Meeting notes', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Meeting notes', + tags: ['meeting/weekly', 'team/engineering'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.find(t => t.startsWith('meeting/'))?.split('/')[1] || 'general'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('weekly-Meeting notes'); + }); + }); + }); + + describe('Fallback behavior', () => { + it('should NOT use custom filename when toggle is OFF', () => { + const context: FilenameContext = { + title: 'Test Task', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Test Task', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: false, // Toggle OFF + customFileName: '{{priority}}-{{title}}', + storeTitleInFilename: true, + }); + + const result = generateTaskFilename(context, settings); + // Should use title format due to storeTitleInFilename + expect(result).toBe('Test Task'); + }); + + it('should NOT use custom filename when template is empty', () => { + const context: FilenameContext = { + title: 'Test Task', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Test Task', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, // Toggle ON + customFileName: '', // Empty template + storeTitleInFilename: true, + }); + + const result = generateTaskFilename(context, settings); + // Should use title format due to storeTitleInFilename + expect(result).toBe('Test Task'); + }); + + it('should NOT use custom filename for non-inline tasks', () => { + const context: FilenameContext = { + title: 'Test Task', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'modal', // NOT inline-conversion + fullTaskInfo: createTaskInfo({ + title: 'Test Task', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: '{{priority}}-{{title}}', + storeTitleInFilename: true, + }); + + const result = generateTaskFilename(context, settings); + // Should use title format, not custom filename + expect(result).toBe('Test Task'); + }); + }); + + describe('Filename sanitization', () => { + it('should sanitize invalid filename characters', () => { + const result = sanitizeForFilename('Test<>:"/\\|?*#Task'); + expect(result).not.toContain('<'); + expect(result).not.toContain('>'); + expect(result).not.toContain(':'); + expect(result).not.toContain('"'); + expect(result).not.toContain('\\'); + expect(result).not.toContain('|'); + expect(result).not.toContain('?'); + expect(result).not.toContain('*'); + expect(result).not.toContain('#'); + }); + + it('should preserve spaces in filenames', () => { + const result = sanitizeForFilename('Test Task Name'); + expect(result).toBe('Test Task Name'); + }); + + it('should handle multiple consecutive spaces', () => { + const result = sanitizeForFilename('Test Task Name'); + expect(result).toBe('Test Task Name'); + }); + + it('should handle empty input', () => { + const result = sanitizeForFilename(''); + expect(result).toBe('untitled'); + }); + + it('should handle Windows reserved names', () => { + const result = sanitizeForFilename('CON'); + expect(result).toBe('task-CON'); + }); + }); + + describe('Documentation examples validation', () => { + describe('Example 1: Project-Based Organization', () => { + it('should match documentation example', () => { + const context: FilenameContext = { + title: 'Write documentation', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Write documentation', + tags: ['project/Kinross'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.find(t => t.startsWith('project/'))?.split('/')[1] || 'Inbox'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('Kinross-Write documentation'); + }); + }); + + describe('Example 2: Priority Indicator', () => { + it('should match documentation example', () => { + const context: FilenameContext = { + title: 'Fix bug', + priority: 'high', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Fix bug', + priority: 'high', + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${priority === 'high' ? '!!' : priority === 'medium' ? '!' : ''}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('!!-Fix bug'); + }); + }); + + describe('Example 5: Team/Area Routing', () => { + it('should match documentation example', () => { + const context: FilenameContext = { + title: 'Code review', + priority: 'normal', + status: 'open', + date: testDate, + creationContext: 'inline-conversion', + fullTaskInfo: createTaskInfo({ + title: 'Code review', + tags: ['team/frontend'], + }), + }; + + const settings = createSettings({ + toggleCustomFileName: true, + customFileName: "${tags.find(t => t.startsWith('team/'))?.split('/')[1]?.toUpperCase() || 'GENERAL'}-{{title}}", + }); + + const result = generateTaskFilename(context, settings); + expect(result).toBe('FRONTEND-Code review'); + }); + }); + }); +}); From 35d80532adaf07392f7801f730958bd5ecfc73b7 Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:11:06 -0700 Subject: [PATCH 5/6] fix: fix failing tests related to new features --- tests/unit/services/TaskService.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index 2b0f4f97..e0ce77bb 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -28,7 +28,8 @@ jest.mock('../../../src/utils/filenameGenerator', () => ({ })); jest.mock('../../../src/utils/helpers', () => ({ - ensureFolderExists: jest.fn().mockResolvedValue(undefined) + ensureFolderExists: jest.fn().mockResolvedValue(undefined), + resolveRelativePath: jest.fn((path) => path) // Pass through by default })); jest.mock('../../../src/utils/templateProcessor', () => ({ From a7fd92bb67c158d650093461e2b22e1afb147b42 Mon Sep 17 00:00:00 2001 From: rdick <62073529+rdick@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:24:22 -0700 Subject: [PATCH 6/6] fix: updated docs file name for consistency --- ...ed-tasks.md => inline-tasks-filename-for-converted-tasks.md} | 2 +- docs/features/inline-tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/features/{inline-task-filename-for-converted-tasks.md => inline-tasks-filename-for-converted-tasks.md} (99%) diff --git a/docs/features/inline-task-filename-for-converted-tasks.md b/docs/features/inline-tasks-filename-for-converted-tasks.md similarity index 99% rename from docs/features/inline-task-filename-for-converted-tasks.md rename to docs/features/inline-tasks-filename-for-converted-tasks.md index d1d72ea5..0a9d5d99 100644 --- a/docs/features/inline-task-filename-for-converted-tasks.md +++ b/docs/features/inline-tasks-filename-for-converted-tasks.md @@ -1,4 +1,4 @@ -# Inline Task Filename For Converted Tasks +# Inline Tasks Filename For Converted Tasks Configure custom filenames for converted inline tasks using dynamic filename templates. diff --git a/docs/features/inline-tasks.md b/docs/features/inline-tasks.md index e102115f..4981aaf9 100644 --- a/docs/features/inline-tasks.md +++ b/docs/features/inline-tasks.md @@ -48,7 +48,7 @@ The **Instant Task Conversion** feature transforms lines in your notes into Task **Configure where converted tasks are saved:** You can use dynamic folder templates to organize converted tasks automatically. See [Inline Task Folder For Converted Tasks](inline-tasks-folder-for-converted-tasks.md) for detailed configuration options, including extracting projects from tags, priority-based routing, and date-based organization. -**Configure custom filenames for converted tasks:** You can use custom filename templates to control how converted task files are named. See [Inline Task Filename For Converted Tasks](inline-task-filename-for-converted-tasks.md) for detailed configuration options, including using task properties, date formats, and advanced JavaScript expressions. The task title remains separate from the filename in the frontmatter. +**Configure custom filenames for converted tasks:** You can use custom filename templates to control how converted task files are named. See [Inline Task Filename For Converted Tasks](inline-tasks-filename-for-converted-tasks.md) for detailed configuration options, including using task properties, date formats, and advanced JavaScript expressions. The task title remains separate from the filename in the frontmatter. ### Supported Line Types