From 8905686a697db5b17c49098ce948b9dcd718acec Mon Sep 17 00:00:00 2001 From: Callum Alpass Date: Tue, 3 Jun 2025 21:44:13 +1000 Subject: [PATCH] feat: add customizable task field mapping, statuses, and priorities (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Introduce new service classes:   – FieldMapper: Provides bidirectional mapping between internal task fields and user-configured YAML property names. This ensures backward compatibility while allowing custom field names.   – StatusManager: Enables custom task statuses (with colors, labels, and cycling order) by replacing hardcoded “open”, “in-progress”, “done”. It includes helper functions for status cycling and for generating CSS rules.   – PriorityManager: Allows custom priority levels defined by users (with weight, label, and color), and provides functions for cycling priorities, comparing priorities for sorting, and generating dynamic CSS. • Update main plugin (main.ts):   – Instantiate and initialize the FieldMapper, StatusManager, and PriorityManager using settings.   – Inject dynamic CSS for custom statuses and priorities.   – Update task updates and file indexer interactions to use the FieldMapper for mapping frontmatter properties.   – Refresh UI and cached task info when settings change. • Revise TaskCreationModal:   – Change priority and status selection to use custom configurations from PriorityManager and StatusManager rather than fixed options.   – Use FieldMapper to map task info into YAML frontmatter when creating a new task.   – Update task YAML generation logic to include additional fields (e.g., due date, contexts, recurrence, time estimates) correctly. • Enhance settings UI:   – Extend settings (in settings.ts) to include new customization options such as fieldMapping, customStatuses, and customPriorities.   – Redesign the settings pane with tabbed navigation (General, Field mapping, Statuses, Priorities, Daily notes, Pomodoro) to allow easier configuration of defaults, field mappings, and custom status/priority settings.   – Add warnings for changing field mappings to prevent accidental misconfiguration. • Update types and utilities:   – Add FieldMapping, StatusConfig, and PriorityConfig interfaces to types.ts.   – Update helper functions (updateTaskProperty and extractTaskInfo in helpers.ts) to use FieldMapper if provided, with fallback to legacy behavior.   – Adapt FileIndexer to accept and update the FieldMapper.   – Adjust filenameGenerator and TaskListView to work with string-based priority/status values and incorporate custom sorting and cycling logic. • Modify TaskListView:   – Replace hardcoded priority and status handling with calls to PriorityManager and StatusManager.   – Dynamically generate CSS class names based on custom priorities/statuses.   – Update UI event handlers (including cycling of priorities/statuses) to reflect changes via the new managers.   – Improve sorting and filtering using custom comparison functions. • Update styles:   – Add CSS rules to provide a dynamic top border on task items using the custom priority color on hover. This refactor provides a more flexible and customizable task management system, allowing users to tailor field names, statuses, and priorities to their workflows while maintaining backward compatibility. --- src/main.ts | 144 ++++--- src/modals/TaskCreationModal.ts | 103 +++-- src/services/FieldMapper.ts | 182 +++++++++ src/services/PriorityManager.ts | 212 ++++++++++ src/services/StatusManager.ts | 179 ++++++++ src/settings/settings.ts | 694 +++++++++++++++++++++++++++----- src/types.ts | 53 +++ src/utils/FileIndexer.ts | 16 +- src/utils/filenameGenerator.ts | 4 +- src/utils/helpers.ts | 205 ++++++---- src/views/TaskListView.ts | 209 +++++++--- styles.css | 5 + 12 files changed, 1674 insertions(+), 332 deletions(-) create mode 100644 src/services/FieldMapper.ts create mode 100644 src/services/PriorityManager.ts create mode 100644 src/services/StatusManager.ts diff --git a/src/main.ts b/src/main.ts index c3a0337b..92952c50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,11 +31,15 @@ import { ensureFolderExists, generateDailyNoteTemplate, updateYamlFrontmatter, - extractTaskInfo + extractTaskInfo, + updateTaskProperty } from './utils/helpers'; import { EventEmitter } from './utils/EventEmitter'; import { FileIndexer } from './utils/FileIndexer'; import { YAMLCache } from './utils/YAMLCache'; +import { FieldMapper } from './services/FieldMapper'; +import { StatusManager } from './services/StatusManager'; +import { PriorityManager } from './services/PriorityManager'; export default class TaskNotesPlugin extends Plugin { settings: TaskNotesSettings; @@ -52,21 +56,35 @@ export default class TaskNotesPlugin extends Plugin { // Pomodoro service pomodoroService: PomodoroService; + // Customization services + fieldMapper: FieldMapper; + statusManager: StatusManager; + priorityManager: PriorityManager; + async onload() { await this.loadSettings(); + // Initialize customization services + this.fieldMapper = new FieldMapper(this.settings.fieldMapping); + this.statusManager = new StatusManager(this.settings.customStatuses); + this.priorityManager = new PriorityManager(this.settings.customPriorities); + // Initialize the file indexer this.fileIndexer = new FileIndexer( this.app.vault, this.settings.taskTag, this.settings.excludedFolders, this.settings.dailyNotesFolder, - this.settings.dailyNoteTemplate + this.settings.dailyNoteTemplate, + this.fieldMapper ); // Initialize Pomodoro service this.pomodoroService = new PomodoroService(this); await this.pomodoroService.initialize(); + + // Inject dynamic styles for custom statuses and priorities + this.injectCustomStyles(); // Register view types this.registerView( @@ -180,10 +198,22 @@ export default class TaskNotesPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); + // Update customization services with new settings + if (this.fieldMapper) { + this.fieldMapper.updateMapping(this.settings.fieldMapping); + } + if (this.statusManager) { + this.statusManager.updateStatuses(this.settings.customStatuses); + } + if (this.priorityManager) { + this.priorityManager.updatePriorities(this.settings.customPriorities); + } + // Update the file indexer with new settings if relevant if (this.fileIndexer) { - // Update template path without recreating the entire indexer + // Update template path and field mapper without recreating the entire indexer this.fileIndexer.updateDailyNoteTemplatePath(this.settings.dailyNoteTemplate); + this.fileIndexer.updateFieldMapper(this.fieldMapper); // Only recreate indexer if core settings changed const coreSettingsChanged = this.fileIndexer.taskTag !== this.settings.taskTag || @@ -199,11 +229,15 @@ export default class TaskNotesPlugin extends Plugin { this.settings.taskTag, this.settings.excludedFolders, this.settings.dailyNotesFolder, - this.settings.dailyNoteTemplate + this.settings.dailyNoteTemplate, + this.fieldMapper ); } } + // Update custom styles + this.injectCustomStyles(); + // If settings have changed, notify views to refresh their data this.notifyDataChanged(); } @@ -677,6 +711,32 @@ async generateDailyNoteTemplate(date: Date): Promise { return generateDailyNoteTemplate(date); } +/** + * Inject dynamic CSS for custom statuses and priorities + */ +private injectCustomStyles(): void { + // Remove existing custom styles + const existingStyle = document.getElementById('tasknotes-custom-styles'); + if (existingStyle) { + existingStyle.remove(); + } + + // Generate new styles + const statusStyles = this.statusManager.getStatusStyles(); + const priorityStyles = this.priorityManager.getPriorityStyles(); + + // Create style element + const styleEl = document.createElement('style'); + styleEl.id = 'tasknotes-custom-styles'; + styleEl.textContent = ` + ${statusStyles} + ${priorityStyles} + `; + + // Inject into document head + document.head.appendChild(styleEl); +} + async updateTaskProperty(task: TaskInfo, property: string, value: any, options: { silent?: boolean } = {}): Promise { try { const file = this.app.vault.getAbstractFileByPath(task.path); @@ -691,29 +751,30 @@ async generateDailyNoteTemplate(date: Date): Promise { // Special handling for status changes - update completedDate in local copy if (property === 'status' && !task.recurrence) { - if (value === 'done') { + if (this.statusManager.isCompletedStatus(value)) { updatedTask.completedDate = format(new Date(), 'yyyy-MM-dd'); - } else if (value === 'open' || value === 'in-progress') { + } else { updatedTask.completedDate = undefined; } } - // Process the frontmatter - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - // Update the property - frontmatter[property] = value; - - // Special handling for status changes - update completedDate - if (property === 'status' && !task.recurrence) { - if (value === 'done') { - // Set completedDate to today when marking as done - frontmatter.completedDate = format(new Date(), 'yyyy-MM-dd'); - } else if (value === 'open' || value === 'in-progress') { - // Clear completedDate when marking as open or in-progress - delete frontmatter.completedDate; - } + // Read current content and use field mapping to update + const content = await this.app.vault.read(file); + const propertyUpdates: Partial = {}; + (propertyUpdates as any)[property] = value; + + // Special handling for status changes - update completedDate + if (property === 'status' && !task.recurrence) { + if (this.statusManager.isCompletedStatus(value)) { + propertyUpdates.completedDate = format(new Date(), 'yyyy-MM-dd'); + } else { + propertyUpdates.completedDate = undefined; } - }); + } + + // Use field mapping to update the content + const updatedContent = updateTaskProperty(content, propertyUpdates, this.fieldMapper); + await this.app.vault.modify(file, updatedContent); // Show a notice (unless silent) if (!options.silent) { @@ -756,7 +817,7 @@ async generateDailyNoteTemplate(date: Date): Promise { try { if (!task.recurrence) { // Not a recurring task - do regular status toggle - const newStatus = task.status === 'done' ? 'open' : 'done'; + const newStatus = this.statusManager.getNextStatus(task.status); await this.updateTaskProperty(task, 'status', newStatus); return; } @@ -844,40 +905,23 @@ async generateDailyNoteTemplate(date: Date): Promise { const updatedTask = { ...task }; updatedTask.archived = !task.archived; - // Process the frontmatter - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - // Make sure tags array exists - if (!frontmatter.tags) { - frontmatter.tags = []; - } - - // Convert to array if it's not already - if (!Array.isArray(frontmatter.tags)) { - frontmatter.tags = [frontmatter.tags]; - } - - // Toggle archive tag - if (task.archived) { - // Remove archive tag - frontmatter.tags = frontmatter.tags.filter( - (tag: string) => tag !== 'archive' - ); - } else { - // Add archive tag if not present - if (!frontmatter.tags.includes('archive')) { - frontmatter.tags.push('archive'); - } - } - }); + // Read current content and use field mapping to update + const content = await this.app.vault.read(file); + const propertyUpdates: Partial = { + archived: !task.archived + }; + + // Use field mapping to update the content + const updatedContent = updateTaskProperty(content, propertyUpdates, this.fieldMapper); + await this.app.vault.modify(file, updatedContent); // Show a notice new Notice(task.archived ? 'Task unarchived' : 'Task archived'); // Add the updated task to the file indexer's cache if (this.fileIndexer) { - // Read the file to get the most up-to-date content - const content = await this.app.vault.cachedRead(file); - const taskInfo = extractTaskInfo(content, task.path); + // Use the updated task info with field mapping + const taskInfo = extractTaskInfo(updatedContent, task.path, this.fieldMapper); // Find and update this file in the index await this.fileIndexer.updateTaskInfoInCache(task.path, taskInfo as TaskInfo); diff --git a/src/modals/TaskCreationModal.ts b/src/modals/TaskCreationModal.ts index c91fe178..5d029a7b 100644 --- a/src/modals/TaskCreationModal.ts +++ b/src/modals/TaskCreationModal.ts @@ -11,8 +11,8 @@ export class TaskCreationModal extends Modal { title: string = ''; details: string = ''; dueDate: string = ''; - priority: 'low' | 'normal' | 'high' = 'normal'; - status: 'open' | 'in-progress' | 'done' = 'open'; + priority: string = 'normal'; + status: string = 'open'; contexts: string = ''; tags: string = ''; timeEstimate: number = 0; @@ -188,22 +188,22 @@ export class TaskCreationModal extends Modal { this.createFormGroup(contentEl, 'Priority', (container) => { const select = container.createEl('select'); - const options = [ - { value: 'low', text: 'Low' }, - { value: 'normal', text: 'Normal' }, - { value: 'high', text: 'High' } - ]; + // Get custom priorities ordered by weight (highest first) + const priorities = this.plugin.priorityManager.getPrioritiesByWeight(); - options.forEach(option => { - const optEl = select.createEl('option', { value: option.value, text: option.text }); - if (option.value === this.plugin.settings.defaultTaskPriority) { + priorities.forEach(priorityConfig => { + const optEl = select.createEl('option', { + value: priorityConfig.value, + text: priorityConfig.label + }); + if (priorityConfig.value === this.plugin.settings.defaultTaskPriority) { optEl.selected = true; - this.priority = option.value as 'low' | 'normal' | 'high'; + this.priority = priorityConfig.value; } }); select.addEventListener('change', (e) => { - this.priority = (e.target as HTMLSelectElement).value as 'low' | 'normal' | 'high'; + this.priority = (e.target as HTMLSelectElement).value; this.updateFilenamePreview(); }); }); @@ -212,22 +212,22 @@ export class TaskCreationModal extends Modal { this.createFormGroup(contentEl, 'Status', (container) => { const select = container.createEl('select'); - const statusOptions = [ - { value: 'open', text: 'Open' }, - { value: 'in-progress', text: 'In progress' }, - { value: 'done', text: 'Done' } - ]; + // Get custom statuses ordered by their order field + const statuses = this.plugin.statusManager.getStatusesByOrder(); - statusOptions.forEach(option => { - const optEl = select.createEl('option', { value: option.value, text: option.text }); - if (option.value === this.plugin.settings.defaultTaskStatus) { + statuses.forEach(statusConfig => { + const optEl = select.createEl('option', { + value: statusConfig.value, + text: statusConfig.label + }); + if (statusConfig.value === this.plugin.settings.defaultTaskStatus) { optEl.selected = true; - this.status = option.value as 'open' | 'in-progress' | 'done'; + this.status = statusConfig.value; } }); select.addEventListener('change', (e) => { - this.status = (e.target as HTMLSelectElement).value as 'open' | 'in-progress' | 'done'; + this.status = (e.target as HTMLSelectElement).value; this.updateFilenamePreview(); }); }); @@ -707,64 +707,81 @@ export class TaskCreationModal extends Modal { .filter(context => context.length > 0); } - // Create the YAML frontmatter - const yaml: Partial = { + // Create task info object + const taskInfo: Partial = { title: this.title, - dateCreated: format(now, "yyyy-MM-dd'T'HH:mm:ss"), - dateModified: format(now, "yyyy-MM-dd'T'HH:mm:ss"), status: this.status, - tags: tagsArray, priority: this.priority, + tags: tagsArray, }; - // Add completedDate if status is done - if (this.status === 'done') { - yaml.completedDate = format(now, 'yyyy-MM-dd'); + // Add completedDate if status is completed + if (this.plugin.statusManager.isCompletedStatus(this.status)) { + taskInfo.completedDate = format(now, 'yyyy-MM-dd'); } - // Add optional fields + // Use field mapper to create the YAML frontmatter + const yaml = this.plugin.fieldMapper.mapToFrontmatter(taskInfo); + + // Add standard fields that aren't mapped + yaml.dateCreated = format(now, "yyyy-MM-dd'T'HH:mm:ss"); + yaml.dateModified = format(now, "yyyy-MM-dd'T'HH:mm:ss"); + + // Add optional fields through field mapping if (this.dueDate) { - yaml.due = this.dueDate; + taskInfo.due = this.dueDate; } if (contextsArray.length > 0) { - yaml.contexts = contextsArray; + taskInfo.contexts = contextsArray; } if (this.timeEstimate > 0) { - yaml.timeEstimate = this.timeEstimate; - yaml.timeSpent = 0; - yaml.timeEntries = []; + taskInfo.timeEstimate = this.timeEstimate; + taskInfo.timeSpent = 0; + taskInfo.timeEntries = []; } + // Re-map with additional fields + const finalYaml = this.plugin.fieldMapper.mapToFrontmatter(taskInfo); + + // Preserve standard fields + finalYaml.dateCreated = yaml.dateCreated; + finalYaml.dateModified = yaml.dateModified; + // Add recurrence info if specified if (this.recurrence !== 'none') { - yaml.recurrence = { + taskInfo.recurrence = { frequency: this.recurrence }; if (this.recurrence === 'weekly' && this.daysOfWeek.length > 0) { - yaml.recurrence.days_of_week = this.daysOfWeek; + taskInfo.recurrence.days_of_week = this.daysOfWeek; } if (this.recurrence === 'monthly' && this.dayOfMonth) { - yaml.recurrence.day_of_month = parseInt(this.dayOfMonth); + taskInfo.recurrence.day_of_month = parseInt(this.dayOfMonth); } if (this.recurrence === 'yearly') { if (this.dayOfMonth) { - yaml.recurrence.day_of_month = parseInt(this.dayOfMonth); + taskInfo.recurrence.day_of_month = parseInt(this.dayOfMonth); } if (this.monthOfYear) { - yaml.recurrence.month_of_year = parseInt(this.monthOfYear); + taskInfo.recurrence.month_of_year = parseInt(this.monthOfYear); } } - yaml.complete_instances = []; + taskInfo.complete_instances = []; } + // Create final YAML with all fields mapped + const completeYaml = this.plugin.fieldMapper.mapToFrontmatter(taskInfo); + completeYaml.dateCreated = format(now, "yyyy-MM-dd'T'HH:mm:ss"); + completeYaml.dateModified = format(now, "yyyy-MM-dd'T'HH:mm:ss"); + // Prepare the file content - const content = `---\n${YAML.stringify(yaml)}---\n\n# ${this.title}\n\n${this.details}`; + const content = `---\n${YAML.stringify(completeYaml)}---\n\n# ${this.title}\n\n${this.details}`; // Create the file await this.app.vault.create(taskFilePath, content); diff --git a/src/services/FieldMapper.ts b/src/services/FieldMapper.ts new file mode 100644 index 00000000..5ff126d5 --- /dev/null +++ b/src/services/FieldMapper.ts @@ -0,0 +1,182 @@ +import { FieldMapping, TaskInfo } from '../types'; + +/** + * Service for mapping between internal field names and user-configured property names + */ +export class FieldMapper { + constructor(private mapping: FieldMapping) {} + + /** + * Convert internal field name to user's property name + */ + toUserField(internalName: keyof FieldMapping): string { + return this.mapping[internalName]; + } + + /** + * Convert frontmatter object using mapping to internal task data + */ + mapFromFrontmatter(frontmatter: any, filePath: string): Partial { + if (!frontmatter) return {}; + + const mapped: Partial = { + path: filePath + }; + + // Map each field if it exists in frontmatter + if (frontmatter[this.mapping.title] !== undefined) { + mapped.title = frontmatter[this.mapping.title]; + } + + if (frontmatter[this.mapping.status] !== undefined) { + mapped.status = frontmatter[this.mapping.status]; + } + + if (frontmatter[this.mapping.priority] !== undefined) { + mapped.priority = frontmatter[this.mapping.priority]; + } + + if (frontmatter[this.mapping.due] !== undefined) { + mapped.due = frontmatter[this.mapping.due]; + } + + if (frontmatter[this.mapping.contexts] !== undefined) { + const contexts = frontmatter[this.mapping.contexts]; + // Ensure contexts is always an array + mapped.contexts = Array.isArray(contexts) ? contexts : [contexts]; + } + + if (frontmatter[this.mapping.timeEstimate] !== undefined) { + mapped.timeEstimate = frontmatter[this.mapping.timeEstimate]; + } + + if (frontmatter[this.mapping.timeSpent] !== undefined) { + mapped.timeSpent = frontmatter[this.mapping.timeSpent]; + } + + if (frontmatter[this.mapping.completedDate] !== undefined) { + mapped.completedDate = frontmatter[this.mapping.completedDate]; + } + + if (frontmatter[this.mapping.recurrence] !== undefined) { + mapped.recurrence = frontmatter[this.mapping.recurrence]; + } + + // Handle tags array (includes archive tag) + if (frontmatter.tags && Array.isArray(frontmatter.tags)) { + mapped.tags = frontmatter.tags; + mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag); + } + + // Handle time entries + if (frontmatter.timeEntries !== undefined) { + mapped.timeEntries = frontmatter.timeEntries; + } + + return mapped; + } + + /** + * Convert internal task data to frontmatter using mapping + */ + mapToFrontmatter(taskData: Partial): any { + const frontmatter: any = {}; + + // Map each field if it exists in task data + if (taskData.title !== undefined) { + frontmatter[this.mapping.title] = taskData.title; + } + + if (taskData.status !== undefined) { + frontmatter[this.mapping.status] = taskData.status; + } + + if (taskData.priority !== undefined) { + frontmatter[this.mapping.priority] = taskData.priority; + } + + if (taskData.due !== undefined) { + frontmatter[this.mapping.due] = taskData.due; + } + + if (taskData.contexts !== undefined) { + frontmatter[this.mapping.contexts] = taskData.contexts; + } + + if (taskData.timeEstimate !== undefined) { + frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate; + } + + if (taskData.timeSpent !== undefined) { + frontmatter[this.mapping.timeSpent] = taskData.timeSpent; + } + + if (taskData.completedDate !== undefined) { + frontmatter[this.mapping.completedDate] = taskData.completedDate; + } + + if (taskData.recurrence !== undefined) { + frontmatter[this.mapping.recurrence] = taskData.recurrence; + } + + // Handle tags (merge archive status into tags array) + let tags = taskData.tags ? [...taskData.tags] : []; + + if (taskData.archived === true && !tags.includes(this.mapping.archiveTag)) { + tags.push(this.mapping.archiveTag); + } else if (taskData.archived === false) { + tags = tags.filter(tag => tag !== this.mapping.archiveTag); + } + + if (tags.length > 0) { + frontmatter.tags = tags; + } + + // Handle time entries + if (taskData.timeEntries !== undefined) { + frontmatter.timeEntries = taskData.timeEntries; + } + + return frontmatter; + } + + /** + * Update mapping configuration + */ + updateMapping(newMapping: FieldMapping): void { + this.mapping = newMapping; + } + + /** + * Get current mapping + */ + getMapping(): FieldMapping { + return { ...this.mapping }; + } + + /** + * Validate that a mapping has no empty field names + */ + static validateMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + const fields = Object.keys(mapping) as (keyof FieldMapping)[]; + for (const field of fields) { + if (!mapping[field] || mapping[field].trim() === '') { + errors.push(`Field "${field}" cannot be empty`); + } + } + + // Check for duplicate values + const values = Object.values(mapping); + const uniqueValues = new Set(values); + if (values.length !== uniqueValues.size) { + errors.push('Field mappings must have unique property names'); + } + + return { + valid: errors.length === 0, + errors + }; + } +} \ No newline at end of file diff --git a/src/services/PriorityManager.ts b/src/services/PriorityManager.ts new file mode 100644 index 00000000..284007d2 --- /dev/null +++ b/src/services/PriorityManager.ts @@ -0,0 +1,212 @@ +import { PriorityConfig } from '../types'; + +/** + * Service for managing custom task priorities + */ +export class PriorityManager { + constructor(private priorities: PriorityConfig[]) {} + + /** + * Get priority configuration by value + */ + getPriorityConfig(value: string): PriorityConfig | undefined { + return this.priorities.find(p => p.value === value); + } + + /** + * Get priorities ordered by weight (highest weight first) + */ + getPrioritiesByWeight(): PriorityConfig[] { + return [...this.priorities].sort((a, b) => b.weight - a.weight); + } + + /** + * Get priorities ordered by weight (lowest weight first) + */ + getPrioritiesByWeightAsc(): PriorityConfig[] { + return [...this.priorities].sort((a, b) => a.weight - b.weight); + } + + /** + * Get next priority in cycle (cycling by weight order) + */ + getNextPriority(currentPriority: string): string { + const sortedPriorities = this.getPrioritiesByWeightAsc(); + const currentIndex = sortedPriorities.findIndex(p => p.value === currentPriority); + + if (currentIndex === -1) { + // Current priority not found, return first priority + return sortedPriorities[0]?.value || 'normal'; + } + + // Get next priority, cycling to first if at end + const nextIndex = (currentIndex + 1) % sortedPriorities.length; + return sortedPriorities[nextIndex].value; + } + + /** + * Compare priorities for sorting (higher weight = higher priority) + * Returns negative if a has lower priority, positive if higher, 0 if equal + */ + comparePriorities(a: string, b: string): number { + const priorityA = this.getPriorityConfig(a); + const priorityB = this.getPriorityConfig(b); + + // Default weight if priority not found + const weightA = priorityA?.weight || 0; + const weightB = priorityB?.weight || 0; + + return weightB - weightA; // Higher weight comes first + } + + /** + * Get CSS variables for priority colors + */ + getPriorityStyles(): string { + const cssRules: string[] = []; + + for (const priority of this.priorities) { + const cssClass = `--priority-${priority.value.replace(/[^a-zA-Z0-9-]/g, '-')}-color`; + cssRules.push(`${cssClass}: ${priority.color};`); + } + + return `:root { ${cssRules.join(' ')} }`; + } + + /** + * Get all priority configurations + */ + getAllPriorities(): PriorityConfig[] { + return [...this.priorities]; + } + + /** + * Update priority configurations + */ + updatePriorities(newPriorities: PriorityConfig[]): void { + this.priorities = newPriorities; + } + + /** + * Get the highest priority value (highest weight) + */ + getHighestPriority(): string | undefined { + const sorted = this.getPrioritiesByWeight(); + return sorted[0]?.value; + } + + /** + * Get the lowest priority value (lowest weight) + */ + getLowestPriority(): string | undefined { + const sorted = this.getPrioritiesByWeightAsc(); + return sorted[0]?.value; + } + + /** + * Check if priority A is higher than priority B + */ + isHigherPriority(a: string, b: string): boolean { + return this.comparePriorities(a, b) > 0; + } + + /** + * Validate priority configuration + */ + static validatePriorities(priorities: PriorityConfig[]): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // At least 1 priority required + if (priorities.length < 1) { + errors.push('At least 1 priority is required'); + } + + // Check for unique priority values + const values = priorities.map(p => p.value); + const uniqueValues = new Set(values); + if (values.length !== uniqueValues.size) { + errors.push('Priority values must be unique'); + } + + // Check for unique IDs + const ids = priorities.map(p => p.id); + const uniqueIds = new Set(ids); + if (ids.length !== uniqueIds.size) { + errors.push('Priority IDs must be unique'); + } + + // Check for unique weights + const weights = priorities.map(p => p.weight); + const uniqueWeights = new Set(weights); + if (weights.length !== uniqueWeights.size) { + errors.push('Priority weights must be unique'); + } + + // Check for empty values and labels + for (const priority of priorities) { + if (!priority.value || priority.value.trim() === '') { + errors.push('Priority values cannot be empty'); + break; + } + if (!priority.label || priority.label.trim() === '') { + errors.push('Priority labels cannot be empty'); + break; + } + if (!priority.color || !priority.color.match(/^#[0-9a-fA-F]{6}$/)) { + errors.push('Priority colors must be valid hex colors (#rrggbb)'); + break; + } + if (typeof priority.weight !== 'number' || priority.weight < 0) { + errors.push('Priority weights must be non-negative numbers'); + break; + } + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Generate a new unique priority ID + */ + static generatePriorityId(existingPriorities: PriorityConfig[]): string { + const existingIds = new Set(existingPriorities.map(p => p.id)); + let counter = 1; + let id = `priority-${counter}`; + + while (existingIds.has(id)) { + counter++; + id = `priority-${counter}`; + } + + return id; + } + + /** + * Generate a new unique weight value + */ + static generatePriorityWeight(existingPriorities: PriorityConfig[]): number { + const existingWeights = existingPriorities.map(p => p.weight); + if (existingWeights.length === 0) return 1; + + return Math.max(...existingWeights) + 1; + } + + /** + * Create a new priority with default values + */ + static createDefaultPriority(existingPriorities: PriorityConfig[]): PriorityConfig { + const id = PriorityManager.generatePriorityId(existingPriorities); + const weight = PriorityManager.generatePriorityWeight(existingPriorities); + + return { + id, + value: 'new-priority', + label: 'New Priority', + color: '#808080', + weight + }; + } +} \ No newline at end of file diff --git a/src/services/StatusManager.ts b/src/services/StatusManager.ts new file mode 100644 index 00000000..7173df06 --- /dev/null +++ b/src/services/StatusManager.ts @@ -0,0 +1,179 @@ +import { StatusConfig } from '../types'; + +/** + * Service for managing custom task statuses + */ +export class StatusManager { + constructor(private statuses: StatusConfig[]) {} + + /** + * Get next status in cycle from current status + */ + getNextStatus(currentStatus: string): string { + const sortedStatuses = this.getStatusesByOrder(); + const currentIndex = sortedStatuses.findIndex(s => s.value === currentStatus); + + if (currentIndex === -1) { + // Current status not found, return first status + return sortedStatuses[0]?.value || 'open'; + } + + // Get next status, cycling to first if at end + const nextIndex = (currentIndex + 1) % sortedStatuses.length; + return sortedStatuses[nextIndex].value; + } + + /** + * Get status configuration by value + */ + getStatusConfig(value: string): StatusConfig | undefined { + return this.statuses.find(s => s.value === value); + } + + /** + * Get all completed status values + */ + getCompletedStatuses(): string[] { + return this.statuses + .filter(s => s.isCompleted) + .map(s => s.value); + } + + /** + * Get all non-completed status values + */ + getOpenStatuses(): string[] { + return this.statuses + .filter(s => !s.isCompleted) + .map(s => s.value); + } + + /** + * Get statuses ordered by their order field + */ + getStatusesByOrder(): StatusConfig[] { + return [...this.statuses].sort((a, b) => a.order - b.order); + } + + /** + * Check if a status value represents a completed task + */ + isCompletedStatus(statusValue: string): boolean { + const status = this.getStatusConfig(statusValue); + return status?.isCompleted || false; + } + + /** + * Get CSS variables for status colors + */ + getStatusStyles(): string { + const cssRules: string[] = []; + + for (const status of this.statuses) { + const cssClass = `--status-${status.value.replace(/[^a-zA-Z0-9-]/g, '-')}-color`; + cssRules.push(`${cssClass}: ${status.color};`); + } + + return `:root { ${cssRules.join(' ')} }`; + } + + /** + * Get all status configurations + */ + getAllStatuses(): StatusConfig[] { + return [...this.statuses]; + } + + /** + * Update status configurations + */ + updateStatuses(newStatuses: StatusConfig[]): void { + this.statuses = newStatuses; + } + + /** + * Validate status configuration + */ + static validateStatuses(statuses: StatusConfig[]): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Minimum 2 statuses required + if (statuses.length < 2) { + errors.push('At least 2 statuses are required'); + } + + // At least one completed status required + const hasCompletedStatus = statuses.some(s => s.isCompleted); + if (!hasCompletedStatus) { + errors.push('At least one status must be marked as completed'); + } + + // Check for unique status values + const values = statuses.map(s => s.value); + const uniqueValues = new Set(values); + if (values.length !== uniqueValues.size) { + errors.push('Status values must be unique'); + } + + // Check for unique IDs + const ids = statuses.map(s => s.id); + const uniqueIds = new Set(ids); + if (ids.length !== uniqueIds.size) { + errors.push('Status IDs must be unique'); + } + + // Check for empty values and labels + for (const status of statuses) { + if (!status.value || status.value.trim() === '') { + errors.push('Status values cannot be empty'); + break; + } + if (!status.label || status.label.trim() === '') { + errors.push('Status labels cannot be empty'); + break; + } + if (!status.color || !status.color.match(/^#[0-9a-fA-F]{6}$/)) { + errors.push('Status colors must be valid hex colors (#rrggbb)'); + break; + } + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Generate a new unique status ID + */ + static generateStatusId(existingStatuses: StatusConfig[]): string { + const existingIds = new Set(existingStatuses.map(s => s.id)); + let counter = 1; + let id = `status-${counter}`; + + while (existingIds.has(id)) { + counter++; + id = `status-${counter}`; + } + + return id; + } + + /** + * Create a new status with default values + */ + static createDefaultStatus(existingStatuses: StatusConfig[]): StatusConfig { + const id = StatusManager.generateStatusId(existingStatuses); + const order = Math.max(...existingStatuses.map(s => s.order), 0) + 1; + + return { + id, + value: 'new-status', + label: 'New Status', + color: '#808080', + isCompleted: false, + order + }; + } +} \ No newline at end of file diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 191e6d41..8b65369d 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,13 +1,16 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; import TaskNotesPlugin from '../main'; +import { FieldMapping, StatusConfig, PriorityConfig } from '../types'; +import { StatusManager } from '../services/StatusManager'; +import { PriorityManager } from '../services/PriorityManager'; export interface TaskNotesSettings { dailyNotesFolder: string; tasksFolder: string; // Now just a default location for new tasks taskTag: string; // The tag that identifies tasks excludedFolders: string; // Comma-separated list of folders to exclude from Notes tab - defaultTaskPriority: 'low' | 'normal' | 'high'; - defaultTaskStatus: 'open' | 'in-progress' | 'done'; + defaultTaskPriority: string; // Changed to string to support custom priorities + defaultTaskStatus: string; // Changed to string to support custom statuses taskOrgFiltersCollapsed: boolean; // Save collapse state of task organization filters // Daily note settings dailyNoteTemplate: string; // Path to template file for daily notes @@ -24,8 +27,81 @@ export interface TaskNotesSettings { pomodoroNotifications: boolean; pomodoroSoundEnabled: boolean; pomodoroSoundVolume: number; // 0-100 + // Customization settings + fieldMapping: FieldMapping; + customStatuses: StatusConfig[]; + customPriorities: PriorityConfig[]; } +// Default field mapping maintains backward compatibility +export const DEFAULT_FIELD_MAPPING: FieldMapping = { + title: 'title', + status: 'status', + priority: 'priority', + due: 'due', + contexts: 'contexts', + timeEstimate: 'timeEstimate', + timeSpent: 'timeSpent', + completedDate: 'completedDate', + dateCreated: 'dateCreated', + dateModified: 'dateModified', + recurrence: 'recurrence', + archiveTag: 'archived' +}; + +// Default status configuration matches current hardcoded behavior +export const DEFAULT_STATUSES: StatusConfig[] = [ + { + id: 'open', + value: 'open', + label: 'Open', + color: '#808080', + isCompleted: false, + order: 1 + }, + { + id: 'in-progress', + value: 'in-progress', + label: 'In progress', + color: '#0066cc', + isCompleted: false, + order: 2 + }, + { + id: 'done', + value: 'done', + label: 'Done', + color: '#00aa00', + isCompleted: true, + order: 3 + } +]; + +// Default priority configuration matches current hardcoded behavior +export const DEFAULT_PRIORITIES: PriorityConfig[] = [ + { + id: 'low', + value: 'low', + label: 'Low', + color: '#00aa00', + weight: 1 + }, + { + id: 'normal', + value: 'normal', + label: 'Normal', + color: '#ffaa00', + weight: 2 + }, + { + id: 'high', + value: 'high', + label: 'High', + color: '#ff0000', + weight: 3 + } +]; + export const DEFAULT_SETTINGS: TaskNotesSettings = { dailyNotesFolder: 'TaskNotes/Daily', tasksFolder: 'TaskNotes/Tasks', @@ -48,11 +124,17 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { pomodoroAutoStartWork: false, pomodoroNotifications: true, pomodoroSoundEnabled: true, - pomodoroSoundVolume: 50 + pomodoroSoundVolume: 50, + // Customization defaults + fieldMapping: DEFAULT_FIELD_MAPPING, + customStatuses: DEFAULT_STATUSES, + customPriorities: DEFAULT_PRIORITIES }; export class TaskNotesSettingTab extends PluginSettingTab { plugin: TaskNotesPlugin; + private activeTab: string = 'general'; + private tabContents: Record = {}; constructor(app: App, plugin: TaskNotesPlugin) { super(app, plugin); @@ -63,19 +145,87 @@ export class TaskNotesSettingTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - // General Settings - new Setting(containerEl) - .setName('Daily notes folder') - .setDesc('Folder where daily notes will be stored') - .addText(text => text - .setPlaceholder('TaskNotes/Daily') - .setValue(this.plugin.settings.dailyNotesFolder) - .onChange(async (value) => { - this.plugin.settings.dailyNotesFolder = value; - await this.plugin.saveSettings(); - })); + // Create tab navigation + const tabNav = containerEl.createDiv('settings-tab-nav'); + tabNav.style.display = 'flex'; + tabNav.style.borderBottom = '1px solid var(--background-modifier-border)'; + tabNav.style.marginBottom = '20px'; - new Setting(containerEl) + const tabs = [ + { id: 'general', name: 'General' }, + { id: 'field-mapping', name: 'Field mapping' }, + { id: 'statuses', name: 'Statuses' }, + { id: 'priorities', name: 'Priorities' }, + { id: 'daily-notes', name: 'Daily notes' }, + { id: 'pomodoro', name: 'Pomodoro' } + ]; + + tabs.forEach(tab => { + const tabButton = tabNav.createEl('button', { + text: tab.name, + cls: this.activeTab === tab.id ? 'settings-tab-button active' : 'settings-tab-button' + }); + + tabButton.style.padding = '8px 16px'; + tabButton.style.border = 'none'; + tabButton.style.background = this.activeTab === tab.id ? 'var(--interactive-accent)' : 'transparent'; + tabButton.style.color = this.activeTab === tab.id ? 'var(--text-on-accent)' : 'var(--text-normal)'; + tabButton.style.cursor = 'pointer'; + tabButton.style.borderRadius = '4px 4px 0 0'; + + tabButton.addEventListener('click', () => { + this.switchTab(tab.id); + }); + }); + + // Create tab content containers + const tabContentsEl = containerEl.createDiv('settings-tab-contents'); + + // Create all tab content containers + tabs.forEach(tab => { + const tabContent = tabContentsEl.createDiv('settings-tab-content'); + tabContent.style.display = this.activeTab === tab.id ? 'block' : 'none'; + this.tabContents[tab.id] = tabContent; + }); + + this.renderActiveTab(); + } + + private switchTab(tabId: string): void { + this.activeTab = tabId; + this.display(); // Re-render the entire settings tab + } + + private renderActiveTab(): void { + // Clear current tab content + Object.values(this.tabContents).forEach(content => content.empty()); + + switch (this.activeTab) { + case 'general': + this.renderGeneralTab(); + break; + case 'field-mapping': + this.renderFieldMappingTab(); + break; + case 'statuses': + this.renderStatusesTab(); + break; + case 'priorities': + this.renderPrioritiesTab(); + break; + case 'daily-notes': + this.renderDailyNotesTab(); + break; + case 'pomodoro': + this.renderPomodoroTab(); + break; + } + } + + private renderGeneralTab(): void { + const container = this.tabContents['general']; + + new Setting(container) .setName('Default tasks folder') .setDesc('Default folder for new tasks (tasks are identified by tag, not folder)') .addText(text => text @@ -86,32 +236,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) - .setName('Excluded folders') - .setDesc('Comma-separated list of folder paths to exclude from Notes tab (e.g., "Templates,Archive,Attachments")') - .addText(text => text - .setPlaceholder('Templates,Archive') - .setValue(this.plugin.settings.excludedFolders) - .onChange(async (value) => { - this.plugin.settings.excludedFolders = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Daily note template') - .setDesc('Path to template file for daily notes (leave empty to use built-in template). Supports Obsidian template variables like {{title}}, {{date}}, {{date:format}}, {{time}}, etc.') - .addText(text => text - .setPlaceholder('Templates/Daily Note Template.md') - .setValue(this.plugin.settings.dailyNoteTemplate) - .onChange(async (value) => { - this.plugin.settings.dailyNoteTemplate = value; - await this.plugin.saveSettings(); - })); - - // Task Settings - new Setting(containerEl).setName('Task defaults').setHeading(); - - new Setting(containerEl) + new Setting(container) .setName('Task tag') .setDesc('Tag that identifies notes as tasks (without #)') .addText(text => text @@ -122,36 +247,56 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) - .setName('Default task priority') - .setDesc('Default priority for new tasks') - .addDropdown(dropdown => dropdown - .addOption('low', 'Low') - .addOption('normal', 'Normal') - .addOption('high', 'High') - .setValue(this.plugin.settings.defaultTaskPriority) - .onChange(async (value: any) => { - this.plugin.settings.defaultTaskPriority = value; + new Setting(container) + .setName('Excluded folders') + .setDesc('Comma-separated list of folder paths to exclude from Notes tab') + .addText(text => text + .setPlaceholder('Templates,Archive') + .setValue(this.plugin.settings.excludedFolders) + .onChange(async (value) => { + this.plugin.settings.excludedFolders = value; await this.plugin.saveSettings(); })); - new Setting(containerEl) + // Task defaults section + new Setting(container).setName('Task defaults').setHeading(); + + new Setting(container) .setName('Default task status') .setDesc('Default status for new tasks') - .addDropdown(dropdown => dropdown - .addOption('open', 'Open') - .addOption('in-progress', 'In progress') - .addOption('done', 'Done') - .setValue(this.plugin.settings.defaultTaskStatus) - .onChange(async (value: any) => { - this.plugin.settings.defaultTaskStatus = value; - await this.plugin.saveSettings(); - })); + .addDropdown(dropdown => { + // Populate with custom statuses + this.plugin.settings.customStatuses.forEach(status => { + dropdown.addOption(status.value, status.label); + }); + return dropdown + .setValue(this.plugin.settings.defaultTaskStatus) + .onChange(async (value: any) => { + this.plugin.settings.defaultTaskStatus = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(container) + .setName('Default task priority') + .setDesc('Default priority for new tasks') + .addDropdown(dropdown => { + // Populate with custom priorities + this.plugin.settings.customPriorities.forEach(priority => { + dropdown.addOption(priority.value, priority.label); + }); + return dropdown + .setValue(this.plugin.settings.defaultTaskPriority) + .onChange(async (value: any) => { + this.plugin.settings.defaultTaskPriority = value; + await this.plugin.saveSettings(); + }); + }); + + // Task filename settings + new Setting(container).setName('Task filenames').setHeading(); - // Task Filename Settings - new Setting(containerEl).setName('Task filenames').setHeading(); - - new Setting(containerEl) + new Setting(container) .setName('Filename format') .setDesc('How task filenames should be generated') .addDropdown(dropdown => dropdown @@ -163,28 +308,390 @@ export class TaskNotesSettingTab extends PluginSettingTab { .onChange(async (value: any) => { this.plugin.settings.taskFilenameFormat = value; await this.plugin.saveSettings(); - this.updateCustomTemplateVisibility(); + this.renderActiveTab(); // Re-render to update visibility })); - const customTemplateSetting = new Setting(containerEl) - .setName('Custom filename template') - .setDesc('Template for custom filenames. Available variables: {title}, {date}, {time}, {priority}, {status}, {timestamp}, {year}, {month}, {day}, {hour}, {minute}, {second}') + if (this.plugin.settings.taskFilenameFormat === 'custom') { + new Setting(container) + .setName('Custom filename template') + .setDesc('Template for custom filenames. Available variables: {title}, {date}, {time}, {priority}, {status}, {timestamp}, etc.') + .addText(text => text + .setPlaceholder('{date}-{title}') + .setValue(this.plugin.settings.customFilenameTemplate) + .onChange(async (value) => { + this.plugin.settings.customFilenameTemplate = value; + await this.plugin.saveSettings(); + })); + } + } + + private renderFieldMappingTab(): void { + const container = this.tabContents['field-mapping']; + + // Warning message + const warning = container.createDiv(); + warning.style.padding = '12px'; + warning.style.marginBottom = '20px'; + warning.style.backgroundColor = 'var(--background-modifier-warning)'; + warning.style.borderRadius = '4px'; + warning.innerHTML = ` + ⚠️ Warning: TaskNotes will read AND write using these property names. + Changing these after creating tasks may cause inconsistencies. + `; + + container.createEl('h3', { text: 'Field mapping' }); + container.createEl('p', { + text: 'Configure which frontmatter properties TaskNotes should use for each field.' + }); + + // Create mapping table + const table = container.createEl('table'); + table.style.width = '100%'; + table.style.borderCollapse = 'collapse'; + + const header = table.createEl('tr'); + header.createEl('th', { text: 'TaskNotes field' }); + header.createEl('th', { text: 'Your property name' }); + + const fieldMappings: Array<[keyof FieldMapping, string]> = [ + ['title', 'Title'], + ['status', 'Status'], + ['priority', 'Priority'], + ['due', 'Due date'], + ['contexts', 'Contexts'], + ['timeEstimate', 'Time estimate'], + ['timeSpent', 'Time spent'], + ['completedDate', 'Completed date'], + ['dateCreated', 'Created date'], + ['dateModified', 'Modified date'], + ['recurrence', 'Recurrence'], + ['archiveTag', 'Archive tag'] + ]; + + fieldMappings.forEach(([field, label]) => { + const row = table.createEl('tr'); + const labelCell = row.createEl('td'); + labelCell.style.padding = '8px'; + labelCell.style.borderBottom = '1px solid var(--background-modifier-border)'; + labelCell.textContent = label; + + const inputCell = row.createEl('td'); + inputCell.style.padding = '8px'; + inputCell.style.borderBottom = '1px solid var(--background-modifier-border)'; + + const input = inputCell.createEl('input', { + type: 'text', + value: this.plugin.settings.fieldMapping[field] + }); + input.style.width = '100%'; + input.style.padding = '4px'; + + input.addEventListener('change', async () => { + this.plugin.settings.fieldMapping[field] = input.value; + await this.plugin.saveSettings(); + }); + }); + + // Reset button + new Setting(container) + .setName('Reset to defaults') + .setDesc('Reset all field mappings to default values') + .addButton(button => button + .setButtonText('Reset') + .setWarning() + .onClick(async () => { + this.plugin.settings.fieldMapping = { ...DEFAULT_FIELD_MAPPING }; + await this.plugin.saveSettings(); + this.renderActiveTab(); + })); + } + + private renderStatusesTab(): void { + const container = this.tabContents['statuses']; + + container.createEl('h3', { text: 'Task Statuses' }); + container.createEl('p', { + text: 'Define the statuses available for your tasks. The order determines the cycling sequence.' + }); + + // Status list + const statusList = container.createDiv(); + statusList.style.marginBottom = '20px'; + + this.renderStatusList(statusList); + + // Add status button + new Setting(container) + .setName('Add new status') + .addButton(button => button + .setButtonText('Add Status') + .onClick(async () => { + const newStatus = StatusManager.createDefaultStatus(this.plugin.settings.customStatuses); + this.plugin.settings.customStatuses.push(newStatus); + await this.plugin.saveSettings(); + this.renderActiveTab(); + })); + } + + private renderStatusList(container: HTMLElement): void { + container.empty(); + + const sortedStatuses = [...this.plugin.settings.customStatuses].sort((a, b) => a.order - b.order); + + sortedStatuses.forEach((status, index) => { + const statusRow = container.createDiv(); + statusRow.style.display = 'flex'; + statusRow.style.alignItems = 'center'; + statusRow.style.marginBottom = '12px'; + statusRow.style.padding = '12px'; + statusRow.style.border = '1px solid var(--background-modifier-border)'; + statusRow.style.borderRadius = '4px'; + + // Color indicator + const colorIndicator = statusRow.createDiv(); + colorIndicator.style.width = '20px'; + colorIndicator.style.height = '20px'; + colorIndicator.style.borderRadius = '50%'; + colorIndicator.style.backgroundColor = status.color; + colorIndicator.style.marginRight = '12px'; + colorIndicator.style.flexShrink = '0'; + + // Status value input + const valueInput = statusRow.createEl('input', { + type: 'text', + value: status.value + }); + valueInput.style.marginRight = '12px'; + valueInput.style.width = '120px'; + + // Status label input + const labelInput = statusRow.createEl('input', { + type: 'text', + value: status.label + }); + labelInput.style.marginRight = '12px'; + labelInput.style.width = '120px'; + + // Color input + const colorInput = statusRow.createEl('input', { + type: 'color', + value: status.color + }); + colorInput.style.marginRight = '12px'; + colorInput.style.width = '40px'; + + // Completed checkbox + const completedLabel = statusRow.createEl('label'); + completedLabel.style.marginRight = '12px'; + completedLabel.style.display = 'flex'; + completedLabel.style.alignItems = 'center'; + + const completedCheckbox = completedLabel.createEl('input', { + type: 'checkbox' + }); + completedCheckbox.checked = status.isCompleted; + completedCheckbox.style.marginRight = '4px'; + + completedLabel.createSpan({ text: 'Completed' }); + + // Delete button + const deleteButton = statusRow.createEl('button', { + text: 'Delete' + }); + deleteButton.style.marginLeft = 'auto'; + deleteButton.style.backgroundColor = 'var(--interactive-accent-rgb)'; + deleteButton.style.color = 'var(--text-on-accent)'; + deleteButton.style.border = 'none'; + deleteButton.style.padding = '4px 8px'; + deleteButton.style.borderRadius = '4px'; + deleteButton.style.cursor = 'pointer'; + + // Event listeners + const updateStatus = async () => { + status.value = valueInput.value; + status.label = labelInput.value; + status.color = colorInput.value; + status.isCompleted = completedCheckbox.checked; + await this.plugin.saveSettings(); + colorIndicator.style.backgroundColor = status.color; + }; + + valueInput.addEventListener('change', updateStatus); + labelInput.addEventListener('change', updateStatus); + colorInput.addEventListener('change', updateStatus); + completedCheckbox.addEventListener('change', updateStatus); + + deleteButton.addEventListener('click', async () => { + if (this.plugin.settings.customStatuses.length <= 2) { + alert('You must have at least 2 statuses'); + return; + } + + const statusIndex = this.plugin.settings.customStatuses.findIndex(s => s.id === status.id); + if (statusIndex !== -1) { + this.plugin.settings.customStatuses.splice(statusIndex, 1); + await this.plugin.saveSettings(); + this.renderActiveTab(); + } + }); + }); + } + + private renderPrioritiesTab(): void { + const container = this.tabContents['priorities']; + + container.createEl('h3', { text: 'Task priorities' }); + container.createEl('p', { + text: 'Define the priority levels for your tasks. Higher weight = higher priority.' + }); + + // Priority list + const priorityList = container.createDiv(); + priorityList.style.marginBottom = '20px'; + + this.renderPriorityList(priorityList); + + // Add priority button + new Setting(container) + .setName('Add new priority') + .addButton(button => button + .setButtonText('Add Priority') + .onClick(async () => { + const newPriority = PriorityManager.createDefaultPriority(this.plugin.settings.customPriorities); + this.plugin.settings.customPriorities.push(newPriority); + await this.plugin.saveSettings(); + this.renderActiveTab(); + })); + } + + private renderPriorityList(container: HTMLElement): void { + container.empty(); + + const sortedPriorities = [...this.plugin.settings.customPriorities].sort((a, b) => b.weight - a.weight); + + sortedPriorities.forEach((priority, index) => { + const priorityRow = container.createDiv(); + priorityRow.style.display = 'flex'; + priorityRow.style.alignItems = 'center'; + priorityRow.style.marginBottom = '12px'; + priorityRow.style.padding = '12px'; + priorityRow.style.border = '1px solid var(--background-modifier-border)'; + priorityRow.style.borderRadius = '4px'; + + // Color indicator + const colorIndicator = priorityRow.createDiv(); + colorIndicator.style.width = '20px'; + colorIndicator.style.height = '20px'; + colorIndicator.style.borderRadius = '50%'; + colorIndicator.style.backgroundColor = priority.color; + colorIndicator.style.marginRight = '12px'; + colorIndicator.style.flexShrink = '0'; + + // Priority value input + const valueInput = priorityRow.createEl('input', { + type: 'text', + value: priority.value + }); + valueInput.style.marginRight = '12px'; + valueInput.style.width = '120px'; + + // Priority label input + const labelInput = priorityRow.createEl('input', { + type: 'text', + value: priority.label + }); + labelInput.style.marginRight = '12px'; + labelInput.style.width = '120px'; + + // Color input + const colorInput = priorityRow.createEl('input', { + type: 'color', + value: priority.color + }); + colorInput.style.marginRight = '12px'; + colorInput.style.width = '40px'; + + // Weight input + const weightInput = priorityRow.createEl('input', { + type: 'number', + value: priority.weight.toString() + }); + weightInput.style.marginRight = '12px'; + weightInput.style.width = '80px'; + + // Delete button + const deleteButton = priorityRow.createEl('button', { + text: 'Delete' + }); + deleteButton.style.marginLeft = 'auto'; + deleteButton.style.backgroundColor = 'var(--interactive-accent-rgb)'; + deleteButton.style.color = 'var(--text-on-accent)'; + deleteButton.style.border = 'none'; + deleteButton.style.padding = '4px 8px'; + deleteButton.style.borderRadius = '4px'; + deleteButton.style.cursor = 'pointer'; + + // Event listeners + const updatePriority = async () => { + priority.value = valueInput.value; + priority.label = labelInput.value; + priority.color = colorInput.value; + priority.weight = parseInt(weightInput.value) || 0; + await this.plugin.saveSettings(); + colorIndicator.style.backgroundColor = priority.color; + }; + + valueInput.addEventListener('change', updatePriority); + labelInput.addEventListener('change', updatePriority); + colorInput.addEventListener('change', updatePriority); + weightInput.addEventListener('change', updatePriority); + + deleteButton.addEventListener('click', async () => { + if (this.plugin.settings.customPriorities.length <= 1) { + alert('You must have at least 1 priority'); + return; + } + + const priorityIndex = this.plugin.settings.customPriorities.findIndex(p => p.id === priority.id); + if (priorityIndex !== -1) { + this.plugin.settings.customPriorities.splice(priorityIndex, 1); + await this.plugin.saveSettings(); + this.renderActiveTab(); + } + }); + }); + } + + private renderDailyNotesTab(): void { + const container = this.tabContents['daily-notes']; + + new Setting(container) + .setName('Daily notes folder') + .setDesc('Folder where daily notes will be stored') .addText(text => text - .setPlaceholder('{date}-{title}') - .setValue(this.plugin.settings.customFilenameTemplate) + .setPlaceholder('TaskNotes/Daily') + .setValue(this.plugin.settings.dailyNotesFolder) .onChange(async (value) => { - this.plugin.settings.customFilenameTemplate = value; + this.plugin.settings.dailyNotesFolder = value; await this.plugin.saveSettings(); })); - // Store reference for visibility toggle - (this as any).customTemplateSetting = customTemplateSetting; - this.updateCustomTemplateVisibility(); - - // Pomodoro Settings - new Setting(containerEl).setName('Pomodoro timer').setHeading(); - - new Setting(containerEl) + new Setting(container) + .setName('Daily note template') + .setDesc('Path to template file for daily notes (leave empty to use built-in template). Supports Obsidian template variables like {{title}}, {{date}}, {{date:format}}, {{time}}, etc.') + .addText(text => text + .setPlaceholder('Templates/Daily Note Template.md') + .setValue(this.plugin.settings.dailyNoteTemplate) + .onChange(async (value) => { + this.plugin.settings.dailyNoteTemplate = value; + await this.plugin.saveSettings(); + })); + } + + private renderPomodoroTab(): void { + const container = this.tabContents['pomodoro']; + + new Setting(container) .setName('Work duration') .setDesc('Duration of work intervals in minutes') .addText(text => text @@ -198,7 +705,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { } })); - new Setting(containerEl) + new Setting(container) .setName('Short break duration') .setDesc('Duration of short breaks in minutes') .addText(text => text @@ -212,7 +719,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { } })); - new Setting(containerEl) + new Setting(container) .setName('Long break duration') .setDesc('Duration of long breaks in minutes') .addText(text => text @@ -226,7 +733,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { } })); - new Setting(containerEl) + new Setting(container) .setName('Long break interval') .setDesc('Take a long break after this many pomodoros') .addText(text => text @@ -240,7 +747,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { } })); - new Setting(containerEl) + new Setting(container) .setName('Auto-start breaks') .setDesc('Automatically start break timer after work session') .addToggle(toggle => toggle @@ -250,7 +757,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) + new Setting(container) .setName('Auto-start work') .setDesc('Automatically start work timer after break') .addToggle(toggle => toggle @@ -260,7 +767,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) + new Setting(container) .setName('Enable notifications') .setDesc('Show notifications when pomodoro sessions complete') .addToggle(toggle => toggle @@ -270,7 +777,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) + new Setting(container) .setName('Enable sound') .setDesc('Play sound when pomodoro sessions complete') .addToggle(toggle => toggle @@ -280,7 +787,7 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) + new Setting(container) .setName('Sound volume') .setDesc('Volume for completion sounds (0-100)') .addSlider(slider => slider @@ -292,15 +799,4 @@ export class TaskNotesSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); } - - private updateCustomTemplateVisibility() { - const customSetting = (this as any).customTemplateSetting as Setting; - if (customSetting) { - if (this.plugin.settings.taskFilenameFormat === 'custom') { - customSetting.settingEl.style.display = ''; - } else { - customSetting.settingEl.style.display = 'none'; - } - } - } } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 7fcee992..7dbb3e52 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,4 +146,57 @@ export interface PomodoroState { pomodorosCompleted: number; // today's count currentStreak: number; // consecutive pomodoros totalMinutesToday: number; // total focused minutes today +} + +// Field mapping and customization types +export interface FieldMapping { + title: string; + status: string; + priority: string; + due: string; + contexts: string; + timeEstimate: string; + timeSpent: string; + completedDate: string; + dateCreated: string; + dateModified: string; + recurrence: string; + archiveTag: string; // For the archive tag in the tags array +} + +export interface StatusConfig { + id: string; // Unique identifier + value: string; // What gets written to YAML + label: string; // What displays in UI + color: string; // Hex color for UI elements + isCompleted: boolean; // Whether this counts as "done" + order: number; // Sort order (for cycling) +} + +export interface PriorityConfig { + id: string; // Unique identifier + value: string; // What gets written to YAML + label: string; // What displays in UI + color: string; // Hex color for indicators + weight: number; // For sorting (higher = more important) +} + +// Template configuration for quick setup +export interface Template { + id: string; + name: string; + description: string; + config: { + fieldMapping: Partial; + customStatuses: StatusConfig[]; + customPriorities: PriorityConfig[]; + }; +} + +// Configuration export/import +export interface ExportedConfig { + version: string; + fieldMapping: FieldMapping; + customStatuses: StatusConfig[]; + customPriorities: PriorityConfig[]; } \ No newline at end of file diff --git a/src/utils/FileIndexer.ts b/src/utils/FileIndexer.ts index 6f990548..eda004e7 100644 --- a/src/utils/FileIndexer.ts +++ b/src/utils/FileIndexer.ts @@ -1,6 +1,7 @@ import { TFile, Vault } from 'obsidian'; import { FileIndex, IndexedFile, TaskInfo, NoteInfo, FileEventHandlers } from '../types'; import { extractNoteInfo, extractTaskInfo, debounce } from './helpers'; +import { FieldMapper } from '../services/FieldMapper'; import * as YAML from 'yaml'; import { YAMLCache } from './YAMLCache'; @@ -13,11 +14,12 @@ export class FileIndexer { public excludedFolders: string[]; private dailyNotesPath: string; private dailyNoteTemplatePath: string; + private fieldMapper: FieldMapper | null = null; // Store event handlers for cleanup private eventHandlers: FileEventHandlers = {}; - constructor(vault: Vault, taskTag: string, excludedFolders: string = '', dailyNotesPath: string = '', dailyNoteTemplatePath: string = '') { + constructor(vault: Vault, taskTag: string, excludedFolders: string = '', dailyNotesPath: string = '', dailyNoteTemplatePath: string = '', fieldMapper?: FieldMapper) { this.vault = vault; this.taskTag = taskTag; this.excludedFolders = excludedFolders @@ -27,6 +29,7 @@ export class FileIndexer { // Normalize daily notes path by removing leading/trailing slashes this.dailyNotesPath = dailyNotesPath.replace(/^\/+|\/+$/g, ''); this.dailyNoteTemplatePath = dailyNoteTemplatePath; + this.fieldMapper = fieldMapper || null; // Register event listeners for file changes this.registerFileEvents(); @@ -39,6 +42,13 @@ export class FileIndexer { this.dailyNoteTemplatePath = newPath; } + /** + * Update the field mapper (used when settings change) + */ + updateFieldMapper(fieldMapper: FieldMapper) { + this.fieldMapper = fieldMapper; + } + private registerFileEvents() { // Create debounced versions of file operations const debouncedUpdate = debounce((file: TFile) => { @@ -245,7 +255,7 @@ export class FileIndexer { // Use cachedRead for better performance const content = await this.vault.cachedRead(file); - const taskInfo = extractTaskInfo(content, indexedFile.path); + const taskInfo = extractTaskInfo(content, indexedFile.path, this.fieldMapper || undefined); if (taskInfo) { // Cache the result in the index @@ -471,7 +481,7 @@ export class FileIndexer { if (!(fileObj instanceof TFile)) return null; const content = await this.vault.cachedRead(fileObj); - return extractTaskInfo(content, file.path); + return extractTaskInfo(content, file.path, this.fieldMapper || undefined); } else { return file.cachedInfo as TaskInfo; } diff --git a/src/utils/filenameGenerator.ts b/src/utils/filenameGenerator.ts index 1aea5ba2..ab7c3dfe 100644 --- a/src/utils/filenameGenerator.ts +++ b/src/utils/filenameGenerator.ts @@ -4,8 +4,8 @@ import { TaskNotesSettings } from '../settings/settings'; export interface FilenameContext { title: string; - priority: 'low' | 'normal' | 'high'; - status: 'open' | 'in-progress' | 'done'; + priority: string; + status: string; date?: Date; } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 63b788f6..2cf549bd 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,8 +1,9 @@ import { normalizePath, TFile, Vault } from 'obsidian'; import { format } from 'date-fns'; -import { TimeInfo } from '../types'; +import { TimeInfo, TaskInfo } from '../types'; import * as YAML from 'yaml'; import { YAMLCache } from './YAMLCache'; +import { FieldMapper } from '../services/FieldMapper'; /** * Creates a debounced version of a function @@ -112,6 +113,66 @@ export function updateYamlFrontmatter(content: string, key: string, upd return `---\n${updatedFrontmatter}---${restOfContent}`; } +/** + * Updates task properties in content using field mapping + */ +export function updateTaskProperty( + content: string, + propertyUpdates: Partial, + fieldMapper?: FieldMapper +): string { + // Check if the content has YAML frontmatter + if (!content.startsWith('---')) { + // If not, create new frontmatter + const yamlObj: Record = {}; + + if (fieldMapper) { + // Use field mapper to create frontmatter + const mappedFrontmatter = fieldMapper.mapToFrontmatter(propertyUpdates); + Object.assign(yamlObj, mappedFrontmatter); + } else { + // Use legacy field names + Object.assign(yamlObj, propertyUpdates); + } + + const yamlStr = YAML.stringify(yamlObj); + return `---\n${yamlStr}---\n\n${content}`; + } + + // Find the end of the frontmatter + const endOfFrontmatter = content.indexOf('---', 3); + if (endOfFrontmatter === -1) return content; + + // Extract the frontmatter + const frontmatter = content.substring(3, endOfFrontmatter); + const restOfContent = content.substring(endOfFrontmatter + 3); // Skip the closing --- + + // Parse the frontmatter + let yamlObj: Record = {}; + try { + yamlObj = YAML.parse(frontmatter) || {}; + } catch (e) { + console.error('Error parsing YAML frontmatter:', e); + return content; + } + + // Update properties + if (fieldMapper) { + // Use field mapper to update properties + const mappedUpdates = fieldMapper.mapToFrontmatter(propertyUpdates); + Object.assign(yamlObj, mappedUpdates); + } else { + // Use legacy field names + Object.assign(yamlObj, propertyUpdates); + } + + // Stringify the frontmatter + const updatedFrontmatter = YAML.stringify(yamlObj); + + // Return the updated content + return `---\n${updatedFrontmatter}---${restOfContent}`; +} + /** * Generates a daily note template */ @@ -242,34 +303,13 @@ export function isSameDay(date1: Date, date2: Date): boolean { } /** - * Extracts task information from a task file's content + * Extracts task information from a task file's content using field mapping */ -export function extractTaskInfo(content: string, path: string): { - title: string, - status: string, - priority: string, - due?: string, +export function extractTaskInfo( + content: string, path: string, - archived: boolean, - tags?: string[], - contexts?: string[], - recurrence?: { - frequency: 'daily' | 'weekly' | 'monthly' | 'yearly', - days_of_week?: string[], - day_of_month?: number, - month_of_year?: number - }, - complete_instances?: string[], - completedDate?: string, - timeEstimate?: number, - timeSpent?: number, - timeEntries?: { - startTime: string, - endTime?: string, - duration?: number, - description?: string - }[] -} | null { + fieldMapper?: FieldMapper +): TaskInfo | null { // Try to extract task info from frontmatter if (content.startsWith('---')) { const endOfFrontmatter = content.indexOf('---', 3); @@ -278,51 +318,76 @@ export function extractTaskInfo(content: string, path: string): { const yaml = YAMLCache.extractFrontmatter(content, path); if (yaml) { - // Check if task has archive tag - const tags = yaml.tags || []; - const archived = Array.isArray(tags) && tags.includes('archive'); - - // Extract recurrence info if present - let recurrence = undefined; - if (yaml.recurrence && typeof yaml.recurrence === 'object') { - recurrence = { - frequency: yaml.recurrence.frequency, - days_of_week: yaml.recurrence.days_of_week, - day_of_month: yaml.recurrence.day_of_month, - month_of_year: yaml.recurrence.month_of_year + if (fieldMapper) { + // Use field mapper to extract task info + const mappedTask = fieldMapper.mapFromFrontmatter(yaml, path); + + // Ensure required fields have defaults + const taskInfo: TaskInfo = { + title: mappedTask.title || 'Untitled Task', + status: mappedTask.status || 'open', + priority: mappedTask.priority || 'normal', + due: mappedTask.due, + path, + archived: mappedTask.archived || false, + tags: mappedTask.tags || [], + contexts: mappedTask.contexts || [], + recurrence: mappedTask.recurrence, + complete_instances: mappedTask.complete_instances, + completedDate: mappedTask.completedDate, + timeEstimate: mappedTask.timeEstimate, + timeSpent: mappedTask.timeSpent, + timeEntries: mappedTask.timeEntries + }; + + return taskInfo; + } else { + // Fallback to legacy field names for backward compatibility + const tags = yaml.tags || []; + const archived = Array.isArray(tags) && tags.includes('archived'); + + // Extract recurrence info if present + let recurrence = undefined; + if (yaml.recurrence && typeof yaml.recurrence === 'object') { + recurrence = { + frequency: yaml.recurrence.frequency, + days_of_week: yaml.recurrence.days_of_week, + day_of_month: yaml.recurrence.day_of_month, + month_of_year: yaml.recurrence.month_of_year + }; + } + + // Extract complete_instances array if present + let complete_instances = undefined; + if (yaml.complete_instances && Array.isArray(yaml.complete_instances)) { + complete_instances = yaml.complete_instances; + } + + // Extract contexts + const contexts = yaml.contexts || []; + + return { + title: yaml.title || 'Untitled Task', + status: yaml.status || 'open', + priority: yaml.priority || 'normal', + due: yaml.due, + path, + archived, + tags: Array.isArray(tags) ? [...tags] : [], + contexts: Array.isArray(contexts) ? [...contexts] : [], + recurrence, + complete_instances, + completedDate: yaml.completedDate, + timeEstimate: yaml.timeEstimate, + timeSpent: yaml.timeSpent, + timeEntries: yaml.timeEntries?.map((entry: any) => ({ + start: entry.start || entry.startTime, + end: entry.end || entry.endTime, + duration: entry.duration, + description: entry.description + })) }; } - - // Extract complete_instances array if present - let complete_instances = undefined; - if (yaml.complete_instances && Array.isArray(yaml.complete_instances)) { - complete_instances = yaml.complete_instances; - } - - // Extract contexts - const contexts = yaml.contexts || []; - - return { - title: yaml.title || 'Untitled Task', - status: yaml.status || 'open', - priority: yaml.priority || 'normal', - due: yaml.due, - path, - archived, - tags: Array.isArray(tags) ? [...tags] : [], - contexts: Array.isArray(contexts) ? [...contexts] : [], - recurrence, - complete_instances, - completedDate: yaml.completedDate, - timeEstimate: yaml.timeEstimate, - timeSpent: yaml.timeSpent, - timeEntries: yaml.timeEntries?.map((entry: any) => ({ - start: entry.start || entry.startTime, - end: entry.end || entry.endTime, - duration: entry.duration, - description: entry.description - })) - }; } } } diff --git a/src/views/TaskListView.ts b/src/views/TaskListView.ts index d68fbac0..b29d18b8 100644 --- a/src/views/TaskListView.ts +++ b/src/views/TaskListView.ts @@ -202,9 +202,29 @@ export class TaskListView extends ItemView { statusFilter.createEl('span', { text: 'Status: ' }); const statusSelect = statusFilter.createEl('select', { cls: 'status-select' }); - const statuses = ['All', 'Open', 'In progress', 'Done', 'Archived']; - statuses.forEach(status => { - const option = statusSelect.createEl('option', { value: status.toLowerCase(), text: status }); + // Create status options from custom statuses + const statusOptions = ['All', 'Open (All non-completed)', 'Archived']; + + // Add custom statuses + this.plugin.statusManager.getAllStatuses().forEach(status => { + statusOptions.splice(-1, 0, status.label); // Insert before 'Archived' + }); + + statusOptions.forEach(statusText => { + let value: string; + if (statusText === 'All') { + value = 'all'; + } else if (statusText === 'Open (All non-completed)') { + value = 'open'; + } else if (statusText === 'Archived') { + value = 'archived'; + } else { + // Find the status config that matches this label + const statusConfig = this.plugin.statusManager.getAllStatuses().find(s => s.label === statusText); + value = statusConfig ? statusConfig.value : statusText.toLowerCase(); + } + + const option = statusSelect.createEl('option', { value, text: statusText }); }); // Context filter (moved to collapsible section) @@ -406,17 +426,30 @@ export class TaskListView extends ItemView { }); } + /** + * Generate a safe CSS class name from a value + */ + private getSafeCSSClass(prefix: string, value: string): string { + return `${prefix}-${value.replace(/[^a-zA-Z0-9-]/g, '-')}`; + } + /** * Format group name for display */ private formatGroupName(groupName: string): string { + // Check if it's a priority value + const priorityConfig = this.plugin.priorityManager.getPriorityConfig(groupName); + if (priorityConfig) { + return `${priorityConfig.label} priority`; + } + + // Check if it's a status value + const statusConfig = this.plugin.statusManager.getStatusConfig(groupName); + if (statusConfig) { + return statusConfig.label; + } + switch (groupName) { - case 'high': - return 'High priority'; - case 'normal': - return 'Normal priority'; - case 'low': - return 'Low priority'; case 'all': return 'All tasks'; default: @@ -445,14 +478,24 @@ export class TaskListView extends ItemView { ? getEffectiveTaskStatus(task, this.plugin.selectedDate) : task.status; + const priorityClass = this.getSafeCSSClass('priority', task.priority); const taskItem = container.createDiv({ - cls: `task-item priority-${task.priority} ${isDueOnSelectedDate ? 'task-due-today' : ''} ${task.archived ? 'task-archived' : ''} ${task.recurrence ? 'task-recurring' : ''} tasknotes-card` + cls: `task-item ${priorityClass} ${isDueOnSelectedDate ? 'task-due-today' : ''} ${task.archived ? 'task-archived' : ''} ${task.recurrence ? 'task-recurring' : ''} tasknotes-card` }); // Store reference to this task element for future updates taskItem.dataset.taskPath = task.path; this.taskElements.set(task.path, taskItem); + // Get priority configuration for styling and interactions + const priorityConfig = this.plugin.priorityManager.getPriorityConfig(task.priority); + + // Apply priority color for hover border styling + if (priorityConfig) { + taskItem.dataset.priorityColor = priorityConfig.color; + taskItem.style.setProperty('--current-priority-color', priorityConfig.color); + } + // Create header row (title and metadata) const taskHeader = taskItem.createDiv({ cls: 'task-header tasknotes-card-header' }); @@ -463,15 +506,22 @@ export class TaskListView extends ItemView { const titleContainer = taskInfo.createDiv({ cls: 'task-title-container' }); // Priority indicator - clickable + const priorityLabel = priorityConfig?.label || task.priority; + const priorityIndicator = titleContainer.createEl('button', { - cls: `task-priority-indicator priority-${task.priority} clickable`, + cls: `task-priority-indicator ${priorityClass} clickable`, attr: { - 'aria-label': `Priority: ${task.priority}. Click to change`, + 'aria-label': `Priority: ${priorityLabel}. Click to change`, 'title': 'Click to change priority', 'type': 'button' } }); + // Apply custom priority color + if (priorityConfig) { + priorityIndicator.style.backgroundColor = priorityConfig.color; + } + // Add click handler for priority indicator priorityIndicator.addEventListener('click', async (e) => { e.stopPropagation(); @@ -479,13 +529,11 @@ export class TaskListView extends ItemView { // Get fresh task data from cache const currentTask = this.getTaskFromCache(task.path) || task; - // Cycle through priorities: high -> normal -> low -> high - const priorityCycle = ['high', 'normal', 'low']; - const currentIndex = priorityCycle.indexOf(currentTask.priority); - const nextPriority = priorityCycle[(currentIndex + 1) % priorityCycle.length]; + // Use PriorityManager to get next priority + const nextPriority = this.plugin.priorityManager.getNextPriority(currentTask.priority); const originalPriority = currentTask.priority; - const updatedTask = { ...currentTask, priority: nextPriority as 'low' | 'normal' | 'high' }; + const updatedTask = { ...currentTask, priority: nextPriority }; // Optimistic UI update this.updateTaskElementInDOM(currentTask.path, updatedTask); @@ -659,17 +707,26 @@ export class TaskListView extends ItemView { }); // Status badge - clickable for non-recurring tasks (in footer) + const statusClass = this.getSafeCSSClass('task-status', effectiveStatus); + const statusConfig = this.plugin.statusManager.getStatusConfig(effectiveStatus); + const statusLabel = statusConfig?.label || effectiveStatus; + const statusBadge = dueDateSection.createEl('button', { - cls: `task-status task-status-${effectiveStatus.replace(/\s+/g, '-').toLowerCase()} ${task.recurrence ? 'recurring-status' : ''} ${!task.recurrence ? 'clickable' : ''}`, - text: effectiveStatus, + cls: `task-status ${statusClass} ${task.recurrence ? 'recurring-status' : ''} ${!task.recurrence ? 'clickable' : ''}`, + text: statusLabel, attr: { - 'aria-label': `Change status: ${effectiveStatus}`, + 'aria-label': `Change status: ${statusLabel}`, 'title': task.recurrence ? `Status for ${format(this.plugin.selectedDate, 'MMMM d, yyyy')}` : 'Click to change status', 'type': 'button', 'disabled': task.recurrence ? 'true' : null } }); + // Apply custom status color + if (statusConfig) { + statusBadge.style.backgroundColor = statusConfig.color; + } + // Add click handler for status badge (non-recurring tasks only) if (!task.recurrence) { statusBadge.addEventListener('click', async (e) => { @@ -678,16 +735,14 @@ export class TaskListView extends ItemView { // Get fresh task data from cache const currentTask = this.getTaskFromCache(task.path) || task; - // Cycle through statuses: open -> in-progress -> done -> open - const statusCycle = ['open', 'in-progress', 'done']; - const currentIndex = statusCycle.indexOf(currentTask.status.toLowerCase()); - const nextStatus = statusCycle[(currentIndex + 1) % statusCycle.length]; + // Use StatusManager to get next status + const nextStatus = this.plugin.statusManager.getNextStatus(currentTask.status); const originalStatus = currentTask.status; const updatedTask = { ...currentTask, status: nextStatus }; - // Update completedDate - if (nextStatus === 'done') { + // Update completedDate based on whether new status is completed + if (this.plugin.statusManager.isCompletedStatus(nextStatus)) { updatedTask.completedDate = format(new Date(), 'yyyy-MM-dd'); } else { updatedTask.completedDate = undefined; @@ -744,12 +799,13 @@ export class TaskListView extends ItemView { if (task.recurrence) { const recurringSection = taskFooter.createDiv({ cls: 'recurring-section' }); + const isCompleted = this.plugin.statusManager.isCompletedStatus(effectiveStatus); const toggleButton = recurringSection.createEl('button', { - cls: `task-toggle-button ${effectiveStatus === 'done' ? 'mark-incomplete' : 'mark-complete'}`, - text: effectiveStatus === 'done' ? 'Mark incomplete' : 'Mark complete', + cls: `task-toggle-button ${isCompleted ? 'mark-incomplete' : 'mark-complete'}`, + text: isCompleted ? 'Mark incomplete' : 'Mark complete', attr: { - 'aria-label': `${effectiveStatus === 'done' ? 'Mark task incomplete' : 'Mark task complete'} for ${format(this.plugin.selectedDate, 'MMMM d, yyyy')}`, - 'aria-pressed': (effectiveStatus === 'done').toString() + 'aria-label': `${isCompleted ? 'Mark task incomplete' : 'Mark task complete'} for ${format(this.plugin.selectedDate, 'MMMM d, yyyy')}`, + 'aria-pressed': isCompleted.toString() } }); @@ -766,7 +822,7 @@ export class TaskListView extends ItemView { : currentTask.status; try { - const currentlyComplete = currentEffectiveStatus === 'done'; + const currentlyComplete = this.plugin.statusManager.isCompletedStatus(currentEffectiveStatus); const newComplete = !currentlyComplete; const selectedDateStr = format(this.plugin.selectedDate, 'yyyy-MM-dd'); @@ -841,8 +897,8 @@ export class TaskListView extends ItemView { // Sort with tasks due on selected date first, then by normal sort criteria tasks.sort((a, b) => { // First, put completed tasks at the bottom - const aIsDone = a.status.toLowerCase() === 'done'; - const bIsDone = b.status.toLowerCase() === 'done'; + const aIsDone = this.plugin.statusManager.isCompletedStatus(a.status); + const bIsDone = this.plugin.statusManager.isCompletedStatus(b.status); if (aIsDone && !bIsDone) return 1; if (!aIsDone && bIsDone) return -1; @@ -863,9 +919,8 @@ export class TaskListView extends ItemView { if (a.due && !b.due) return -1; if (!a.due && b.due) return 1; - // Then sort by priority - const priorityOrder = { high: 0, normal: 1, low: 2 }; - return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder]; + // Then sort by priority using PriorityManager + return this.plugin.priorityManager.comparePriorities(a.priority, b.priority); }); } @@ -902,9 +957,8 @@ export class TaskListView extends ItemView { if (a.due && !b.due) return -1; if (!a.due && b.due) return 1; - // Then sort by priority - const priorityOrder = { high: 0, normal: 1, low: 2 }; - return priorityOrder[a.priority as keyof typeof priorityOrder] - priorityOrder[b.priority as keyof typeof priorityOrder]; + // Then sort by priority using PriorityManager + return this.plugin.priorityManager.comparePriorities(a.priority, b.priority); }); // Update cache and timestamp - we need a fresh cache @@ -940,8 +994,10 @@ export class TaskListView extends ItemView { tasks.forEach(task => { if (task.contexts) { - task.contexts.forEach(context => { - if (context.trim()) { + // Ensure contexts is always treated as an array + const contextsArray = Array.isArray(task.contexts) ? task.contexts : [task.contexts]; + contextsArray.forEach(context => { + if (typeof context === 'string' && context.trim()) { contextsSet.add(context.trim()); } }); @@ -1038,22 +1094,34 @@ export class TaskListView extends ItemView { ? getEffectiveTaskStatus(updatedTask, this.plugin.selectedDate) : updatedTask.status; - // Format display text properly - let displayStatus = effectiveStatus; - if (effectiveStatus === 'in-progress') { - displayStatus = 'In progress'; - } else { - displayStatus = effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1); - } + const statusClass = this.getSafeCSSClass('task-status', effectiveStatus); + const statusConfig = this.plugin.statusManager.getStatusConfig(effectiveStatus); + const statusLabel = statusConfig?.label || effectiveStatus; - statusBadge.className = `task-status task-status-${effectiveStatus.replace(/\s+/g, '-').toLowerCase()} ${updatedTask.recurrence ? 'recurring-status' : ''} ${!updatedTask.recurrence ? 'clickable' : ''}`; - statusBadge.textContent = displayStatus; + statusBadge.className = `task-status ${statusClass} ${updatedTask.recurrence ? 'recurring-status' : ''} ${!updatedTask.recurrence ? 'clickable' : ''}`; + statusBadge.textContent = statusLabel; + + // Apply custom status color + if (statusConfig) { + statusBadge.style.backgroundColor = statusConfig.color; + } } // Update priority indicator const priorityIndicator = taskElement.querySelector('.task-priority-indicator') as HTMLElement; if (priorityIndicator) { - priorityIndicator.className = `task-priority-indicator priority-${updatedTask.priority} clickable`; + const priorityClass = this.getSafeCSSClass('priority', updatedTask.priority); + const priorityConfig = this.plugin.priorityManager.getPriorityConfig(updatedTask.priority); + priorityIndicator.className = `task-priority-indicator ${priorityClass} clickable`; + + // Apply custom priority color + if (priorityConfig) { + priorityIndicator.style.backgroundColor = priorityConfig.color; + + // Update hover border styling on the task item + taskElement.dataset.priorityColor = priorityConfig.color; + taskElement.style.setProperty('--current-priority-color', priorityConfig.color); + } } // Update archive status @@ -1148,7 +1216,8 @@ export class TaskListView extends ItemView { (updatedTask.recurrence && isRecurringTaskDueOn(updatedTask, this.plugin.selectedDate)) ); - taskElement.className = `task-item priority-${updatedTask.priority} ${isDueOnSelectedDate ? 'task-due-today' : ''} ${updatedTask.archived ? 'task-archived' : ''} ${updatedTask.recurrence ? 'task-recurring' : ''} tasknotes-card`; + const priorityClass = this.getSafeCSSClass('priority', updatedTask.priority); + taskElement.className = `task-item ${priorityClass} ${isDueOnSelectedDate ? 'task-due-today' : ''} ${updatedTask.archived ? 'task-archived' : ''} ${updatedTask.recurrence ? 'task-recurring' : ''} tasknotes-card`; // Add visual feedback for the update taskElement.classList.add('task-updated'); @@ -1185,10 +1254,7 @@ export class TaskListView extends ItemView { const sortedTasks = [...tasks].sort((a, b) => { switch (this.sortKey) { case 'priority': - const priorityOrder = { high: 0, normal: 1, low: 2 }; - const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 1; - const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 1; - return aPriority - bPriority; + return this.plugin.priorityManager.comparePriorities(a.priority, b.priority); case 'title': return a.title.localeCompare(b.title); @@ -1248,13 +1314,17 @@ export class TaskListView extends ItemView { case 'context': if (task.contexts && task.contexts.length > 0) { + // Ensure contexts is an array + const contextsArray = Array.isArray(task.contexts) ? task.contexts : [task.contexts]; // For tasks with multiple contexts, create separate entries - task.contexts.forEach(context => { - const contextKey = context.trim(); - if (!grouped.has(contextKey)) { - grouped.set(contextKey, []); + contextsArray.forEach(context => { + if (typeof context === 'string') { + const contextKey = context.trim(); + if (!grouped.has(contextKey)) { + grouped.set(contextKey, []); + } + grouped.get(contextKey)!.push(task); } - grouped.get(contextKey)!.push(task); }); return; // Skip the default grouping below } else { @@ -1316,9 +1386,15 @@ export class TaskListView extends ItemView { if (selectedStatus === 'all') { filteredTasks = nonArchivedTasks; - } else { + } else if (selectedStatus === 'open') { + // Show all non-completed tasks filteredTasks = nonArchivedTasks.filter(task => - task.status.toLowerCase() === selectedStatus + !this.plugin.statusManager.isCompletedStatus(task.status) + ); + } else { + // Show tasks with specific status value + filteredTasks = nonArchivedTasks.filter(task => + task.status === selectedStatus ); } } @@ -1330,9 +1406,12 @@ export class TaskListView extends ItemView { return false; // Task has no contexts, exclude it } + // Ensure contexts is an array + const contextsArray = Array.isArray(task.contexts) ? task.contexts : [task.contexts]; + // Check if task has any of the selected contexts - return task.contexts.some(context => - this.selectedContexts.has(context.trim()) + return contextsArray.some(context => + typeof context === 'string' && this.selectedContexts.has(context.trim()) ); }); } diff --git a/styles.css b/styles.css index f2ab39d6..c8b91017 100644 --- a/styles.css +++ b/styles.css @@ -1253,6 +1253,11 @@ button.processing::after { transform: scaleX(1); } +/* Priority-based top border on hover - for custom priority colors */ +.task-item[data-priority-color]:hover { + border-top: 3px solid var(--current-priority-color, var(--interactive-accent)); +} + /* Task update animation */ .task-item.task-updated { animation: taskUpdate 1.5s ease;