mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Fixes two related issues in the Edit Task modal: 1. completedDate not being added when marking tasks as done 2. Empty contexts and projects arrays being added to frontmatter Changes: - Extract completedDate logic into reusable helper method - Add completedDate handling to updateTask method - Fix empty projects array handling in TaskEditModal The Edit Task modal now behaves consistently with other completion methods (Agenda view, widget context menu, quick actions).
1871 lines
62 KiB
TypeScript
1871 lines
62 KiB
TypeScript
import {
|
|
EVENT_TASK_DELETED,
|
|
EVENT_TASK_UPDATED,
|
|
TaskCreationData,
|
|
TaskDependency,
|
|
TaskInfo,
|
|
TimeEntry,
|
|
IWebhookNotifier,
|
|
} from "../types";
|
|
import { AutoArchiveService } from "./AutoArchiveService";
|
|
import {
|
|
FilenameContext,
|
|
generateTaskFilename,
|
|
generateUniqueFilename,
|
|
} from "../utils/filenameGenerator";
|
|
import { Notice, TFile, normalizePath, stringifyYaml } from "obsidian";
|
|
import {
|
|
TemplateData,
|
|
mergeTemplateFrontmatter,
|
|
processTemplate,
|
|
} from "../utils/templateProcessor";
|
|
import {
|
|
addDTSTARTToRecurrenceRule,
|
|
ensureFolderExists,
|
|
updateToNextScheduledOccurrence,
|
|
splitFrontmatterAndBody,
|
|
} from "../utils/helpers";
|
|
import {
|
|
DEFAULT_DEPENDENCY_RELTYPE,
|
|
formatDependencyLink,
|
|
normalizeDependencyEntry,
|
|
resolveDependencyEntry,
|
|
} from "../utils/dependencyUtils";
|
|
import {
|
|
formatDateForStorage,
|
|
getCurrentDateString,
|
|
getCurrentTimestamp,
|
|
getTodayLocal,
|
|
createUTCDateFromLocalCalendarDate,
|
|
} from "../utils/dateUtils";
|
|
import { format } from "date-fns";
|
|
import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplateProcessor";
|
|
|
|
import TaskNotesPlugin from "../main";
|
|
import { TranslationKey } from "../i18n";
|
|
|
|
export class TaskService {
|
|
private webhookNotifier?: IWebhookNotifier;
|
|
private autoArchiveService?: AutoArchiveService;
|
|
|
|
constructor(private plugin: TaskNotesPlugin) {}
|
|
|
|
private translate(key: TranslationKey, variables?: Record<string, any>): string {
|
|
return this.plugin.i18n.translate(key, variables);
|
|
}
|
|
|
|
/**
|
|
* Sanitize title by removing problematic characters that could cause issues
|
|
* This ensures consistency between what's stored and what's returned
|
|
*/
|
|
private sanitizeTitle(input: string): string {
|
|
if (!input || typeof input !== "string") {
|
|
return "untitled";
|
|
}
|
|
|
|
try {
|
|
// Remove or replace problematic characters
|
|
let sanitized = input
|
|
.trim()
|
|
// Replace multiple spaces with single space
|
|
.replace(/\s+/g, " ")
|
|
// Remove characters that are problematic in filenames and content
|
|
.replace(/[<>:"/\\|?*#[\]]/g, "")
|
|
// Remove control characters separately
|
|
.replace(/./g, (char) => {
|
|
const code = char.charCodeAt(0);
|
|
return code <= 31 || (code >= 127 && code <= 159) ? "" : char;
|
|
})
|
|
// Remove leading/trailing dots
|
|
.replace(/^\.+|\.+$/g, "")
|
|
// Final trim in case we removed characters at the edges
|
|
.trim();
|
|
|
|
// Additional validation
|
|
if (!sanitized || sanitized.length === 0) {
|
|
sanitized = "untitled";
|
|
}
|
|
|
|
return sanitized;
|
|
} catch (error) {
|
|
console.error("Error sanitizing title:", error);
|
|
return "untitled";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set webhook notifier for triggering webhook events
|
|
* Called after HTTPAPIService is initialized to avoid circular dependencies
|
|
*/
|
|
setWebhookNotifier(notifier: IWebhookNotifier): void {
|
|
this.webhookNotifier = notifier;
|
|
}
|
|
|
|
/**
|
|
* Set auto-archive service for handling automatic archiving
|
|
*/
|
|
setAutoArchiveService(service: AutoArchiveService): void {
|
|
this.autoArchiveService = service;
|
|
}
|
|
|
|
/**
|
|
* Process a folder path template with task and date variables
|
|
*
|
|
* This method enables dynamic folder creation by replacing template variables
|
|
* with actual values from the task data and current date.
|
|
*
|
|
* Supported task variables:
|
|
* - {{context}} - First context from the task's contexts array
|
|
* - {{project}} - First project from the task's projects array
|
|
* - {{contexts}} - All contexts joined by `/`
|
|
* - {{projects}} - All projects joined by `/`
|
|
* - {{priority}} - Task priority (e.g., "high", "medium", "low")
|
|
* - {{status}} - Task status (e.g., "todo", "in-progress", "done")
|
|
* - {{title}} - Task title (sanitized for folder names)
|
|
*
|
|
* Supported date variables:
|
|
* - {{year}} - Current year (e.g., "2025")
|
|
* - {{month}} - Current month with leading zero (e.g., "08")
|
|
* - {{day}} - Current day with leading zero (e.g., "15")
|
|
* - {{date}} - Full current date (e.g., "2025-08-15")
|
|
*
|
|
* @param folderTemplate - The template string with variables to process
|
|
* @param taskData - Optional task data for variable substitution
|
|
* @param date - Date to use for date variables (defaults to current date)
|
|
* @returns Processed folder path with variables replaced
|
|
*
|
|
* @example
|
|
* processFolderTemplate("Tasks/{{year}}/{{month}}", taskData)
|
|
* // Returns: "Tasks/2025/08"
|
|
*
|
|
* @example
|
|
* processFolderTemplate("{{project}}/{{priority}}", taskData)
|
|
* // Returns: "ProjectName/high"
|
|
*/
|
|
private processFolderTemplate(
|
|
folderTemplate: string,
|
|
taskData?: TaskCreationData,
|
|
date: Date = new Date()
|
|
): string {
|
|
// Convert TaskCreationData to TaskTemplateData
|
|
const templateData: TaskTemplateData | undefined = taskData
|
|
? {
|
|
title: taskData.title,
|
|
priority: taskData.priority,
|
|
status: taskData.status,
|
|
contexts: taskData.contexts,
|
|
projects: taskData.projects,
|
|
due: taskData.due,
|
|
scheduled: taskData.scheduled,
|
|
}
|
|
: undefined;
|
|
|
|
// Use the shared folder template processor utility
|
|
return processFolderTemplate(folderTemplate, {
|
|
date,
|
|
taskData: templateData,
|
|
extractProjectBasename: (project) => this.extractProjectBasename(project),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new task file with all the necessary setup
|
|
* This is the central method for task creation used by all components
|
|
*/
|
|
async createTask(taskData: TaskCreationData): Promise<{ file: TFile; taskInfo: TaskInfo }> {
|
|
try {
|
|
// Validate required fields
|
|
if (!taskData.title || !taskData.title.trim()) {
|
|
throw new Error("Title is required");
|
|
}
|
|
|
|
// Apply defaults for missing fields and sanitize title for consistent behavior
|
|
const title = this.sanitizeTitle(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") {
|
|
// For inline conversion, 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,
|
|
reminders:
|
|
taskData.reminders && taskData.reminders.length > 0
|
|
? taskData.reminders
|
|
: undefined,
|
|
icsEventId: taskData.icsEventId || undefined,
|
|
};
|
|
|
|
// Use field mapper to convert to frontmatter with proper field mapping
|
|
const frontmatter = this.plugin.fieldMapper.mapToFrontmatter(
|
|
completeTaskData,
|
|
this.plugin.settings.taskTag,
|
|
this.plugin.settings.storeTitleInFilename
|
|
);
|
|
|
|
// 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;
|
|
}
|
|
// Remove task tag from tags array if using property identification
|
|
const filteredTags = tagsArray.filter(
|
|
(tag: string) => tag !== this.plugin.settings.taskTag
|
|
);
|
|
if (filteredTags.length > 0) {
|
|
frontmatter.tags = filteredTags;
|
|
}
|
|
} 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, {
|
|
title: taskInfo.title,
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply template to task (both frontmatter and body) if enabled in settings
|
|
*/
|
|
private async applyTemplate(
|
|
taskData: TaskCreationData
|
|
): Promise<{ frontmatter: Record<string, any>; body: string }> {
|
|
const defaults = this.plugin.settings.taskCreationDefaults;
|
|
|
|
// Check if body template is enabled and configured
|
|
if (!defaults.useBodyTemplate || !defaults.bodyTemplate?.trim()) {
|
|
// No template configured, return empty frontmatter and details as body
|
|
return {
|
|
frontmatter: {},
|
|
body: taskData.details?.trim() || "",
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Normalize the template path and ensure it has .md extension
|
|
let templatePath = normalizePath(defaults.bodyTemplate.trim());
|
|
if (!templatePath.endsWith(".md")) {
|
|
templatePath += ".md";
|
|
}
|
|
|
|
// Try to load the template file
|
|
const templateFile = this.plugin.app.vault.getAbstractFileByPath(templatePath);
|
|
if (templateFile instanceof TFile) {
|
|
const templateContent = await this.plugin.app.vault.read(templateFile);
|
|
|
|
// Prepare task data for template variables (with all final values)
|
|
const templateTaskData: TemplateData = {
|
|
title: taskData.title || "",
|
|
priority: taskData.priority || "",
|
|
status: taskData.status || "",
|
|
contexts: Array.isArray(taskData.contexts) ? taskData.contexts : [],
|
|
tags: Array.isArray(taskData.tags) ? taskData.tags : [],
|
|
timeEstimate: taskData.timeEstimate || 0,
|
|
dueDate: taskData.due || "",
|
|
scheduledDate: taskData.scheduled || "",
|
|
details: taskData.details || "",
|
|
parentNote: taskData.parentNote || "",
|
|
};
|
|
|
|
// Process the complete template (frontmatter + body)
|
|
return processTemplate(templateContent, templateTaskData);
|
|
} else {
|
|
// Template file not found, log error and return details as-is
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`Task body template not found: ${templatePath}`);
|
|
new Notice(
|
|
this.translate("services.task.notices.templateNotFound", { path: templatePath })
|
|
);
|
|
return {
|
|
frontmatter: {},
|
|
body: taskData.details?.trim() || "",
|
|
};
|
|
}
|
|
} catch (error) {
|
|
// Error reading template, log error and return details as-is
|
|
console.error("Error reading task body template:", error);
|
|
new Notice(
|
|
this.translate("services.task.notices.templateReadError", {
|
|
template: defaults.bodyTemplate,
|
|
})
|
|
);
|
|
return {
|
|
frontmatter: {},
|
|
body: taskData.details?.trim() || "",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle the status of a task between completed and open
|
|
*/
|
|
async toggleStatus(task: TaskInfo): Promise<TaskInfo> {
|
|
try {
|
|
// Determine new status
|
|
const isCurrentlyCompleted = this.plugin.statusManager.isCompletedStatus(task.status);
|
|
const newStatus = isCurrentlyCompleted
|
|
? this.plugin.settings.defaultTaskStatus // Revert to default open status
|
|
: this.plugin.statusManager.getCompletedStatuses()[0] || "done"; // Set to first completed status
|
|
|
|
return await this.updateProperty(task, "status", newStatus);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error("Error toggling task status:", {
|
|
error: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
taskPath: task.path,
|
|
currentStatus: task.status,
|
|
});
|
|
|
|
throw new Error(`Failed to toggle task status: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a single property of a task following the deterministic data flow pattern
|
|
*/
|
|
async updateProperty(
|
|
task: TaskInfo,
|
|
property: keyof TaskInfo,
|
|
value: any,
|
|
options: { silent?: boolean } = {}
|
|
): Promise<TaskInfo> {
|
|
try {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
// Get fresh task data to prevent overwrites
|
|
const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task;
|
|
|
|
// Step 1: Construct new state in memory using fresh data
|
|
const updatedTask = { ...freshTask } as Record<string, any>;
|
|
updatedTask[property] = value;
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
// Handle derivative changes for status updates
|
|
if (property === "status" && !freshTask.recurrence) {
|
|
if (this.plugin.statusManager.isCompletedStatus(value)) {
|
|
updatedTask.completedDate = getCurrentDateString();
|
|
} else {
|
|
updatedTask.completedDate = undefined;
|
|
}
|
|
}
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
// Use field mapper to get the correct frontmatter property name
|
|
const fieldName = this.plugin.fieldMapper.toUserField(
|
|
property as keyof import("../types").FieldMapping
|
|
);
|
|
|
|
if (property === "status") {
|
|
// Coerce boolean-like status strings to actual booleans for compatibility with Obsidian checkbox properties
|
|
const lower = String(value).toLowerCase();
|
|
const coercedValue =
|
|
lower === "true" || lower === "false" ? lower === "true" : value;
|
|
frontmatter[fieldName] = coercedValue;
|
|
|
|
// Update completed date when marking as complete (non-recurring tasks only)
|
|
// FIX: Use freshTask instead of stale task to check recurrence
|
|
this.updateCompletedDateInFrontmatter(frontmatter, value, !!freshTask.recurrence);
|
|
} else if ((property === "due" || property === "scheduled") && !value) {
|
|
// Remove empty due/scheduled dates
|
|
delete frontmatter[fieldName];
|
|
} else {
|
|
frontmatter[fieldName] = value;
|
|
}
|
|
|
|
// Always update the modification timestamp using field mapper
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated data
|
|
if (this.plugin.cacheManager.waitForFreshTaskData) {
|
|
await this.plugin.cacheManager.waitForFreshTaskData(file, {
|
|
[property]: value,
|
|
});
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(
|
|
task.path,
|
|
updatedTask as TaskInfo
|
|
);
|
|
} 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: task.path,
|
|
});
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
try {
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: task.path,
|
|
originalTask: task,
|
|
updatedTask: updatedTask as TaskInfo,
|
|
});
|
|
|
|
// Step 5: If status changed, trigger UI updates for dependent tasks
|
|
if (property === "status") {
|
|
const wasCompleted = this.plugin.statusManager.isCompletedStatus(task.status);
|
|
const isNowCompleted = this.plugin.statusManager.isCompletedStatus(value);
|
|
|
|
if (wasCompleted !== isNowCompleted) {
|
|
// Get tasks that this task blocks (they need UI updates for their blocked state)
|
|
const dependentTaskPaths = this.plugin.cacheManager.getBlockedTaskPaths(
|
|
task.path
|
|
);
|
|
|
|
// Trigger updates for each dependent task so their UI can refresh
|
|
for (const dependentPath of dependentTaskPaths) {
|
|
try {
|
|
const dependentTask =
|
|
await this.plugin.cacheManager.getTaskInfo(dependentPath);
|
|
if (dependentTask) {
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: dependentPath,
|
|
originalTask: dependentTask,
|
|
updatedTask: dependentTask, // The dependent task itself didn't change, just needs UI refresh
|
|
});
|
|
}
|
|
} catch (dependentError) {
|
|
console.error(
|
|
`Error triggering update for dependent task ${dependentPath}:`,
|
|
dependentError
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (eventError) {
|
|
// eslint-disable-next-line no-console
|
|
console.error("Error emitting task update event:", {
|
|
error: eventError instanceof Error ? eventError.message : String(eventError),
|
|
taskPath: task.path,
|
|
});
|
|
// Event emission errors shouldn't break the operation
|
|
}
|
|
|
|
// Trigger webhooks for property updates
|
|
if (this.webhookNotifier) {
|
|
try {
|
|
// Check if this was a completion
|
|
const wasCompleted = this.plugin.statusManager.isCompletedStatus(task.status);
|
|
const isCompleted =
|
|
property === "status" && this.plugin.statusManager.isCompletedStatus(value);
|
|
|
|
if (property === "status" && !wasCompleted && isCompleted) {
|
|
// Task was completed
|
|
await this.webhookNotifier.triggerWebhook("task.completed", {
|
|
task: updatedTask as TaskInfo,
|
|
});
|
|
} else {
|
|
// Regular property update
|
|
await this.webhookNotifier.triggerWebhook("task.updated", {
|
|
task: updatedTask as TaskInfo,
|
|
previous: task,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to trigger webhook for property update:", error);
|
|
}
|
|
}
|
|
|
|
// Handle auto-archive if status property changed
|
|
if (this.autoArchiveService && property === "status" && value !== task.status) {
|
|
try {
|
|
const statusConfig = this.plugin.statusManager.getStatusConfig(value as string);
|
|
if (statusConfig) {
|
|
if (statusConfig.autoArchive) {
|
|
// Schedule for auto-archive
|
|
await this.autoArchiveService.scheduleAutoArchive(
|
|
updatedTask as TaskInfo,
|
|
statusConfig
|
|
);
|
|
} else {
|
|
// Cancel any pending auto-archive since new status doesn't have auto-archive
|
|
await this.autoArchiveService.cancelAutoArchive(
|
|
(updatedTask as TaskInfo).path
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
"Failed to handle auto-archive for status property change:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 5: Return authoritative data
|
|
return updatedTask as TaskInfo;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
// eslint-disable-next-line no-console
|
|
console.error("Error updating task property:", {
|
|
error: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
taskPath: task.path,
|
|
property: String(property),
|
|
value,
|
|
});
|
|
|
|
throw new Error(`Failed to update task property: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle the archive status of a task
|
|
*/
|
|
async toggleArchive(task: TaskInfo): Promise<TaskInfo> {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
const archiveTag = this.plugin.fieldMapper.getMapping().archiveTag;
|
|
const isCurrentlyArchived = task.archived;
|
|
|
|
// Step 1: Construct new state in memory
|
|
const updatedTask = { ...task };
|
|
updatedTask.archived = !isCurrentlyArchived;
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
// Update tags array to include/exclude archive tag
|
|
if (!updatedTask.tags) {
|
|
updatedTask.tags = [];
|
|
}
|
|
|
|
if (isCurrentlyArchived) {
|
|
// Remove archive tag
|
|
updatedTask.tags = updatedTask.tags.filter((tag) => tag !== archiveTag);
|
|
} else {
|
|
// Add archive tag if not present
|
|
if (!updatedTask.tags.includes(archiveTag)) {
|
|
updatedTask.tags = [...updatedTask.tags, archiveTag];
|
|
}
|
|
}
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
|
|
// Toggle archived property (note: archived is handled via tags, not as a separate field)
|
|
if (isCurrentlyArchived) {
|
|
// Remove archive tag from tags array if present
|
|
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
|
frontmatter.tags = frontmatter.tags.filter((tag: string) => tag !== archiveTag);
|
|
if (frontmatter.tags.length === 0) {
|
|
delete frontmatter.tags;
|
|
}
|
|
}
|
|
} else {
|
|
// Add archive tag to tags array
|
|
if (!frontmatter.tags) {
|
|
frontmatter.tags = [];
|
|
} else if (!Array.isArray(frontmatter.tags)) {
|
|
frontmatter.tags = [frontmatter.tags];
|
|
}
|
|
|
|
if (!frontmatter.tags.includes(archiveTag)) {
|
|
frontmatter.tags.push(archiveTag);
|
|
}
|
|
}
|
|
|
|
// Always update the modification timestamp using field mapper
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 2.5: Move file based on archive operation and settings
|
|
let movedFile = file;
|
|
if (this.plugin.settings.moveArchivedTasks) {
|
|
try {
|
|
if (!isCurrentlyArchived && this.plugin.settings.archiveFolder?.trim()) {
|
|
// Archiving: Move to archive folder
|
|
const archiveFolderTemplate = this.plugin.settings.archiveFolder.trim();
|
|
// Process template variables in archive folder path
|
|
const archiveFolder = this.processFolderTemplate(archiveFolderTemplate, {
|
|
title: updatedTask.title || "",
|
|
priority: updatedTask.priority,
|
|
status: updatedTask.status,
|
|
contexts: updatedTask.contexts,
|
|
projects: updatedTask.projects,
|
|
});
|
|
|
|
// Ensure archive folder exists
|
|
await ensureFolderExists(this.plugin.app.vault, archiveFolder);
|
|
|
|
// Construct new path in archive folder
|
|
const newPath = `${archiveFolder}/${file.name}`;
|
|
|
|
// Check if file already exists at destination
|
|
const existingFile = this.plugin.app.vault.getAbstractFileByPath(newPath);
|
|
if (existingFile) {
|
|
throw new Error(
|
|
`A file named "${file.name}" already exists in the archive folder "${archiveFolder}". Cannot move task to avoid overwriting existing file.`
|
|
);
|
|
}
|
|
|
|
// Move the file
|
|
await this.plugin.app.fileManager.renameFile(file, newPath);
|
|
|
|
// Update the file reference and task path
|
|
movedFile = this.plugin.app.vault.getAbstractFileByPath(newPath) as TFile;
|
|
updatedTask.path = newPath;
|
|
|
|
// Clear old cache entry and update path in task object
|
|
this.plugin.cacheManager.clearCacheEntry(task.path);
|
|
} else if (isCurrentlyArchived && this.plugin.settings.tasksFolder?.trim()) {
|
|
// Unarchiving: Move to default tasks folder
|
|
const tasksFolder = this.plugin.settings.tasksFolder.trim();
|
|
|
|
// Ensure tasks folder exists
|
|
await ensureFolderExists(this.plugin.app.vault, tasksFolder);
|
|
|
|
// Construct new path in tasks folder
|
|
const newPath = `${tasksFolder}/${file.name}`;
|
|
|
|
// Check if file already exists at destination
|
|
const existingFile = this.plugin.app.vault.getAbstractFileByPath(newPath);
|
|
if (existingFile) {
|
|
throw new Error(
|
|
`A file named "${file.name}" already exists in the tasks folder "${tasksFolder}". Cannot move task to avoid overwriting existing file.`
|
|
);
|
|
}
|
|
|
|
// Move the file
|
|
await this.plugin.app.fileManager.renameFile(file, newPath);
|
|
|
|
// Update the file reference and task path
|
|
movedFile = this.plugin.app.vault.getAbstractFileByPath(newPath) as TFile;
|
|
updatedTask.path = newPath;
|
|
|
|
// Clear old cache entry and update path in task object
|
|
this.plugin.cacheManager.clearCacheEntry(task.path);
|
|
}
|
|
} catch (moveError) {
|
|
// If moving fails, show error but don't break the archive operation
|
|
const errorMessage =
|
|
moveError instanceof Error ? moveError.message : String(moveError);
|
|
const operation = isCurrentlyArchived ? "unarchiving" : "archiving";
|
|
console.error(`Error moving ${operation} task:`, errorMessage);
|
|
new Notice(
|
|
this.translate("services.task.notices.moveTaskFailed", {
|
|
operation,
|
|
error: errorMessage,
|
|
})
|
|
);
|
|
// Continue with archive operation without moving the file
|
|
}
|
|
}
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated data
|
|
if (movedFile instanceof TFile && this.plugin.cacheManager.waitForFreshTaskData) {
|
|
await this.plugin.cacheManager.waitForFreshTaskData(movedFile, {
|
|
archived: updatedTask.archived,
|
|
});
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(updatedTask.path, updatedTask);
|
|
} catch (cacheError) {
|
|
console.error("Error updating cache for archived task:", cacheError);
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: updatedTask.path,
|
|
originalTask: task,
|
|
updatedTask: updatedTask,
|
|
});
|
|
|
|
// Trigger webhook for archive/unarchive
|
|
if (this.webhookNotifier) {
|
|
try {
|
|
if (updatedTask.archived) {
|
|
await this.webhookNotifier.triggerWebhook("task.archived", {
|
|
task: updatedTask,
|
|
});
|
|
} else {
|
|
await this.webhookNotifier.triggerWebhook("task.unarchived", {
|
|
task: updatedTask,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to trigger webhook for task archive/unarchive:", error);
|
|
}
|
|
}
|
|
|
|
// Step 5: Return authoritative data
|
|
return updatedTask;
|
|
}
|
|
|
|
/**
|
|
* Start time tracking for a task
|
|
*/
|
|
async startTimeTracking(task: TaskInfo): Promise<TaskInfo> {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
// Check if already tracking
|
|
const activeSession = this.plugin.getActiveTimeSession(task);
|
|
if (activeSession) {
|
|
throw new Error("Time tracking is already active for this task");
|
|
}
|
|
|
|
// Step 1: Construct new state in memory
|
|
const updatedTask = { ...task };
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
if (!updatedTask.timeEntries) {
|
|
updatedTask.timeEntries = [];
|
|
}
|
|
|
|
const newEntry: TimeEntry = {
|
|
startTime: getCurrentTimestamp(),
|
|
description: "Work session",
|
|
};
|
|
updatedTask.timeEntries = [...updatedTask.timeEntries, newEntry];
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries");
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
|
|
if (!frontmatter[timeEntriesField]) {
|
|
frontmatter[timeEntriesField] = [];
|
|
}
|
|
|
|
// Add new time entry with start time
|
|
frontmatter[timeEntriesField].push(newEntry);
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated time entries
|
|
if (this.plugin.cacheManager.waitForFreshTaskData) {
|
|
await this.plugin.cacheManager.waitForFreshTaskData(file, {
|
|
timeEntries: updatedTask.timeEntries,
|
|
});
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(task.path, updatedTask);
|
|
} catch (cacheError) {
|
|
console.error("Error updating cache for time tracking start:", cacheError);
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: task.path,
|
|
originalTask: task,
|
|
updatedTask: updatedTask,
|
|
});
|
|
|
|
// Trigger webhook for time tracking start
|
|
if (this.webhookNotifier) {
|
|
try {
|
|
await this.webhookNotifier.triggerWebhook("time.started", {
|
|
task: updatedTask,
|
|
session: updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1],
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to trigger webhook for time tracking start:", error);
|
|
}
|
|
}
|
|
|
|
// Step 5: Return authoritative data
|
|
return updatedTask;
|
|
}
|
|
|
|
/**
|
|
* Stop time tracking for a task
|
|
*/
|
|
async stopTimeTracking(task: TaskInfo): Promise<TaskInfo> {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
const activeSession = this.plugin.getActiveTimeSession(task);
|
|
if (!activeSession) {
|
|
throw new Error("No active time tracking session for this task");
|
|
}
|
|
|
|
// Step 1: Construct new state in memory
|
|
const updatedTask = { ...task };
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
if (updatedTask.timeEntries && Array.isArray(updatedTask.timeEntries)) {
|
|
const entryIndex = updatedTask.timeEntries.findIndex(
|
|
(entry: TimeEntry) => entry.startTime === activeSession.startTime && !entry.endTime
|
|
);
|
|
if (entryIndex !== -1) {
|
|
updatedTask.timeEntries = [...updatedTask.timeEntries];
|
|
updatedTask.timeEntries[entryIndex] = {
|
|
...updatedTask.timeEntries[entryIndex],
|
|
endTime: getCurrentTimestamp(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries");
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
|
|
if (frontmatter[timeEntriesField] && Array.isArray(frontmatter[timeEntriesField])) {
|
|
// Find and update the active session
|
|
const entryIndex = frontmatter[timeEntriesField].findIndex(
|
|
(entry: TimeEntry) =>
|
|
entry.startTime === activeSession.startTime && !entry.endTime
|
|
);
|
|
|
|
if (entryIndex !== -1) {
|
|
frontmatter[timeEntriesField][entryIndex].endTime = getCurrentTimestamp();
|
|
}
|
|
}
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated time entries
|
|
if (this.plugin.cacheManager.waitForFreshTaskData) {
|
|
await this.plugin.cacheManager.waitForFreshTaskData(file, {
|
|
timeEntries: updatedTask.timeEntries,
|
|
});
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(task.path, updatedTask);
|
|
} catch (cacheError) {
|
|
console.error("Error updating cache for time tracking stop:", cacheError);
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: task.path,
|
|
originalTask: task,
|
|
updatedTask: updatedTask,
|
|
});
|
|
|
|
// Trigger webhook for time tracking stop
|
|
if (this.webhookNotifier) {
|
|
try {
|
|
await this.webhookNotifier.triggerWebhook("time.stopped", {
|
|
task: updatedTask,
|
|
session: updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1],
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to trigger webhook for time tracking stop:", error);
|
|
}
|
|
}
|
|
|
|
// Step 5: Return authoritative data
|
|
return updatedTask;
|
|
}
|
|
|
|
/**
|
|
* Update a task with multiple property changes following the deterministic data flow pattern
|
|
* This is the centralized method for bulk task updates used by the TaskEditModal
|
|
*/
|
|
async updateTask(
|
|
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}`);
|
|
}
|
|
|
|
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("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")) {
|
|
let tagsToSet = updates.tags;
|
|
// Remove task tag if using property identification
|
|
if (this.plugin.settings.taskIdentificationMethod === "property" && tagsToSet) {
|
|
tagsToSet = tagsToSet.filter(
|
|
(tag: string) => tag !== this.plugin.settings.taskTag
|
|
);
|
|
}
|
|
frontmatter.tags = tagsToSet;
|
|
} else if (originalTask.tags) {
|
|
let tagsToSet = originalTask.tags;
|
|
// Remove task tag if using property identification
|
|
if (this.plugin.settings.taskIdentificationMethod === "property") {
|
|
tagsToSet = tagsToSet.filter(
|
|
(tag: string) => tag !== this.plugin.settings.taskTag
|
|
);
|
|
}
|
|
frontmatter.tags = tagsToSet;
|
|
}
|
|
});
|
|
|
|
// 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, keyChanges);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
|
|
async updateBlockingRelationships(
|
|
currentTask: TaskInfo,
|
|
addedBlockedTaskPaths: string[],
|
|
removedBlockedTaskPaths: string[],
|
|
rawEntries: Record<string, TaskDependency | string> = {}
|
|
): Promise<void> {
|
|
// This method is called when the current task's "blocking" list is updated in the UI.
|
|
// The current task is the one blocking other tasks.
|
|
// We need to update the blockedBy field of the tasks that this task is blocking.
|
|
|
|
const uniqueRemovals = Array.from(new Set(removedBlockedTaskPaths));
|
|
const uniqueAdditions = Array.from(new Set(addedBlockedTaskPaths));
|
|
|
|
// Remove current task from the blockedBy field of tasks it's no longer blocking
|
|
for (const blockedTaskPath of uniqueRemovals) {
|
|
const blockedTask = await this.plugin.cacheManager.getTaskInfo(blockedTaskPath);
|
|
if (!blockedTask) {
|
|
continue;
|
|
}
|
|
|
|
const updatedBlockedBy = this.computeBlockedByUpdate(
|
|
blockedTask,
|
|
currentTask.path,
|
|
"remove"
|
|
);
|
|
if (updatedBlockedBy === null) {
|
|
continue;
|
|
}
|
|
|
|
const updates: Partial<TaskInfo> = {
|
|
blockedBy: updatedBlockedBy.length > 0 ? updatedBlockedBy : undefined,
|
|
};
|
|
await this.updateTask(blockedTask, updates);
|
|
}
|
|
|
|
// Add current task to the blockedBy field of tasks it's now blocking
|
|
for (const blockedTaskPath of uniqueAdditions) {
|
|
const blockedTask = await this.plugin.cacheManager.getTaskInfo(blockedTaskPath);
|
|
if (!blockedTask) {
|
|
continue;
|
|
}
|
|
|
|
// Don't use the raw entry from the UI since it was created relative to the current task's path
|
|
// Instead, always generate a new link from the blocked task's perspective
|
|
const updatedBlockedBy = this.computeBlockedByUpdate(
|
|
blockedTask,
|
|
currentTask.path,
|
|
"add",
|
|
rawEntries[blockedTaskPath]
|
|
);
|
|
if (updatedBlockedBy === null) {
|
|
continue;
|
|
}
|
|
|
|
await this.updateTask(blockedTask, { blockedBy: updatedBlockedBy });
|
|
}
|
|
}
|
|
|
|
private computeBlockedByUpdate(
|
|
blockedTask: TaskInfo,
|
|
blockingTaskPath: string,
|
|
action: "add" | "remove",
|
|
rawEntry?: TaskDependency | string
|
|
): TaskDependency[] | null {
|
|
const existing = Array.isArray(blockedTask.blockedBy)
|
|
? blockedTask.blockedBy
|
|
.map((entry) => normalizeDependencyEntry(entry))
|
|
.filter((entry): entry is TaskDependency => !!entry)
|
|
: [];
|
|
|
|
if (existing.length === 0 && action === "remove") {
|
|
return null;
|
|
}
|
|
|
|
let modified = false;
|
|
let hasExistingEntry = false;
|
|
const result: TaskDependency[] = [];
|
|
|
|
for (const entry of existing) {
|
|
const resolved = resolveDependencyEntry(this.plugin.app, blockedTask.path, entry);
|
|
if (resolved && resolved.path === blockingTaskPath) {
|
|
hasExistingEntry = true;
|
|
if (action === "remove") {
|
|
modified = true;
|
|
continue; // skip to remove
|
|
}
|
|
}
|
|
result.push(entry);
|
|
}
|
|
|
|
if (action === "add" && !hasExistingEntry) {
|
|
const normalizedIncoming = rawEntry ? normalizeDependencyEntry(rawEntry) : null;
|
|
const uid = formatDependencyLink(this.plugin.app, blockedTask.path, blockingTaskPath, this.plugin.settings.useFrontmatterMarkdownLinks);
|
|
const dependency: TaskDependency = {
|
|
uid,
|
|
reltype: normalizedIncoming?.reltype ?? DEFAULT_DEPENDENCY_RELTYPE,
|
|
};
|
|
if (normalizedIncoming?.gap) {
|
|
dependency.gap = normalizedIncoming.gap;
|
|
}
|
|
result.push(dependency);
|
|
modified = true;
|
|
}
|
|
|
|
if (!modified) {
|
|
return null;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Delete a task file and remove it from all caches and indexes
|
|
*/
|
|
async deleteTask(task: TaskInfo): Promise<void> {
|
|
try {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
// Step 1: Delete the file from the vault
|
|
await this.plugin.app.vault.delete(file);
|
|
|
|
// Step 2: Remove from cache and indexes (this will be done by the file delete event)
|
|
// But we'll also do it proactively to ensure immediate UI updates
|
|
this.plugin.cacheManager.clearCacheEntry(task.path);
|
|
|
|
// Step 3: Emit task deleted event
|
|
this.plugin.emitter.trigger(EVENT_TASK_DELETED, {
|
|
path: task.path,
|
|
deletedTask: task,
|
|
});
|
|
|
|
// Trigger webhook for task deletion
|
|
if (this.webhookNotifier) {
|
|
try {
|
|
await this.webhookNotifier.triggerWebhook("task.deleted", { task });
|
|
} catch (error) {
|
|
console.warn("Failed to trigger webhook for task deletion:", error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
// eslint-disable-next-line no-console
|
|
console.error("Error deleting task:", {
|
|
error: errorMessage,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
taskPath: task.path,
|
|
});
|
|
|
|
throw new Error(`Failed to delete task: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle completion status for recurring tasks on a specific date
|
|
*/
|
|
async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
// Get fresh task data to ensure we have the latest completion state
|
|
const freshTask = (await this.plugin.cacheManager.getTaskInfo(task.path)) || task;
|
|
|
|
if (!freshTask.recurrence) {
|
|
throw new Error("Task is not recurring");
|
|
}
|
|
|
|
// Default to local today instead of selectedDate for recurring task completion
|
|
// This ensures completion is recorded for user's actual calendar day unless explicitly overridden
|
|
const targetDate =
|
|
date ||
|
|
(() => {
|
|
const todayLocal = getTodayLocal();
|
|
return createUTCDateFromLocalCalendarDate(todayLocal);
|
|
})();
|
|
const dateStr = formatDateForStorage(targetDate);
|
|
|
|
// Check current completion status for this date using fresh data
|
|
const completeInstances = Array.isArray(freshTask.complete_instances)
|
|
? freshTask.complete_instances
|
|
: [];
|
|
const currentComplete = completeInstances.includes(dateStr);
|
|
const newComplete = !currentComplete;
|
|
|
|
// Step 1: Construct new state in memory using fresh data
|
|
const updatedTask = { ...freshTask };
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
if (newComplete) {
|
|
// Add date to completed instances if not already present
|
|
if (!completeInstances.includes(dateStr)) {
|
|
updatedTask.complete_instances = [...completeInstances, dateStr];
|
|
}
|
|
} else {
|
|
// Remove date from completed instances
|
|
updatedTask.complete_instances = completeInstances.filter((d) => d !== dateStr);
|
|
}
|
|
|
|
// Add DTSTART to recurrence rule if it's missing (only when completing)
|
|
if (
|
|
newComplete &&
|
|
typeof updatedTask.recurrence === "string" &&
|
|
!updatedTask.recurrence.includes("DTSTART:")
|
|
) {
|
|
const updatedRecurrence = addDTSTARTToRecurrenceRule(updatedTask);
|
|
if (updatedRecurrence) {
|
|
updatedTask.recurrence = updatedRecurrence;
|
|
}
|
|
}
|
|
|
|
// Update scheduled date to next uncompleted occurrence
|
|
const nextDates = updateToNextScheduledOccurrence(
|
|
updatedTask,
|
|
this.plugin.settings.maintainDueDateOffsetInRecurring
|
|
);
|
|
if (nextDates.scheduled) {
|
|
updatedTask.scheduled = nextDates.scheduled;
|
|
}
|
|
if (nextDates.due) {
|
|
updatedTask.due = nextDates.due;
|
|
}
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
const completeInstancesField = this.plugin.fieldMapper.toUserField("completeInstances");
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
const scheduledField = this.plugin.fieldMapper.toUserField("scheduled");
|
|
const dueField = this.plugin.fieldMapper.toUserField("due");
|
|
const recurrenceField = this.plugin.fieldMapper.toUserField("recurrence");
|
|
|
|
// Ensure complete_instances array exists
|
|
if (!frontmatter[completeInstancesField]) {
|
|
frontmatter[completeInstancesField] = [];
|
|
}
|
|
|
|
const completeDates: string[] = frontmatter[completeInstancesField];
|
|
|
|
if (newComplete) {
|
|
// Add date to completed instances if not already present
|
|
if (!completeDates.includes(dateStr)) {
|
|
frontmatter[completeInstancesField] = [...completeDates, dateStr];
|
|
}
|
|
} else {
|
|
// Remove date from completed instances
|
|
frontmatter[completeInstancesField] = completeDates.filter((d) => d !== dateStr);
|
|
}
|
|
|
|
// Update recurrence field if it was updated with DTSTART
|
|
if (updatedTask.recurrence !== freshTask.recurrence) {
|
|
frontmatter[recurrenceField] = updatedTask.recurrence;
|
|
}
|
|
|
|
// Update scheduled date if it changed
|
|
if (updatedTask.scheduled) {
|
|
frontmatter[scheduledField] = updatedTask.scheduled;
|
|
}
|
|
|
|
// Update due date if it changed
|
|
if (updatedTask.due) {
|
|
frontmatter[dueField] = updatedTask.due;
|
|
}
|
|
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated data
|
|
if (this.plugin.cacheManager.waitForFreshTaskData) {
|
|
const expectedChanges: Partial<TaskInfo> = {
|
|
complete_instances: updatedTask.complete_instances,
|
|
};
|
|
if (updatedTask.scheduled !== freshTask.scheduled) {
|
|
expectedChanges.scheduled = updatedTask.scheduled;
|
|
}
|
|
if (updatedTask.due !== freshTask.due) {
|
|
expectedChanges.due = updatedTask.due;
|
|
}
|
|
await this.plugin.cacheManager.waitForFreshTaskData(file, expectedChanges);
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(freshTask.path, updatedTask);
|
|
} catch (cacheError) {
|
|
console.error("Error updating cache for recurring task:", cacheError);
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: freshTask.path,
|
|
originalTask: freshTask,
|
|
updatedTask: updatedTask,
|
|
});
|
|
|
|
// Step 5: Trigger webhook for recurring task completion
|
|
if (newComplete && this.webhookNotifier) {
|
|
try {
|
|
await this.webhookNotifier.triggerWebhook("recurring.instance.completed", {
|
|
task: updatedTask,
|
|
date: dateStr,
|
|
targetDate: targetDate,
|
|
});
|
|
} catch (webhookError) {
|
|
console.error("Error triggering recurring task completion webhook:", webhookError);
|
|
}
|
|
}
|
|
|
|
// Step 6: Return authoritative data
|
|
return updatedTask;
|
|
}
|
|
|
|
/**
|
|
* Delete a specific time entry from a task
|
|
*/
|
|
async deleteTimeEntry(task: TaskInfo, timeEntryIndex: number): Promise<TaskInfo> {
|
|
const file = this.plugin.app.vault.getAbstractFileByPath(task.path);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`Cannot find task file: ${task.path}`);
|
|
}
|
|
|
|
if (!task.timeEntries || !Array.isArray(task.timeEntries)) {
|
|
throw new Error("Task has no time entries");
|
|
}
|
|
|
|
if (timeEntryIndex < 0 || timeEntryIndex >= task.timeEntries.length) {
|
|
throw new Error("Invalid time entry index");
|
|
}
|
|
|
|
// Step 1: Construct new state in memory
|
|
const updatedTask = { ...task };
|
|
updatedTask.dateModified = getCurrentTimestamp();
|
|
|
|
// Remove the time entry at the specified index
|
|
updatedTask.timeEntries = task.timeEntries.filter((_, index) => index !== timeEntryIndex);
|
|
|
|
// Step 2: Persist to file
|
|
await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
|
const timeEntriesField = this.plugin.fieldMapper.toUserField("timeEntries");
|
|
const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified");
|
|
|
|
if (frontmatter[timeEntriesField] && Array.isArray(frontmatter[timeEntriesField])) {
|
|
// Remove the time entry at the specified index
|
|
frontmatter[timeEntriesField] = frontmatter[timeEntriesField].filter(
|
|
(_: any, index: number) => index !== timeEntryIndex
|
|
);
|
|
}
|
|
|
|
frontmatter[dateModifiedField] = updatedTask.dateModified;
|
|
});
|
|
|
|
// Step 3: Wait for fresh data and update cache
|
|
try {
|
|
// Wait for the metadata cache to have the updated time entries
|
|
if (this.plugin.cacheManager.waitForFreshTaskData) {
|
|
await this.plugin.cacheManager.waitForFreshTaskData(file, {
|
|
timeEntries: updatedTask.timeEntries,
|
|
});
|
|
}
|
|
this.plugin.cacheManager.updateTaskInfoInCache(task.path, updatedTask);
|
|
} catch (cacheError) {
|
|
console.error("Error updating cache for time entry deletion:", cacheError);
|
|
}
|
|
|
|
// Step 4: Notify system of change
|
|
this.plugin.emitter.trigger(EVENT_TASK_UPDATED, {
|
|
path: task.path,
|
|
originalTask: task,
|
|
updatedTask: updatedTask,
|
|
});
|
|
|
|
// Step 5: Return authoritative data
|
|
return updatedTask;
|
|
}
|
|
|
|
/**
|
|
* Update the completedDate field in frontmatter based on the task's status.
|
|
* For non-recurring tasks:
|
|
* - Sets completedDate to current date when status becomes completed
|
|
* - Removes completedDate when status becomes incomplete
|
|
* For recurring tasks, this method does nothing (they don't use completedDate).
|
|
*
|
|
* @param frontmatter - The frontmatter object to modify
|
|
* @param newStatus - The new status value
|
|
* @param isRecurring - Whether the task is recurring
|
|
*/
|
|
private updateCompletedDateInFrontmatter(
|
|
frontmatter: Record<string, any>,
|
|
newStatus: string,
|
|
isRecurring: boolean
|
|
): void {
|
|
if (isRecurring) {
|
|
return; // Recurring tasks don't use completedDate
|
|
}
|
|
|
|
const completedDateField = this.plugin.fieldMapper.toUserField("completedDate");
|
|
|
|
if (this.plugin.statusManager.isCompletedStatus(newStatus)) {
|
|
frontmatter[completedDateField] = getCurrentDateString();
|
|
} else {
|
|
if (frontmatter[completedDateField]) {
|
|
delete frontmatter[completedDateField];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the basename from a project string, handling wikilink format
|
|
* Examples:
|
|
* - "[[projects/testContext/testProject/testProject Root]]" -> "testProject Root"
|
|
* - "[[path|display text]]" -> "display text"
|
|
* - "simple string" -> "simple string"
|
|
*/
|
|
private extractProjectBasename(project: string): string {
|
|
if (!project) return "";
|
|
|
|
// Check if it's a wikilink format [[...]]
|
|
const linkMatch = project.match(/^\[\[([^\]]+)\]\]$/);
|
|
if (linkMatch) {
|
|
const linkContent = linkMatch[1];
|
|
|
|
// Handle pipe syntax: "path|display" -> use "display"
|
|
if (linkContent.includes("|")) {
|
|
return linkContent.split("|")[1].trim();
|
|
}
|
|
|
|
// Try to resolve the file using Obsidian's metadata cache
|
|
if (this.plugin.app?.metadataCache) {
|
|
try {
|
|
const file = this.plugin.app.metadataCache.getFirstLinkpathDest(
|
|
linkContent,
|
|
""
|
|
);
|
|
if (file) {
|
|
// Return the file's basename (name without extension)
|
|
return file.basename;
|
|
}
|
|
} catch (error) {
|
|
// File resolution failed, fall back to manual extraction
|
|
console.debug("Error resolving project file:", error);
|
|
}
|
|
}
|
|
|
|
// Fallback: extract basename manually from the path
|
|
const pathParts = linkContent.split("/");
|
|
return pathParts[pathParts.length - 1] || linkContent;
|
|
}
|
|
|
|
// For non-wikilink strings, return as-is
|
|
return project;
|
|
}
|
|
}
|