refactor: split task creation and update flows

This commit is contained in:
callumalpass 2026-03-29 07:47:22 +11:00
parent a8e8b29810
commit ef1262e8e8
3 changed files with 702 additions and 654 deletions

View file

@ -47,12 +47,34 @@ import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplate
import TaskNotesPlugin from "../main";
import { TranslationKey } from "../i18n";
import { TaskCreationService } from "./task-service/TaskCreationService";
import { TaskUpdateService } from "./task-service/TaskUpdateService";
export class TaskService {
private webhookNotifier?: IWebhookNotifier;
private autoArchiveService?: AutoArchiveService;
private readonly taskCreationService: TaskCreationService;
private readonly taskUpdateService: TaskUpdateService;
constructor(private plugin: TaskNotesPlugin) {}
constructor(private plugin: TaskNotesPlugin) {
this.taskCreationService = new TaskCreationService({
plugin: this.plugin,
webhookNotifier: this.webhookNotifier,
applyTaskCreationDefaults: (taskData) => this.applyTaskCreationDefaults(taskData),
applyTemplate: (taskData) => this.applyTemplate(taskData),
processFolderTemplate: (folderTemplate, taskData, date) =>
this.processFolderTemplate(folderTemplate, taskData, date),
sanitizeTitleForFilename: (input) => this.sanitizeTitleForFilename(input),
sanitizeTitleForStorage: (input) => this.sanitizeTitleForStorage(input),
});
this.taskUpdateService = new TaskUpdateService({
plugin: this.plugin,
webhookNotifier: this.webhookNotifier,
autoArchiveService: this.autoArchiveService,
updateCompletedDateInFrontmatter: (frontmatter, newStatus, isRecurring) =>
this.updateCompletedDateInFrontmatter(frontmatter, newStatus, isRecurring),
});
}
private translate(key: TranslationKey, variables?: Record<string, any>): string {
return this.plugin.i18n.translate(key, variables);
@ -137,6 +159,8 @@ export class TaskService {
*/
setWebhookNotifier(notifier: IWebhookNotifier): void {
this.webhookNotifier = notifier;
this.taskCreationService.setWebhookNotifier(notifier);
this.taskUpdateService.setWebhookNotifier(notifier);
}
/**
@ -144,6 +168,7 @@ export class TaskService {
*/
setAutoArchiveService(service: AutoArchiveService): void {
this.autoArchiveService = service;
this.taskUpdateService.setAutoArchiveService(service);
}
/**
@ -218,279 +243,7 @@ export class TaskService {
taskData: TaskCreationData,
options: { applyDefaults?: boolean } = {}
): Promise<{ file: TFile; taskInfo: TaskInfo }> {
const { applyDefaults = true } = options;
try {
// Apply task creation defaults if enabled
if (applyDefaults) {
taskData = await this.applyTaskCreationDefaults(taskData);
}
// Validate required fields
if (!taskData.title || !taskData.title.trim()) {
throw new Error("Title is required");
}
// Apply defaults for missing fields and sanitize title
// Use stricter sanitization only when title is used in filename
const title = this.plugin.settings.storeTitleInFilename
? this.sanitizeTitleForFilename(taskData.title.trim())
: this.sanitizeTitleForStorage(taskData.title.trim());
const priority = taskData.priority || this.plugin.settings.defaultTaskPriority;
const status = taskData.status || this.plugin.settings.defaultTaskStatus;
const dateCreated = taskData.dateCreated || getCurrentTimestamp();
const dateModified = taskData.dateModified || getCurrentTimestamp();
// Prepare contexts, projects, and tags arrays
const contextsArray = taskData.contexts || [];
const projectsArray = taskData.projects || [];
// Handle tags based on identification method
let tagsArray = taskData.tags || [];
// Only add task tag if using tag-based identification
if (this.plugin.settings.taskIdentificationMethod === "tag") {
if (!tagsArray.includes(this.plugin.settings.taskTag)) {
tagsArray = [this.plugin.settings.taskTag, ...tagsArray];
}
}
// Generate filename
const filenameContext: FilenameContext = {
title: title,
priority: priority,
status: status,
date: new Date(),
dueDate: taskData.due,
scheduledDate: taskData.scheduled,
};
const baseFilename = generateTaskFilename(filenameContext, this.plugin.settings);
// Determine folder based on creation context
// Process folder templates with task and date variables for dynamic folder organization
let folder = "";
if (taskData.creationContext === "inline-conversion" || taskData.creationContext === "modal-inline-creation") {
// For inline conversion and modal-based inline task creation, use the inline task folder setting with variable support
const inlineFolder = this.plugin.settings.inlineTaskConvertFolder || "";
if (inlineFolder.trim()) {
// Inline folder is configured, use it
folder = inlineFolder;
// Handle currentNotePath and currentNoteTitle template variables
if (
inlineFolder.includes("{{currentNotePath}}") ||
inlineFolder.includes("{{currentNoteTitle}}")
) {
const currentFile = this.plugin.app.workspace.getActiveFile();
if (inlineFolder.includes("{{currentNotePath}}")) {
// Get current file's folder path
const currentFolderPath = currentFile?.parent?.path || "";
folder = folder.replace(/\{\{currentNotePath\}\}/g, currentFolderPath);
}
if (inlineFolder.includes("{{currentNoteTitle}}")) {
// Get current file's title (basename without extension)
const currentNoteTitle = currentFile?.basename || "";
folder = folder.replace(/\{\{currentNoteTitle\}\}/g, currentNoteTitle);
}
}
// Process task and date variables in the inline folder path
folder = this.processFolderTemplate(folder, taskData);
} else {
// Fallback to default tasks folder when inline folder is empty (#128)
const tasksFolder = this.plugin.settings.tasksFolder || "";
folder = this.processFolderTemplate(tasksFolder, taskData);
}
} else {
// For manual creation and other contexts, use the general tasks folder
const tasksFolder = this.plugin.settings.tasksFolder || "";
folder = this.processFolderTemplate(tasksFolder, taskData);
}
// Ensure folder exists
if (folder) {
await ensureFolderExists(this.plugin.app.vault, folder);
}
// Generate unique filename
const uniqueFilename = await generateUniqueFilename(
baseFilename,
folder,
this.plugin.app.vault
);
const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`;
// Create complete TaskInfo object with all the data
const completeTaskData: Partial<TaskInfo> = {
title: title,
status: status,
priority: priority,
due: taskData.due || undefined,
scheduled: taskData.scheduled || undefined,
contexts: contextsArray.length > 0 ? contextsArray : undefined,
projects: projectsArray.length > 0 ? projectsArray : undefined,
timeEstimate:
taskData.timeEstimate && taskData.timeEstimate > 0
? taskData.timeEstimate
: undefined,
dateCreated: dateCreated,
dateModified: dateModified,
recurrence: taskData.recurrence || undefined,
recurrence_anchor: taskData.recurrence_anchor || undefined,
reminders:
taskData.reminders && taskData.reminders.length > 0
? taskData.reminders
: undefined,
icsEventId: taskData.icsEventId || undefined,
};
// Add DTSTART to recurrence rule if it doesn't have one
// This ensures Google Calendar sync works correctly from the start
if (
completeTaskData.recurrence &&
typeof completeTaskData.recurrence === "string" &&
!completeTaskData.recurrence.includes("DTSTART:")
) {
const tempTaskInfo: TaskInfo = {
...completeTaskData,
title: title,
status: status,
priority: priority,
path: "", // Path not yet known, but not needed for DTSTART calculation
archived: false,
};
const recurrenceWithDTSTART = addDTSTARTToRecurrenceRule(tempTaskInfo);
if (recurrenceWithDTSTART) {
completeTaskData.recurrence = recurrenceWithDTSTART;
}
}
const shouldAddTaskTag = this.plugin.settings.taskIdentificationMethod === "tag";
const taskTagForFrontmatter = shouldAddTaskTag
? this.plugin.settings.taskTag
: undefined;
// Use field mapper to convert to frontmatter with proper field mapping
const frontmatter = this.plugin.fieldMapper.mapToFrontmatter(
completeTaskData,
taskTagForFrontmatter,
this.plugin.settings.storeTitleInFilename
);
// Handle task identification based on settings
if (this.plugin.settings.taskIdentificationMethod === "property") {
const propName = this.plugin.settings.taskPropertyName;
const propValue = this.plugin.settings.taskPropertyValue;
if (propName && propValue) {
// Coerce boolean-like strings to actual booleans for compatibility with Obsidian properties
const lower = propValue.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : propValue;
frontmatter[propName] = coercedValue as any;
}
if (tagsArray.length > 0) {
frontmatter.tags = tagsArray;
}
} else {
// Tags are handled separately (not via field mapper)
frontmatter.tags = tagsArray;
}
// Apply template processing (both frontmatter and body)
const templateResult = await this.applyTemplate(taskData);
const normalizedBody = templateResult.body
? templateResult.body.replace(/\r\n/g, "\n").trimEnd()
: taskData.details
? taskData.details.replace(/\r\n/g, "\n").trimEnd()
: "";
// Merge template frontmatter with base frontmatter
// User-defined values take precedence over template frontmatter
let finalFrontmatter = mergeTemplateFrontmatter(
frontmatter,
templateResult.frontmatter
);
// Add custom frontmatter properties (including user fields)
if (taskData.customFrontmatter) {
finalFrontmatter = { ...finalFrontmatter, ...taskData.customFrontmatter };
}
// Prepare file content
const yamlHeader = stringifyYaml(finalFrontmatter);
let content = `---\n${yamlHeader}---\n\n`;
if (normalizedBody.length > 0) {
content += `${normalizedBody}\n`;
}
// Create the file
const file = await this.plugin.app.vault.create(fullPath, content);
// Create final TaskInfo object for cache and events
// Ensure required fields are present by using the complete task data as base
const taskInfo: TaskInfo = {
...completeTaskData,
...finalFrontmatter,
// Ensure required fields are always defined
title: finalFrontmatter.title || completeTaskData.title || title,
status: finalFrontmatter.status || completeTaskData.status || status,
priority: finalFrontmatter.priority || completeTaskData.priority || priority,
path: file.path,
tags: tagsArray,
archived: false,
details: normalizedBody,
};
// Wait for fresh data and update cache
try {
// Wait for the metadata cache to have the updated data for new tasks
if (this.plugin.cacheManager.waitForFreshTaskData) {
await this.plugin.cacheManager.waitForFreshTaskData(file);
}
this.plugin.cacheManager.updateTaskInfoInCache(file.path, taskInfo);
} catch (cacheError) {
console.error("Error updating cache for new task:", cacheError);
}
// Emit task created event
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
path: file.path,
updatedTask: taskInfo,
});
// Trigger webhook for task creation
if (this.webhookNotifier) {
try {
await this.webhookNotifier.triggerWebhook("task.created", { task: taskInfo });
} catch (error) {
console.warn("Failed to trigger webhook for task creation:", error);
}
}
// Sync to Google Calendar if enabled (fire-and-forget to avoid blocking modal)
if (
this.plugin.taskCalendarSyncService?.isEnabled() &&
this.plugin.settings.googleCalendarExport.syncOnTaskCreate
) {
this.plugin.taskCalendarSyncService.syncTaskToCalendar(taskInfo).catch((error) => {
console.warn("Failed to sync task to Google Calendar:", error);
});
}
return { file, taskInfo };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error("Error creating task:", {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
taskData,
});
throw new Error(`Failed to create task: ${errorMessage}`);
}
return this.taskCreationService.createTask(taskData, options);
}
/**
@ -1297,386 +1050,7 @@ export class TaskService {
originalTask: TaskInfo,
updates: Partial<TaskInfo> & { details?: string }
): Promise<TaskInfo> {
try {
const file = this.plugin.app.vault.getAbstractFileByPath(originalTask.path);
if (!(file instanceof TFile)) {
throw new Error(`Cannot find task file: ${originalTask.path}`);
}
if (Array.isArray(updates.timeEntries)) {
updates.timeEntries = updates.timeEntries.map((entry) => {
const sanitizedEntry = { ...entry };
delete sanitizedEntry.duration;
return sanitizedEntry;
});
}
const isRenameNeeded =
this.plugin.settings.storeTitleInFilename &&
updates.title &&
updates.title !== originalTask.title;
let newPath = originalTask.path;
if (isRenameNeeded) {
const parentPath = file.parent ? file.parent.path : "";
const newFilename = await generateUniqueFilename(
updates.title!,
parentPath,
this.plugin.app.vault
);
newPath = parentPath ? `${parentPath}/${newFilename}.md` : `${newFilename}.md`;
}
// Check if recurrence rule changed and update scheduled date if needed
let recurrenceUpdates: Partial<TaskInfo> = {};
if (
updates.recurrence !== undefined &&
updates.recurrence !== originalTask.recurrence
) {
// Recurrence rule changed, calculate new scheduled date
const tempTask: TaskInfo = { ...originalTask, ...updates };
const nextDates = updateToNextScheduledOccurrence(
tempTask,
this.plugin.settings.maintainDueDateOffsetInRecurring
);
if (nextDates.scheduled) {
recurrenceUpdates.scheduled = nextDates.scheduled;
}
if (nextDates.due) {
recurrenceUpdates.due = nextDates.due;
}
// Add DTSTART to recurrence rule if it's missing (scenario 1: editing recurrence rule)
if (
typeof updates.recurrence === "string" &&
updates.recurrence &&
!updates.recurrence.includes("DTSTART:")
) {
const tempTaskWithRecurrence: TaskInfo = {
...originalTask,
...updates,
...recurrenceUpdates,
};
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTaskWithRecurrence);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
} else if (
updates.recurrence !== undefined &&
!originalTask.recurrence &&
updates.recurrence
) {
// Scenario 2: Converting non-recurring to recurring task
if (
typeof updates.recurrence === "string" &&
!updates.recurrence.includes("DTSTART:")
) {
const tempTask: TaskInfo = { ...originalTask, ...updates };
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTask);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
}
// Scenario 3: Scheduled date update for recurring tasks
if (
updates.scheduled !== undefined &&
updates.scheduled !== originalTask.scheduled &&
originalTask.recurrence
) {
if (
typeof originalTask.recurrence === "string" &&
!originalTask.recurrence.includes("DTSTART:")
) {
const tempTask: TaskInfo = { ...originalTask, ...updates };
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTask);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
}
// Step 1: Persist frontmatter changes to the file at its original path
let normalizedDetails: string | null = null;
if (Object.prototype.hasOwnProperty.call(updates, "details")) {
normalizedDetails =
typeof updates.details === "string"
? updates.details.replace(/\r\n/g, "\n")
: "";
}
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
const completeTaskData: Partial<TaskInfo> = {
...originalTask,
...updates,
...recurrenceUpdates,
dateModified: getCurrentTimestamp(),
};
const mappedFrontmatter = this.plugin.fieldMapper.mapToFrontmatter(
completeTaskData,
this.plugin.settings.taskIdentificationMethod === "tag"
? this.plugin.settings.taskTag
: undefined,
this.plugin.settings.storeTitleInFilename
);
Object.keys(mappedFrontmatter).forEach((key) => {
if (mappedFrontmatter[key] !== undefined) {
frontmatter[key] = mappedFrontmatter[key];
}
});
// Handle completedDate for status changes (non-recurring tasks only)
if (updates.status !== undefined) {
this.updateCompletedDateInFrontmatter(frontmatter, updates.status, !!originalTask.recurrence);
}
// Handle task identification based on settings
if (this.plugin.settings.taskIdentificationMethod === "property") {
const propName = this.plugin.settings.taskPropertyName;
const propValue = this.plugin.settings.taskPropertyValue;
if (propName && propValue) {
// Coerce boolean-like strings to actual booleans for compatibility with Obsidian properties
const lower = propValue.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : propValue;
frontmatter[propName] = coercedValue as any;
}
}
// Handle custom frontmatter properties (including user fields)
if ((updates as any).customFrontmatter) {
Object.keys((updates as any).customFrontmatter).forEach((key) => {
const value = (updates as any).customFrontmatter[key];
if (value === null) {
// Remove the property if value is null
delete frontmatter[key];
} else {
// Set the property value
frontmatter[key] = value;
}
});
}
if (updates.hasOwnProperty("due") && updates.due === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("due")];
if (updates.hasOwnProperty("scheduled") && updates.scheduled === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("scheduled")];
if (updates.hasOwnProperty("contexts") && updates.contexts === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("contexts")];
if (updates.hasOwnProperty("projects")) {
const projectsField = this.plugin.fieldMapper.toUserField("projects");
const projectsToSet = Array.isArray(updates.projects) ? updates.projects : [];
if (projectsToSet.length > 0) {
frontmatter[projectsField] = projectsToSet;
} else {
delete frontmatter[projectsField];
}
}
if (updates.hasOwnProperty("timeEstimate") && updates.timeEstimate === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("timeEstimate")];
if (updates.hasOwnProperty("completedDate") && updates.completedDate === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("completedDate")];
if (updates.hasOwnProperty("recurrence") && updates.recurrence === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("recurrence")];
if (updates.hasOwnProperty("blockedBy") && updates.blockedBy === undefined)
delete frontmatter[this.plugin.fieldMapper.toUserField("blockedBy")];
if (isRenameNeeded) {
delete frontmatter[this.plugin.fieldMapper.toUserField("title")];
}
if (updates.hasOwnProperty("tags")) {
const tagsToSet = Array.isArray(updates.tags) ? [...updates.tags] : [];
if (tagsToSet.length > 0) {
frontmatter.tags = tagsToSet;
} else {
delete frontmatter.tags;
}
}
});
// Step 2: Rename the file if needed, after frontmatter is updated
if (isRenameNeeded) {
await this.plugin.app.fileManager.renameFile(file, newPath);
}
// Step 2b: Update body content if details changed
if (normalizedDetails !== null) {
const targetFile = this.plugin.app.vault.getAbstractFileByPath(newPath);
if (targetFile instanceof TFile) {
const currentContent = await this.plugin.app.vault.read(targetFile);
const { frontmatter: frontmatterText } =
splitFrontmatterAndBody(currentContent);
const frontmatterBlock =
frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : "";
const bodyContent = normalizedDetails.trimEnd();
const finalBody = bodyContent.length > 0 ? `${bodyContent}\n` : "";
await this.plugin.app.vault.modify(
targetFile,
`${frontmatterBlock}${finalBody}`
);
}
}
// Step 3: Construct the final authoritative state
const updatedTask: TaskInfo = {
...originalTask,
...updates,
...recurrenceUpdates,
path: newPath,
dateModified: getCurrentTimestamp(),
};
if (normalizedDetails !== null) {
updatedTask.details = normalizedDetails;
}
if (updates.status !== undefined && !originalTask.recurrence) {
if (this.plugin.statusManager.isCompletedStatus(updates.status)) {
if (!originalTask.completedDate) {
updatedTask.completedDate = getCurrentDateString();
}
} else {
updatedTask.completedDate = undefined;
}
}
// Step 4: Wait for fresh data and update cache
if (isRenameNeeded) {
this.plugin.cacheManager.clearCacheEntry(originalTask.path);
}
try {
// Wait for the metadata cache to have the updated data
const finalFile = this.plugin.app.vault.getAbstractFileByPath(newPath);
if (finalFile instanceof TFile && this.plugin.cacheManager.waitForFreshTaskData) {
// Wait for key changes to be reflected
const keyChanges: Partial<TaskInfo> = {};
if (updates.title !== undefined) keyChanges.title = updates.title;
if (updates.status !== undefined) keyChanges.status = updates.status;
if (updates.priority !== undefined) keyChanges.priority = updates.priority;
if (Object.keys(keyChanges).length > 0) {
await this.plugin.cacheManager.waitForFreshTaskData(finalFile);
}
}
this.plugin.cacheManager.updateTaskInfoInCache(newPath, updatedTask);
} catch (cacheError) {
// Cache errors shouldn't break the operation, just log them
// eslint-disable-next-line no-console
console.error("Error updating task cache:", {
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
taskPath: newPath,
});
}
// Step 5: Notify system of change
try {
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
path: newPath,
originalTask: originalTask,
updatedTask: updatedTask,
});
} catch (eventError) {
// eslint-disable-next-line no-console
console.error("Error emitting task update event:", {
error: eventError instanceof Error ? eventError.message : String(eventError),
taskPath: newPath,
});
// Event emission errors shouldn't break the operation
}
// Trigger webhooks for task update/completion
if (this.webhookNotifier) {
try {
// Check if this was a completion
const wasCompleted = this.plugin.statusManager.isCompletedStatus(
originalTask.status
);
const isCompleted = this.plugin.statusManager.isCompletedStatus(
updatedTask.status
);
if (!wasCompleted && isCompleted) {
// Task was completed
await this.webhookNotifier.triggerWebhook("task.completed", {
task: updatedTask,
});
} else {
// Regular update
await this.webhookNotifier.triggerWebhook("task.updated", {
task: updatedTask,
previous: originalTask,
});
}
} catch (error) {
console.warn("Failed to trigger webhook for task update:", error);
}
}
// Sync to Google Calendar if enabled (fire-and-forget to avoid blocking modal)
if (this.plugin.taskCalendarSyncService?.isEnabled()) {
const wasCompleted = this.plugin.statusManager.isCompletedStatus(
originalTask.status
);
const isCompleted = this.plugin.statusManager.isCompletedStatus(
updatedTask.status
);
const syncPromise =
!wasCompleted && isCompleted
? this.plugin.taskCalendarSyncService.completeTaskInCalendar(updatedTask)
: this.plugin.taskCalendarSyncService.updateTaskInCalendar(
updatedTask,
originalTask
);
syncPromise.catch((error) => {
console.warn("Failed to sync task update to Google Calendar:", error);
});
}
// Handle auto-archive if status changed
if (
this.autoArchiveService &&
updates.status !== undefined &&
updates.status !== originalTask.status
) {
try {
const statusConfig = this.plugin.statusManager.getStatusConfig(
updatedTask.status
);
if (statusConfig) {
if (statusConfig.autoArchive) {
// Schedule for auto-archive
await this.autoArchiveService.scheduleAutoArchive(
updatedTask,
statusConfig
);
} else {
// Cancel any pending auto-archive since new status doesn't have auto-archive
await this.autoArchiveService.cancelAutoArchive(updatedTask.path);
}
}
} catch (error) {
console.warn("Failed to handle auto-archive for status change:", error);
}
}
return updatedTask;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Error updating task:", {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
taskPath: originalTask.path,
updates,
});
throw new Error(`Failed to update task: ${errorMessage}`);
}
return this.taskUpdateService.updateTask(originalTask, updates);
}
async updateBlockingRelationships(

View file

@ -0,0 +1,262 @@
import { Notice, TFile, normalizePath, stringifyYaml } from "obsidian";
import {
EVENT_TASK_UPDATED,
IWebhookNotifier,
TaskCreationData,
TaskInfo,
} from "../../types";
import { FilenameContext, generateTaskFilename, generateUniqueFilename } from "../../utils/filenameGenerator";
import { addDTSTARTToRecurrenceRule, ensureFolderExists } from "../../utils/helpers";
import { getCurrentTimestamp } from "../../utils/dateUtils";
import { mergeTemplateFrontmatter } from "../../utils/templateProcessor";
import type TaskNotesPlugin from "../../main";
interface TemplateApplicationResult {
frontmatter: Record<string, unknown>;
body: string;
}
export interface TaskCreationServiceDependencies {
plugin: TaskNotesPlugin;
webhookNotifier?: IWebhookNotifier;
applyTaskCreationDefaults(taskData: TaskCreationData): Promise<TaskCreationData>;
applyTemplate(taskData: TaskCreationData): Promise<TemplateApplicationResult>;
processFolderTemplate(folderTemplate: string, taskData?: TaskCreationData, date?: Date): string;
sanitizeTitleForFilename(input: string): string;
sanitizeTitleForStorage(input: string): string;
}
export class TaskCreationService {
constructor(private deps: TaskCreationServiceDependencies) {}
setWebhookNotifier(webhookNotifier?: IWebhookNotifier): void {
this.deps.webhookNotifier = webhookNotifier;
}
async createTask(
taskData: TaskCreationData,
options: { applyDefaults?: boolean } = {}
): Promise<{ file: TFile; taskInfo: TaskInfo }> {
const { applyDefaults = true } = options;
const { plugin } = this.deps;
try {
if (applyDefaults) {
taskData = await this.deps.applyTaskCreationDefaults(taskData);
}
if (!taskData.title || !taskData.title.trim()) {
throw new Error("Title is required");
}
const title = plugin.settings.storeTitleInFilename
? this.deps.sanitizeTitleForFilename(taskData.title.trim())
: this.deps.sanitizeTitleForStorage(taskData.title.trim());
const priority = taskData.priority || plugin.settings.defaultTaskPriority;
const status = taskData.status || plugin.settings.defaultTaskStatus;
const dateCreated = taskData.dateCreated || getCurrentTimestamp();
const dateModified = taskData.dateModified || getCurrentTimestamp();
const contextsArray = taskData.contexts || [];
const projectsArray = taskData.projects || [];
let tagsArray = taskData.tags || [];
if (plugin.settings.taskIdentificationMethod === "tag") {
if (!tagsArray.includes(plugin.settings.taskTag)) {
tagsArray = [plugin.settings.taskTag, ...tagsArray];
}
}
const filenameContext: FilenameContext = {
title,
priority,
status,
date: new Date(),
dueDate: taskData.due,
scheduledDate: taskData.scheduled,
};
const baseFilename = generateTaskFilename(filenameContext, plugin.settings);
const folder = await this.resolveTargetFolder(taskData);
if (folder) {
await ensureFolderExists(plugin.app.vault, folder);
}
const uniqueFilename = await generateUniqueFilename(baseFilename, folder, plugin.app.vault);
const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`;
const completeTaskData: Partial<TaskInfo> = {
title,
status,
priority,
due: taskData.due || undefined,
scheduled: taskData.scheduled || undefined,
contexts: contextsArray.length > 0 ? contextsArray : undefined,
projects: projectsArray.length > 0 ? projectsArray : undefined,
timeEstimate:
taskData.timeEstimate && taskData.timeEstimate > 0 ? taskData.timeEstimate : undefined,
dateCreated,
dateModified,
recurrence: taskData.recurrence || undefined,
recurrence_anchor: taskData.recurrence_anchor || undefined,
reminders:
taskData.reminders && taskData.reminders.length > 0 ? taskData.reminders : undefined,
icsEventId: taskData.icsEventId || undefined,
};
if (
completeTaskData.recurrence &&
typeof completeTaskData.recurrence === "string" &&
!completeTaskData.recurrence.includes("DTSTART:")
) {
const tempTaskInfo: TaskInfo = {
...completeTaskData,
title,
status,
priority,
path: "",
archived: false,
};
const recurrenceWithDtstart = addDTSTARTToRecurrenceRule(tempTaskInfo);
if (recurrenceWithDtstart) {
completeTaskData.recurrence = recurrenceWithDtstart;
}
}
const shouldAddTaskTag = plugin.settings.taskIdentificationMethod === "tag";
const taskTagForFrontmatter = shouldAddTaskTag ? plugin.settings.taskTag : undefined;
const frontmatter = plugin.fieldMapper.mapToFrontmatter(
completeTaskData,
taskTagForFrontmatter,
plugin.settings.storeTitleInFilename
);
if (plugin.settings.taskIdentificationMethod === "property") {
const propName = plugin.settings.taskPropertyName;
const propValue = plugin.settings.taskPropertyValue;
if (propName && propValue) {
const lower = propValue.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : propValue;
frontmatter[propName] = coercedValue;
}
if (tagsArray.length > 0) {
frontmatter.tags = tagsArray;
}
} else {
frontmatter.tags = tagsArray;
}
const templateResult = await this.deps.applyTemplate(taskData);
const normalizedBody = templateResult.body
? templateResult.body.replace(/\r\n/g, "\n").trimEnd()
: taskData.details
? taskData.details.replace(/\r\n/g, "\n").trimEnd()
: "";
let finalFrontmatter = mergeTemplateFrontmatter(frontmatter, templateResult.frontmatter);
if (taskData.customFrontmatter) {
finalFrontmatter = { ...finalFrontmatter, ...taskData.customFrontmatter };
}
const yamlHeader = stringifyYaml(finalFrontmatter);
let content = `---\n${yamlHeader}---\n\n`;
if (normalizedBody.length > 0) {
content += `${normalizedBody}\n`;
}
const file = await plugin.app.vault.create(fullPath, content);
const taskInfo: TaskInfo = {
...completeTaskData,
...finalFrontmatter,
title: String(finalFrontmatter.title || completeTaskData.title || title),
status: String(finalFrontmatter.status || completeTaskData.status || status),
priority: String(finalFrontmatter.priority || completeTaskData.priority || priority),
path: file.path,
tags: tagsArray,
archived: false,
details: normalizedBody,
};
try {
if (plugin.cacheManager.waitForFreshTaskData) {
await plugin.cacheManager.waitForFreshTaskData(file);
}
plugin.cacheManager.updateTaskInfoInCache(file.path, taskInfo);
} catch (cacheError) {
console.error("Error updating cache for new task:", cacheError);
}
plugin.emitter.trigger(EVENT_TASK_UPDATED, {
path: file.path,
updatedTask: taskInfo,
});
if (this.deps.webhookNotifier) {
try {
await this.deps.webhookNotifier.triggerWebhook("task.created", { task: taskInfo });
} catch (error) {
console.warn("Failed to trigger webhook for task creation:", error);
}
}
if (
plugin.taskCalendarSyncService?.isEnabled() &&
plugin.settings.googleCalendarExport.syncOnTaskCreate
) {
plugin.taskCalendarSyncService.syncTaskToCalendar(taskInfo).catch((error) => {
console.warn("Failed to sync task to Google Calendar:", error);
});
}
return { file, taskInfo };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Error creating task:", {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
taskData,
});
throw new Error(`Failed to create task: ${errorMessage}`);
}
}
private async resolveTargetFolder(taskData: TaskCreationData): Promise<string> {
const { plugin } = this.deps;
let folder = "";
if (
taskData.creationContext === "inline-conversion" ||
taskData.creationContext === "modal-inline-creation"
) {
const inlineFolder = plugin.settings.inlineTaskConvertFolder || "";
if (inlineFolder.trim()) {
folder = inlineFolder;
if (
inlineFolder.includes("{{currentNotePath}}") ||
inlineFolder.includes("{{currentNoteTitle}}")
) {
const currentFile = plugin.app.workspace.getActiveFile();
if (inlineFolder.includes("{{currentNotePath}}")) {
const currentFolderPath = currentFile?.parent?.path || "";
folder = folder.replace(/\{\{currentNotePath\}\}/g, currentFolderPath);
}
if (inlineFolder.includes("{{currentNoteTitle}}")) {
const currentNoteTitle = currentFile?.basename || "";
folder = folder.replace(/\{\{currentNoteTitle\}\}/g, currentNoteTitle);
}
}
return this.deps.processFolderTemplate(folder, taskData);
}
const tasksFolder = plugin.settings.tasksFolder || "";
return this.deps.processFolderTemplate(tasksFolder, taskData);
}
const tasksFolder = plugin.settings.tasksFolder || "";
return this.deps.processFolderTemplate(tasksFolder, taskData);
}
}

View file

@ -0,0 +1,412 @@
import { TFile } from "obsidian";
import { AutoArchiveService } from "../AutoArchiveService";
import type TaskNotesPlugin from "../../main";
import { EVENT_TASK_UPDATED, IWebhookNotifier, TaskInfo } from "../../types";
import {
addDTSTARTToRecurrenceRule,
splitFrontmatterAndBody,
updateToNextScheduledOccurrence,
} from "../../utils/helpers";
import { generateUniqueFilename } from "../../utils/filenameGenerator";
import { getCurrentDateString, getCurrentTimestamp } from "../../utils/dateUtils";
export interface TaskUpdateServiceDependencies {
plugin: TaskNotesPlugin;
webhookNotifier?: IWebhookNotifier;
autoArchiveService?: AutoArchiveService;
updateCompletedDateInFrontmatter(
frontmatter: Record<string, unknown>,
newStatus: string,
isRecurring: boolean
): void;
}
export class TaskUpdateService {
constructor(private deps: TaskUpdateServiceDependencies) {}
setWebhookNotifier(webhookNotifier?: IWebhookNotifier): void {
this.deps.webhookNotifier = webhookNotifier;
}
setAutoArchiveService(autoArchiveService?: AutoArchiveService): void {
this.deps.autoArchiveService = autoArchiveService;
}
async updateTask(
originalTask: TaskInfo,
updates: Partial<TaskInfo> & { details?: string }
): Promise<TaskInfo> {
const { plugin } = this.deps;
try {
const file = plugin.app.vault.getAbstractFileByPath(originalTask.path);
if (!(file instanceof TFile)) {
throw new Error(`Cannot find task file: ${originalTask.path}`);
}
if (Array.isArray(updates.timeEntries)) {
updates.timeEntries = updates.timeEntries.map((entry) => {
const sanitizedEntry = { ...entry };
delete sanitizedEntry.duration;
return sanitizedEntry;
});
}
const isRenameNeeded =
plugin.settings.storeTitleInFilename &&
updates.title &&
updates.title !== originalTask.title;
let newPath = originalTask.path;
if (isRenameNeeded) {
const parentPath = file.parent ? file.parent.path : "";
const newFilename = await generateUniqueFilename(
updates.title!,
parentPath,
plugin.app.vault
);
newPath = parentPath ? `${parentPath}/${newFilename}.md` : `${newFilename}.md`;
}
const recurrenceUpdates = this.getRecurrenceUpdates(originalTask, updates);
let normalizedDetails: string | null = null;
if (Object.prototype.hasOwnProperty.call(updates, "details")) {
normalizedDetails =
typeof updates.details === "string" ? updates.details.replace(/\r\n/g, "\n") : "";
}
await plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
const completeTaskData: Partial<TaskInfo> = {
...originalTask,
...updates,
...recurrenceUpdates,
dateModified: getCurrentTimestamp(),
};
const mappedFrontmatter = plugin.fieldMapper.mapToFrontmatter(
completeTaskData,
plugin.settings.taskIdentificationMethod === "tag"
? plugin.settings.taskTag
: undefined,
plugin.settings.storeTitleInFilename
);
Object.entries(mappedFrontmatter).forEach(([key, value]) => {
if (value !== undefined) {
frontmatter[key] = value;
}
});
if (updates.status !== undefined) {
this.deps.updateCompletedDateInFrontmatter(
frontmatter,
updates.status,
!!originalTask.recurrence
);
}
if (plugin.settings.taskIdentificationMethod === "property") {
const propName = plugin.settings.taskPropertyName;
const propValue = plugin.settings.taskPropertyValue;
if (propName && propValue) {
const lower = propValue.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : propValue;
frontmatter[propName] = coercedValue;
}
}
const customFrontmatter = (updates as { customFrontmatter?: Record<string, unknown> })
.customFrontmatter;
if (customFrontmatter) {
Object.entries(customFrontmatter).forEach(([key, value]) => {
if (value === null) {
delete frontmatter[key];
} else {
frontmatter[key] = value;
}
});
}
this.removeUnsetMappedFields(frontmatter, updates);
if (isRenameNeeded) {
delete frontmatter[plugin.fieldMapper.toUserField("title")];
}
if (Object.prototype.hasOwnProperty.call(updates, "tags")) {
const tagsToSet = Array.isArray(updates.tags) ? [...updates.tags] : [];
if (tagsToSet.length > 0) {
frontmatter.tags = tagsToSet;
} else {
delete frontmatter.tags;
}
}
});
if (isRenameNeeded) {
await plugin.app.fileManager.renameFile(file, newPath);
}
if (normalizedDetails !== null) {
const targetFile = plugin.app.vault.getAbstractFileByPath(newPath);
if (targetFile instanceof TFile) {
const currentContent = await plugin.app.vault.read(targetFile);
const { frontmatter: frontmatterText } = splitFrontmatterAndBody(currentContent);
const frontmatterBlock =
frontmatterText !== null ? `---\n${frontmatterText}\n---\n\n` : "";
const bodyContent = normalizedDetails.trimEnd();
const finalBody = bodyContent.length > 0 ? `${bodyContent}\n` : "";
await plugin.app.vault.modify(targetFile, `${frontmatterBlock}${finalBody}`);
}
}
const updatedTask: TaskInfo = {
...originalTask,
...updates,
...recurrenceUpdates,
path: newPath,
dateModified: getCurrentTimestamp(),
};
if (normalizedDetails !== null) {
updatedTask.details = normalizedDetails;
}
if (updates.status !== undefined && !originalTask.recurrence) {
if (plugin.statusManager.isCompletedStatus(updates.status)) {
if (!originalTask.completedDate) {
updatedTask.completedDate = getCurrentDateString();
}
} else {
updatedTask.completedDate = undefined;
}
}
if (isRenameNeeded) {
plugin.cacheManager.clearCacheEntry(originalTask.path);
}
try {
const finalFile = plugin.app.vault.getAbstractFileByPath(newPath);
if (finalFile instanceof TFile && plugin.cacheManager.waitForFreshTaskData) {
const keyChanges: Partial<TaskInfo> = {};
if (updates.title !== undefined) keyChanges.title = updates.title;
if (updates.status !== undefined) keyChanges.status = updates.status;
if (updates.priority !== undefined) keyChanges.priority = updates.priority;
if (Object.keys(keyChanges).length > 0) {
await plugin.cacheManager.waitForFreshTaskData(finalFile);
}
}
plugin.cacheManager.updateTaskInfoInCache(newPath, updatedTask);
} catch (cacheError) {
console.error("Error updating task cache:", {
error: cacheError instanceof Error ? cacheError.message : String(cacheError),
taskPath: newPath,
});
}
try {
plugin.emitter.trigger(EVENT_TASK_UPDATED, {
path: newPath,
originalTask,
updatedTask,
});
} catch (eventError) {
console.error("Error emitting task update event:", {
error: eventError instanceof Error ? eventError.message : String(eventError),
taskPath: newPath,
});
}
if (this.deps.webhookNotifier) {
try {
const wasCompleted = plugin.statusManager.isCompletedStatus(originalTask.status);
const isCompleted = plugin.statusManager.isCompletedStatus(updatedTask.status);
if (!wasCompleted && isCompleted) {
await this.deps.webhookNotifier.triggerWebhook("task.completed", {
task: updatedTask,
});
} else {
await this.deps.webhookNotifier.triggerWebhook("task.updated", {
task: updatedTask,
previous: originalTask,
});
}
} catch (error) {
console.warn("Failed to trigger webhook for task update:", error);
}
}
if (plugin.taskCalendarSyncService?.isEnabled()) {
const wasCompleted = plugin.statusManager.isCompletedStatus(originalTask.status);
const isCompleted = plugin.statusManager.isCompletedStatus(updatedTask.status);
const syncPromise =
!wasCompleted && isCompleted
? plugin.taskCalendarSyncService.completeTaskInCalendar(updatedTask)
: plugin.taskCalendarSyncService.updateTaskInCalendar(updatedTask, originalTask);
syncPromise.catch((error) => {
console.warn("Failed to sync task update to Google Calendar:", error);
});
}
await this.handleAutoArchive(originalTask, updatedTask, updates.status);
return updatedTask;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Error updating task:", {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
taskPath: originalTask.path,
updates,
});
throw new Error(`Failed to update task: ${errorMessage}`);
}
}
private getRecurrenceUpdates(
originalTask: TaskInfo,
updates: Partial<TaskInfo>
): Partial<TaskInfo> {
const { plugin } = this.deps;
const recurrenceUpdates: Partial<TaskInfo> = {};
if (updates.recurrence !== undefined && updates.recurrence !== originalTask.recurrence) {
const tempTask: TaskInfo = { ...originalTask, ...updates };
const nextDates = updateToNextScheduledOccurrence(
tempTask,
plugin.settings.maintainDueDateOffsetInRecurring
);
if (nextDates.scheduled) {
recurrenceUpdates.scheduled = nextDates.scheduled;
}
if (nextDates.due) {
recurrenceUpdates.due = nextDates.due;
}
if (
typeof updates.recurrence === "string" &&
updates.recurrence &&
!updates.recurrence.includes("DTSTART:")
) {
const tempTaskWithRecurrence: TaskInfo = {
...originalTask,
...updates,
...recurrenceUpdates,
};
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTaskWithRecurrence);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
} else if (updates.recurrence !== undefined && !originalTask.recurrence && updates.recurrence) {
if (
typeof updates.recurrence === "string" &&
!updates.recurrence.includes("DTSTART:")
) {
const tempTask: TaskInfo = { ...originalTask, ...updates };
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTask);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
}
if (
updates.scheduled !== undefined &&
updates.scheduled !== originalTask.scheduled &&
originalTask.recurrence &&
typeof originalTask.recurrence === "string" &&
!originalTask.recurrence.includes("DTSTART:")
) {
const tempTask: TaskInfo = { ...originalTask, ...updates };
const updatedRecurrence = addDTSTARTToRecurrenceRule(tempTask);
if (updatedRecurrence) {
recurrenceUpdates.recurrence = updatedRecurrence;
}
}
return recurrenceUpdates;
}
private removeUnsetMappedFields(
frontmatter: Record<string, unknown>,
updates: Partial<TaskInfo>
): void {
const { plugin } = this.deps;
if (Object.prototype.hasOwnProperty.call(updates, "due") && updates.due === undefined) {
delete frontmatter[plugin.fieldMapper.toUserField("due")];
}
if (
Object.prototype.hasOwnProperty.call(updates, "scheduled") &&
updates.scheduled === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("scheduled")];
}
if (
Object.prototype.hasOwnProperty.call(updates, "contexts") &&
updates.contexts === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("contexts")];
}
if (Object.prototype.hasOwnProperty.call(updates, "projects")) {
const projectsField = plugin.fieldMapper.toUserField("projects");
const projectsToSet = Array.isArray(updates.projects) ? updates.projects : [];
if (projectsToSet.length > 0) {
frontmatter[projectsField] = projectsToSet;
} else {
delete frontmatter[projectsField];
}
}
if (
Object.prototype.hasOwnProperty.call(updates, "timeEstimate") &&
updates.timeEstimate === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("timeEstimate")];
}
if (
Object.prototype.hasOwnProperty.call(updates, "completedDate") &&
updates.completedDate === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("completedDate")];
}
if (
Object.prototype.hasOwnProperty.call(updates, "recurrence") &&
updates.recurrence === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("recurrence")];
}
if (
Object.prototype.hasOwnProperty.call(updates, "blockedBy") &&
updates.blockedBy === undefined
) {
delete frontmatter[plugin.fieldMapper.toUserField("blockedBy")];
}
}
private async handleAutoArchive(
originalTask: TaskInfo,
updatedTask: TaskInfo,
newStatus: string | undefined
): Promise<void> {
if (!this.deps.autoArchiveService || newStatus === undefined || newStatus === originalTask.status) {
return;
}
try {
const statusConfig = this.deps.plugin.statusManager.getStatusConfig(updatedTask.status);
if (!statusConfig) {
return;
}
if (statusConfig.autoArchive) {
await this.deps.autoArchiveService.scheduleAutoArchive(updatedTask, statusConfig);
} else {
await this.deps.autoArchiveService.cancelAutoArchive(updatedTask.path);
}
} catch (error) {
console.warn("Failed to handle auto-archive for status change:", error);
}
}
}