2025-09-28 02:28:35 +00:00
|
|
|
import { FieldMapping, TaskInfo } from "../types";
|
2025-09-28 05:28:41 +00:00
|
|
|
import {
|
|
|
|
|
normalizeDependencyEntry,
|
|
|
|
|
normalizeDependencyList,
|
|
|
|
|
serializeDependencies,
|
|
|
|
|
} from "../utils/dependencyUtils";
|
2025-09-28 02:28:35 +00:00
|
|
|
import { validateCompleteInstances } from "../utils/dateUtils";
|
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.
2025-06-03 11:44:13 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Service for mapping between internal field names and user-configured property names
|
|
|
|
|
*/
|
|
|
|
|
export class FieldMapper {
|
2025-09-28 02:28:35 +00:00
|
|
|
constructor(private mapping: FieldMapping) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert internal field name to user's property name
|
|
|
|
|
*/
|
|
|
|
|
toUserField(internalName: keyof FieldMapping): string {
|
|
|
|
|
return this.mapping[internalName];
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Normalize arbitrary title-like values to a string.
|
|
|
|
|
* - string: return as-is
|
|
|
|
|
* - number/boolean: String(value)
|
|
|
|
|
* - array: join elements stringified with ', '
|
|
|
|
|
* - object: return empty string (unsupported edge case)
|
|
|
|
|
*/
|
|
|
|
|
private normalizeTitle(val: unknown): string | undefined {
|
|
|
|
|
if (typeof val === "string") return val;
|
|
|
|
|
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
|
|
|
|
|
if (val === null || val === undefined) return undefined;
|
|
|
|
|
if (typeof val === "object") return "";
|
|
|
|
|
return String(val);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert frontmatter object using mapping to internal task data
|
|
|
|
|
*/
|
|
|
|
|
mapFromFrontmatter(
|
|
|
|
|
frontmatter: any,
|
|
|
|
|
filePath: string,
|
|
|
|
|
storeTitleInFilename?: boolean
|
|
|
|
|
): 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) {
|
|
|
|
|
const rawTitle = frontmatter[this.mapping.title];
|
|
|
|
|
const normalized = this.normalizeTitle(rawTitle);
|
|
|
|
|
if (normalized !== undefined) {
|
|
|
|
|
mapped.title = normalized;
|
|
|
|
|
}
|
|
|
|
|
} else if (storeTitleInFilename) {
|
|
|
|
|
const filename = filePath.split("/").pop()?.replace(".md", "");
|
|
|
|
|
if (filename) {
|
|
|
|
|
mapped.title = filename;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.status] !== undefined) {
|
|
|
|
|
const statusValue = frontmatter[this.mapping.status];
|
|
|
|
|
// Handle boolean status values (convert back to string for internal use)
|
|
|
|
|
if (typeof statusValue === "boolean") {
|
|
|
|
|
mapped.status = statusValue ? "true" : "false";
|
|
|
|
|
} else {
|
|
|
|
|
mapped.status = statusValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.scheduled] !== undefined) {
|
|
|
|
|
mapped.scheduled = frontmatter[this.mapping.scheduled];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.projects] !== undefined) {
|
|
|
|
|
const projects = frontmatter[this.mapping.projects];
|
|
|
|
|
// Ensure projects is always an array
|
|
|
|
|
mapped.projects = Array.isArray(projects) ? projects : [projects];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.timeEstimate] !== undefined) {
|
|
|
|
|
mapped.timeEstimate = frontmatter[this.mapping.timeEstimate];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.completedDate] !== undefined) {
|
|
|
|
|
mapped.completedDate = frontmatter[this.mapping.completedDate];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.recurrence] !== undefined) {
|
|
|
|
|
mapped.recurrence = frontmatter[this.mapping.recurrence];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.dateCreated] !== undefined) {
|
|
|
|
|
mapped.dateCreated = frontmatter[this.mapping.dateCreated];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.dateModified] !== undefined) {
|
|
|
|
|
mapped.dateModified = frontmatter[this.mapping.dateModified];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.timeEntries] !== undefined) {
|
|
|
|
|
// Ensure timeEntries is always an array
|
|
|
|
|
const timeEntriesValue = frontmatter[this.mapping.timeEntries];
|
|
|
|
|
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.completeInstances] !== undefined) {
|
|
|
|
|
// Validate and clean the complete_instances array
|
|
|
|
|
mapped.complete_instances = validateCompleteInstances(
|
|
|
|
|
frontmatter[this.mapping.completeInstances]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== undefined) {
|
2025-09-28 05:28:41 +00:00
|
|
|
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
|
|
|
|
|
if (dependencies) {
|
|
|
|
|
mapped.blockedBy = dependencies;
|
2025-09-28 02:28:35 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.icsEventId] !== undefined) {
|
|
|
|
|
const icsEventId = frontmatter[this.mapping.icsEventId];
|
|
|
|
|
// Ensure icsEventId is always an array
|
|
|
|
|
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (frontmatter[this.mapping.reminders] !== undefined) {
|
|
|
|
|
const reminders = frontmatter[this.mapping.reminders];
|
|
|
|
|
// Ensure reminders is always an array and filter out null/undefined values
|
|
|
|
|
if (Array.isArray(reminders)) {
|
|
|
|
|
const filteredReminders = reminders.filter((r) => r != null);
|
|
|
|
|
if (filteredReminders.length > 0) {
|
|
|
|
|
mapped.reminders = filteredReminders;
|
|
|
|
|
}
|
|
|
|
|
} else if (reminders != null) {
|
|
|
|
|
mapped.reminders = [reminders];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mapped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert internal task data to frontmatter using mapping
|
|
|
|
|
*/
|
|
|
|
|
mapToFrontmatter(
|
|
|
|
|
taskData: Partial<TaskInfo>,
|
|
|
|
|
taskTag?: string,
|
|
|
|
|
storeTitleInFilename?: boolean
|
|
|
|
|
): any {
|
|
|
|
|
const frontmatter: any = {};
|
|
|
|
|
|
|
|
|
|
// Map each field if it exists in task data
|
|
|
|
|
if (taskData.title !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.title] = taskData.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (storeTitleInFilename) {
|
|
|
|
|
delete frontmatter[this.mapping.title];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.status !== undefined) {
|
|
|
|
|
// Coerce boolean-like status strings to actual booleans for compatibility with Obsidian checkbox properties
|
|
|
|
|
const lower = taskData.status.toLowerCase();
|
|
|
|
|
const coercedValue =
|
|
|
|
|
lower === "true" || lower === "false" ? lower === "true" : taskData.status;
|
|
|
|
|
frontmatter[this.mapping.status] = coercedValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.priority !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.priority] = taskData.priority;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.due !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.due] = taskData.due;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.scheduled !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.scheduled] = taskData.scheduled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.contexts !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.contexts] = taskData.contexts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.projects !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.projects] = taskData.projects;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.timeEstimate !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.completedDate !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.completedDate] = taskData.completedDate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.recurrence !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.recurrence] = taskData.recurrence;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.dateCreated !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.dateCreated] = taskData.dateCreated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.dateModified !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.dateModified] = taskData.dateModified;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.timeEntries !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.timeEntries] = taskData.timeEntries;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.complete_instances !== undefined) {
|
|
|
|
|
frontmatter[this.mapping.completeInstances] = taskData.complete_instances;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.blockedBy !== undefined) {
|
2025-09-28 05:28:41 +00:00
|
|
|
if (Array.isArray(taskData.blockedBy)) {
|
|
|
|
|
const normalized = taskData.blockedBy
|
|
|
|
|
.map((item) => normalizeDependencyEntry(item))
|
|
|
|
|
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
|
|
|
|
|
frontmatter[this.mapping.blockedBy] = normalized.length > 0 ? serializeDependencies(normalized) : [];
|
|
|
|
|
} else {
|
|
|
|
|
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
|
|
|
|
|
}
|
2025-09-28 02:28:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
|
|
|
|
|
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
|
|
|
|
|
frontmatter[this.mapping.reminders] = taskData.reminders;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle tags (merge archive status into tags array)
|
|
|
|
|
let tags = taskData.tags ? [...taskData.tags] : [];
|
|
|
|
|
|
|
|
|
|
// Ensure task tag is always preserved if provided
|
|
|
|
|
if (taskTag && !tags.includes(taskTag)) {
|
|
|
|
|
tags.push(taskTag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return frontmatter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update mapping configuration
|
|
|
|
|
*/
|
|
|
|
|
updateMapping(newMapping: FieldMapping): void {
|
|
|
|
|
this.mapping = newMapping;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current mapping
|
|
|
|
|
*/
|
|
|
|
|
getMapping(): FieldMapping {
|
|
|
|
|
return { ...this.mapping };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert user's property name back to internal field name
|
|
|
|
|
* This is the reverse of toUserField()
|
|
|
|
|
*/
|
|
|
|
|
fromUserField(userPropertyName: string): keyof FieldMapping | null {
|
|
|
|
|
for (const [internalName, userName] of Object.entries(this.mapping)) {
|
|
|
|
|
if (userName === userPropertyName) {
|
|
|
|
|
return internalName as keyof FieldMapping;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-09-23 21:17:16 +00:00
|
|
|
}
|