feat: add customizable task field mapping, statuses, and priorities (#2)

• 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.
This commit is contained in:
Callum Alpass 2025-06-03 21:44:13 +10:00
parent a280b66c61
commit 8905686a69
12 changed files with 1674 additions and 332 deletions

View file

@ -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<string> {
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<void> {
try {
const file = this.app.vault.getAbstractFileByPath(task.path);
@ -691,29 +751,30 @@ async generateDailyNoteTemplate(date: Date): Promise<string> {
// 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<TaskInfo> = {};
(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<string> {
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<string> {
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<TaskInfo> = {
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);

View file

@ -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<TaskFrontmatter> = {
// Create task info object
const taskInfo: Partial<TaskInfo> = {
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);

182
src/services/FieldMapper.ts Normal file
View file

@ -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<TaskInfo> {
if (!frontmatter) return {};
const mapped: Partial<TaskInfo> = {
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<TaskInfo>): 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
};
}
}

View file

@ -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
};
}
}

View file

@ -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
};
}
}

View file

@ -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<string, HTMLElement> = {};
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 = `
<strong> Warning:</strong> 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';
}
}
}
}

View file

@ -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<FieldMapping>;
customStatuses: StatusConfig[];
customPriorities: PriorityConfig[];
};
}
// Configuration export/import
export interface ExportedConfig {
version: string;
fieldMapping: FieldMapping;
customStatuses: StatusConfig[];
customPriorities: PriorityConfig[];
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<T = any>(content: string, key: string, upd
return `---\n${updatedFrontmatter}---${restOfContent}`;
}
/**
* Updates task properties in content using field mapping
*/
export function updateTaskProperty(
content: string,
propertyUpdates: Partial<TaskInfo>,
fieldMapper?: FieldMapper
): string {
// Check if the content has YAML frontmatter
if (!content.startsWith('---')) {
// If not, create new frontmatter
const yamlObj: Record<string, any> = {};
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<string, any> = {};
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
}))
};
}
}
}

View file

@ -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())
);
});
}

View file

@ -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;