From 568dcbefa6e8b1d2f60bb1967bb8f9cac3ee2cc5 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Wed, 26 Nov 2025 10:30:06 +0800 Subject: [PATCH] feat(date): add time component support for task dates - Extend regex patterns to support HH:mm time format in date fields - Add date-utils with isDateOnly() and formatDateSmart() helpers - Enhance time-parsing-service to normalize parsed dates with time - Update WriteAPI and executors to handle date-time patterns --- src/common/regex-define.ts | 40 +++-- src/dataflow/api/WriteAPI.ts | 160 ++++++++++++------ .../completion/duplicate-executor.ts | 58 +++---- src/parsers/canvas-task-updater.ts | 157 +++++++++-------- src/services/time-parsing-service.ts | 57 ++++++- src/utils/date/date-utils.ts | 61 +++++++ 6 files changed, 358 insertions(+), 175 deletions(-) create mode 100644 src/utils/date/date-utils.ts diff --git a/src/common/regex-define.ts b/src/common/regex-define.ts index 05f16c2f..5aba47fd 100644 --- a/src/common/regex-define.ts +++ b/src/common/regex-define.ts @@ -2,12 +2,17 @@ const TASK_REGEX = /^(([\s>]*)?(-|\d+\.|\*|\+)\s\[([^\[\]]{1})\])\s+(.*)$/m; // --- Emoji/Tasks Style Regexes --- -const EMOJI_START_DATE_REGEX = /🛫\s*(\d{4}-\d{2}-\d{2})/; -const EMOJI_COMPLETED_DATE_REGEX = /✅\s*(\d{4}-\d{2}-\d{2})/; -const EMOJI_DUE_DATE_REGEX = /(?:📅|🗓️|📆)\s*(\d{4}-\d{2}-\d{2})/; -const EMOJI_SCHEDULED_DATE_REGEX = /⏳\s*(\d{4}-\d{2}-\d{2})/; -const EMOJI_CREATED_DATE_REGEX = /➕\s*(\d{4}-\d{2}-\d{2})/; -const EMOJI_CANCELLED_DATE_REGEX = /❌\s*(\d{4}-\d{2}-\d{2})/; +const EMOJI_START_DATE_REGEX = /🛫\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; +const EMOJI_COMPLETED_DATE_REGEX = + /✅\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; +const EMOJI_DUE_DATE_REGEX = + /(?:📅|🗓️|📆)\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; +const EMOJI_SCHEDULED_DATE_REGEX = + /⏳\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; +const EMOJI_CREATED_DATE_REGEX = + /➕\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; +const EMOJI_CANCELLED_DATE_REGEX = + /❌\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)/; const EMOJI_ID_REGEX = /🆔\s*([^\s]+)/; const EMOJI_DEPENDS_ON_REGEX = /⛔\s*([^\s]+)/; const EMOJI_ON_COMPLETION_REGEX = /🏁\s*([^\s]+)/; @@ -28,7 +33,7 @@ export type MetadataFormat = "emoji" | "dataview"; // Function to get configurable project prefix based on format export const getProjectPrefix = ( prefix?: string, - format: MetadataFormat = "emoji" + format: MetadataFormat = "emoji", ): string => { const projectPrefix = prefix || "project"; if (format === "dataview") { @@ -42,7 +47,7 @@ export const getProjectPrefix = ( // Special handling: emoji format always uses @, dataview format uses configurable prefix export const getContextPrefix = ( prefix?: string, - format: MetadataFormat = "emoji" + format: MetadataFormat = "emoji", ): string => { if (format === "dataview") { const contextPrefix = prefix || "context"; @@ -56,7 +61,7 @@ export const getContextPrefix = ( // Function to get configurable area prefix based on format export const getAreaPrefix = ( prefix?: string, - format: MetadataFormat = "emoji" + format: MetadataFormat = "emoji", ): string => { const areaPrefix = prefix || "area"; if (format === "dataview") { @@ -85,13 +90,18 @@ export const createDataviewContextRegex = (prefix?: string): RegExp => { }; // --- Dataview Style Regexes --- -const DV_START_DATE_REGEX = /\[(?:start|🛫)::\s*(\d{4}-\d{2}-\d{2})\]/i; +const DV_START_DATE_REGEX = + /\[(?:start|🛫)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; const DV_COMPLETED_DATE_REGEX = - /\[(?:completion|✅)::\s*(\d{4}-\d{2}-\d{2})\]/i; -const DV_DUE_DATE_REGEX = /\[(?:due|🗓️)::\s*(\d{4}-\d{2}-\d{2})\]/i; -const DV_SCHEDULED_DATE_REGEX = /\[(?:scheduled|⏳)::\s*(\d{4}-\d{2}-\d{2})\]/i; -const DV_CREATED_DATE_REGEX = /\[(?:created|➕)::\s*(\d{4}-\d{2}-\d{2})\]/i; -const DV_CANCELLED_DATE_REGEX = /\[(?:cancelled|❌)::\s*(\d{4}-\d{2}-\d{2})\]/i; + /\[(?:completion|✅)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; +const DV_DUE_DATE_REGEX = + /\[(?:due|🗓️)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; +const DV_SCHEDULED_DATE_REGEX = + /\[(?:scheduled|⏳)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; +const DV_CREATED_DATE_REGEX = + /\[(?:created|➕)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; +const DV_CANCELLED_DATE_REGEX = + /\[(?:cancelled|❌)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?)\]/i; const DV_ID_REGEX = /\[(?:id|🆔)::\s*([^\]]+)\]/i; const DV_DEPENDS_ON_REGEX = /\[(?:dependsOn|⛔)::\s*([^\]]+)\]/i; const DV_ON_COMPLETION_REGEX = /\[(?:onCompletion|🏁)::\s*([^\]]+)\]/i; diff --git a/src/dataflow/api/WriteAPI.ts b/src/dataflow/api/WriteAPI.ts index b2aad278..2da294ba 100644 --- a/src/dataflow/api/WriteAPI.ts +++ b/src/dataflow/api/WriteAPI.ts @@ -25,6 +25,7 @@ import { CanvasTaskUpdater } from "@/parsers/canvas-task-updater"; import { rrulestr } from "rrule"; import { EMOJI_TAG_REGEX, TOKEN_CONTEXT_REGEX } from "@/common/regex-define"; import { BulkOperationResult } from "@/types/selection"; +import { formatDateSmart, isDateOnly } from "@/utils/date/date-utils"; /** * Arguments for creating a task @@ -96,9 +97,7 @@ export class WriteAPI { } private enqueueWrite(operation: () => Promise): Promise { - const run = this.writeQueue - .catch(() => undefined) - .then(operation); + const run = this.writeQueue.catch(() => undefined).then(operation); this.writeQueue = run.then( () => undefined, @@ -116,9 +115,7 @@ export class WriteAPI { status?: string; completed?: boolean; }): Promise<{ success: boolean; task?: Task; error?: string }> { - return this.enqueueWrite(() => - this.performUpdateTaskStatus(args), - ); + return this.enqueueWrite(() => this.performUpdateTaskStatus(args)); } private async performUpdateTaskStatus(args: { @@ -537,7 +534,10 @@ export class WriteAPI { // Remove existing completion markers first taskLine = taskLine .replace(/\s*\[completion::\s*[^\]]+\]/i, "") - .replace(/\s*✅\s*\d{4}-\d{2}-\d{2}/, ""); + .replace( + /\s*✅\s*\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?/, + "", + ); if (md.completedDate) { const dateStr = typeof md.completedDate === "number" @@ -889,7 +889,7 @@ export class WriteAPI { // Find an enabled mapping where targetKey matches the standard key const mapping = metadataMappings.find( - (m) => m.enabled && m.targetKey === standardKey && m.sourceKey + (m) => m.enabled && m.targetKey === standardKey && m.sourceKey, ); // Return the source key if mapping exists, otherwise use standard key @@ -897,8 +897,12 @@ export class WriteAPI { }; // Handle status/completed updates by writing to frontmatter - const hasStatusUpdate = updates.status !== undefined && updates.status !== originalTask.status; - const hasCompletedUpdate = updates.completed !== undefined && updates.completed !== originalTask.completed; + const hasStatusUpdate = + updates.status !== undefined && + updates.status !== originalTask.status; + const hasCompletedUpdate = + updates.completed !== undefined && + updates.completed !== originalTask.completed; const hasMetadataUpdates = updates.metadata !== undefined; if (hasStatusUpdate || hasCompletedUpdate || hasMetadataUpdates) { @@ -910,14 +914,22 @@ export class WriteAPI { const statusMapping = fileSourceConfig?.statusMapping; let statusValue = updates.status; - if (statusMapping?.enabled && statusMapping.symbolToMetadata) { - statusValue = statusMapping.symbolToMetadata[updates.status] || updates.status; + if ( + statusMapping?.enabled && + statusMapping.symbolToMetadata + ) { + statusValue = + statusMapping.symbolToMetadata[ + updates.status + ] || updates.status; } // Use custom field name if mapped - const statusKey = getFrontmatterKey('status'); + const statusKey = getFrontmatterKey("status"); (fm as any)[statusKey] = statusValue; - console.log(`[WriteAPI][FileSource] Updated ${statusKey}: ${originalTask.status} -> ${statusValue} (symbol: ${updates.status})`); + console.log( + `[WriteAPI][FileSource] Updated ${statusKey}: ${originalTask.status} -> ${statusValue} (symbol: ${updates.status})`, + ); } // Handle metadata updates @@ -926,8 +938,11 @@ export class WriteAPI { // Priority if (metadata.priority !== undefined) { - const priorityKey = getFrontmatterKey('priority'); - if (metadata.priority === null || metadata.priority === 0) { + const priorityKey = getFrontmatterKey("priority"); + if ( + metadata.priority === null || + metadata.priority === 0 + ) { delete (fm as any)[priorityKey]; } else { (fm as any)[priorityKey] = metadata.priority; @@ -936,36 +951,54 @@ export class WriteAPI { // Dates if (metadata.dueDate !== undefined) { - const dueDateKey = getFrontmatterKey('dueDate'); + const dueDateKey = getFrontmatterKey("dueDate"); if (metadata.dueDate) { - (fm as any)[dueDateKey] = new Date(metadata.dueDate).toISOString().split('T')[0]; + (fm as any)[dueDateKey] = new Date( + metadata.dueDate, + ) + .toISOString() + .split("T")[0]; } else { delete (fm as any)[dueDateKey]; } } if (metadata.startDate !== undefined) { - const startDateKey = getFrontmatterKey('startDate'); + const startDateKey = getFrontmatterKey("startDate"); if (metadata.startDate) { - (fm as any)[startDateKey] = new Date(metadata.startDate).toISOString().split('T')[0]; + (fm as any)[startDateKey] = new Date( + metadata.startDate, + ) + .toISOString() + .split("T")[0]; } else { delete (fm as any)[startDateKey]; } } if (metadata.scheduledDate !== undefined) { - const scheduledDateKey = getFrontmatterKey('scheduledDate'); + const scheduledDateKey = + getFrontmatterKey("scheduledDate"); if (metadata.scheduledDate) { - (fm as any)[scheduledDateKey] = new Date(metadata.scheduledDate).toISOString().split('T')[0]; + (fm as any)[scheduledDateKey] = new Date( + metadata.scheduledDate, + ) + .toISOString() + .split("T")[0]; } else { delete (fm as any)[scheduledDateKey]; } } if (metadata.completedDate !== undefined) { - const completedDateKey = getFrontmatterKey('completedDate'); + const completedDateKey = + getFrontmatterKey("completedDate"); if (metadata.completedDate) { - (fm as any)[completedDateKey] = new Date(metadata.completedDate).toISOString().split('T')[0]; + (fm as any)[completedDateKey] = new Date( + metadata.completedDate, + ) + .toISOString() + .split("T")[0]; } else { delete (fm as any)[completedDateKey]; } @@ -973,7 +1006,7 @@ export class WriteAPI { // Project and context if (metadata.project !== undefined) { - const projectKey = getFrontmatterKey('project'); + const projectKey = getFrontmatterKey("project"); if (metadata.project) { (fm as any)[projectKey] = metadata.project; } else { @@ -982,7 +1015,7 @@ export class WriteAPI { } if (metadata.context !== undefined) { - const contextKey = getFrontmatterKey('context'); + const contextKey = getFrontmatterKey("context"); if (metadata.context) { (fm as any)[contextKey] = metadata.context; } else { @@ -992,8 +1025,11 @@ export class WriteAPI { // Tags if (metadata.tags !== undefined) { - const tagsKey = getFrontmatterKey('tags'); - if (Array.isArray(metadata.tags) && metadata.tags.length > 0) { + const tagsKey = getFrontmatterKey("tags"); + if ( + Array.isArray(metadata.tags) && + metadata.tags.length > 0 + ) { (fm as any)[tagsKey] = metadata.tags; } else { delete (fm as any)[tagsKey]; @@ -1002,7 +1038,7 @@ export class WriteAPI { // Area if (metadata.area !== undefined) { - const areaKey = getFrontmatterKey('area'); + const areaKey = getFrontmatterKey("area"); if (metadata.area) { (fm as any)[areaKey] = metadata.area; } else { @@ -1012,9 +1048,11 @@ export class WriteAPI { // Recurrence if (metadata.recurrence !== undefined) { - const recurrenceKey = getFrontmatterKey('recurrence'); + const recurrenceKey = + getFrontmatterKey("recurrence"); if (metadata.recurrence) { - (fm as any)[recurrenceKey] = metadata.recurrence; + (fm as any)[recurrenceKey] = + metadata.recurrence; } else { delete (fm as any)[recurrenceKey]; } @@ -1022,7 +1060,9 @@ export class WriteAPI { } }); - console.log(`[WriteAPI][FileSource] Updated frontmatter for file-source task: ${file.path}`); + console.log( + `[WriteAPI][FileSource] Updated frontmatter for file-source task: ${file.path}`, + ); } catch (error) { console.error( "WriteAPI: Error updating file-source task frontmatter:", @@ -2008,6 +2048,12 @@ export class WriteAPI { // ===== Helper Methods ===== + private formatDateForWrite( + timestamp: number | string | Date | undefined | null, + ): string { + return formatDateSmart(timestamp, { includeSeconds: false }); + } + /** * Generate metadata string based on format preference */ @@ -2145,38 +2191,48 @@ export class WriteAPI { // Start Date if (args.startDate) { - const dateStr = moment(args.startDate).format("YYYY-MM-DD"); - metadata.push( - useDataviewFormat ? `[start:: ${dateStr}]` : `🛫 ${dateStr}`, - ); + const dateStr = this.formatDateForWrite(args.startDate); + if (dateStr) { + metadata.push( + useDataviewFormat + ? `[start:: ${dateStr}]` + : `🛫 ${dateStr}`, + ); + } } // Scheduled Date if (args.scheduledDate) { - const dateStr = moment(args.scheduledDate).format("YYYY-MM-DD"); - metadata.push( - useDataviewFormat - ? `[scheduled:: ${dateStr}]` - : `⏳ ${dateStr}`, - ); + const dateStr = this.formatDateForWrite(args.scheduledDate); + if (dateStr) { + metadata.push( + useDataviewFormat + ? `[scheduled:: ${dateStr}]` + : `⏳ ${dateStr}`, + ); + } } // Due Date if (args.dueDate) { - const dateStr = moment(args.dueDate).format("YYYY-MM-DD"); - metadata.push( - useDataviewFormat ? `[due:: ${dateStr}]` : `📅 ${dateStr}`, - ); + const dateStr = this.formatDateForWrite(args.dueDate); + if (dateStr) { + metadata.push( + useDataviewFormat ? `[due:: ${dateStr}]` : `📅 ${dateStr}`, + ); + } } // Completion Date if (args.completed && args.completedDate) { - const dateStr = moment(args.completedDate).format("YYYY-MM-DD"); - metadata.push( - useDataviewFormat - ? `[completion:: ${dateStr}]` - : `✅ ${dateStr}`, - ); + const dateStr = this.formatDateForWrite(args.completedDate); + if (dateStr) { + metadata.push( + useDataviewFormat + ? `[completion:: ${dateStr}]` + : `✅ ${dateStr}`, + ); + } } // On Completion action diff --git a/src/executors/completion/duplicate-executor.ts b/src/executors/completion/duplicate-executor.ts index 52b4daf5..ccd1f785 100644 --- a/src/executors/completion/duplicate-executor.ts +++ b/src/executors/completion/duplicate-executor.ts @@ -17,7 +17,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { */ protected async executeForCanvas( context: OnCompletionExecutionContext, - config: OnCompletionConfig + config: OnCompletionConfig, ): Promise { const duplicateConfig = config as OnCompletionDuplicateConfig; const { task, app } = context; @@ -34,7 +34,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { targetFile, undefined, // targetNodeId - could be enhanced later duplicateConfig.targetSection, - duplicateConfig.preserveMetadata + duplicateConfig.preserveMetadata, ); if (result.success) { @@ -46,11 +46,11 @@ export class DuplicateActionExecutor extends BaseActionExecutor { ? ` (section: ${duplicateConfig.targetSection})` : ""; return this.createSuccessResult( - `Task duplicated ${locationText}${sectionText}` + `Task duplicated ${locationText}${sectionText}`, ); } else { return this.createErrorResult( - result.error || "Failed to duplicate Canvas task" + result.error || "Failed to duplicate Canvas task", ); } } else { @@ -59,7 +59,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { } } catch (error) { return this.createErrorResult( - `Error duplicating Canvas task: ${error.message}` + `Error duplicating Canvas task: ${error.message}`, ); } } @@ -69,7 +69,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { */ protected async executeForMarkdown( context: OnCompletionExecutionContext, - config: OnCompletionConfig + config: OnCompletionConfig, ): Promise { const duplicateConfig = config as OnCompletionDuplicateConfig; const { task, app } = context; @@ -79,7 +79,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { const sourceFile = app.vault.getFileByPath(task.filePath); if (!(sourceFile instanceof TFile)) { return this.createErrorResult( - `Source file not found: ${task.filePath}` + `Source file not found: ${task.filePath}`, ); } @@ -87,18 +87,18 @@ export class DuplicateActionExecutor extends BaseActionExecutor { let targetFile: TFile; if (duplicateConfig.targetFile) { targetFile = app.vault.getFileByPath( - duplicateConfig.targetFile + duplicateConfig.targetFile, ) as TFile; if (!(targetFile instanceof TFile)) { // Try to create the target file if it doesn't exist try { targetFile = await app.vault.create( duplicateConfig.targetFile, - "" + "", ); } catch (error) { return this.createErrorResult( - `Failed to create target file: ${duplicateConfig.targetFile}` + `Failed to create target file: ${duplicateConfig.targetFile}`, ); } } @@ -113,7 +113,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { // Find the task line if (task.line === undefined || task.line >= sourceLines.length) { return this.createErrorResult( - "Task line not found in source file" + "Task line not found in source file", ); } @@ -122,7 +122,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { // Create duplicate task line let duplicateTaskLine = this.createDuplicateTaskLine( originalTaskLine, - duplicateConfig + duplicateConfig, ); // If target file is different from source, add to target file @@ -136,7 +136,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { const sectionIndex = targetLines.findIndex( (line) => line.trim().startsWith("#") && - line.includes(duplicateConfig.targetSection!) + line.includes(duplicateConfig.targetSection!), ); if (sectionIndex !== -1) { @@ -144,14 +144,14 @@ export class DuplicateActionExecutor extends BaseActionExecutor { targetLines.splice( sectionIndex + 1, 0, - duplicateTaskLine + duplicateTaskLine, ); } else { // Section not found, create it and add the task targetLines.push( "", `## ${duplicateConfig.targetSection}`, - duplicateTaskLine + duplicateTaskLine, ); } } else { @@ -176,11 +176,11 @@ export class DuplicateActionExecutor extends BaseActionExecutor { : ""; return this.createSuccessResult( - `Task duplicated ${locationText}${sectionText}` + `Task duplicated ${locationText}${sectionText}`, ); } catch (error) { return this.createErrorResult( - `Failed to duplicate task: ${error.message}` + `Failed to duplicate task: ${error.message}`, ); } } @@ -190,7 +190,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { */ private async duplicateCanvasToMarkdown( context: OnCompletionExecutionContext, - duplicateConfig: OnCompletionDuplicateConfig + duplicateConfig: OnCompletionDuplicateConfig, ): Promise { const { task, app } = context; @@ -203,14 +203,14 @@ export class DuplicateActionExecutor extends BaseActionExecutor { // Reset completion status taskContent = taskContent.replace( /^(\s*[-*+]\s*\[)[xX\-](\])/, - "$1 $2" + "$1 $2", ); if (!duplicateConfig.preserveMetadata) { // Remove completion-related metadata taskContent = taskContent - .replace(/✅\s*\d{4}-\d{2}-\d{2}/g, "") // Remove completion date - .replace(/⏰\s*\d{4}-\d{2}-\d{2}/g, "") // Remove scheduled date if desired + .replace(/✅\s*\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?/g, "") // Remove completion date + .replace(/⏰\s*\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?/g, "") // Remove scheduled date if desired .trim(); } @@ -227,7 +227,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { targetFileObj = await app.vault.create(targetFile, ""); } catch (error) { return this.createErrorResult( - `Failed to create target file: ${targetFile}` + `Failed to create target file: ${targetFile}`, ); } } @@ -245,7 +245,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor { .trim() .toLowerCase() .includes( - duplicateConfig.targetSection.toLowerCase() + duplicateConfig.targetSection.toLowerCase(), ) ) { insertPosition = i + 1; @@ -269,30 +269,30 @@ export class DuplicateActionExecutor extends BaseActionExecutor { : ""; return this.createSuccessResult( - `Task duplicated from Canvas ${locationText}${sectionText}` + `Task duplicated from Canvas ${locationText}${sectionText}`, ); } catch (error) { return this.createErrorResult( - `Failed to duplicate Canvas task to Markdown: ${error.message}` + `Failed to duplicate Canvas task to Markdown: ${error.message}`, ); } } private createDuplicateTaskLine( originalLine: string, - config: OnCompletionDuplicateConfig + config: OnCompletionDuplicateConfig, ): string { // Reset the task to incomplete state let duplicateLine = originalLine.replace( /^(\s*[-*+]\s*\[)[xX\-](\])/, - "$1 $2" + "$1 $2", ); if (!config.preserveMetadata) { // Remove completion-related metadata duplicateLine = duplicateLine - .replace(/✅\s*\d{4}-\d{2}-\d{2}/g, "") // Remove completion date - .replace(/⏰\s*\d{4}-\d{2}-\d{2}/g, "") // Remove scheduled date if desired + .replace(/✅\s*\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?/g, "") // Remove completion date + .replace(/⏰\s*\d{4}-\d{2}-\d{2}(?:\s+\d{1,2}:\d{2})?/g, "") // Remove scheduled date if desired .trim(); } diff --git a/src/parsers/canvas-task-updater.ts b/src/parsers/canvas-task-updater.ts index eb1a93f0..4b4fff57 100644 --- a/src/parsers/canvas-task-updater.ts +++ b/src/parsers/canvas-task-updater.ts @@ -8,6 +8,7 @@ import { CanvasData, CanvasTextData } from "../types/canvas"; import type TaskProgressBarPlugin from "../index"; import { Events, emit } from "../dataflow/events/Events"; import { CanvasParser } from "../dataflow/core/CanvasParser"; +import { formatDateSmart } from "@/utils/date/date-utils"; /** * Result of a Canvas task update operation @@ -22,14 +23,17 @@ export interface CanvasTaskUpdateResult { * Utility class for updating tasks within Canvas files */ export class CanvasTaskUpdater { - constructor(private vault: Vault, private plugin: TaskProgressBarPlugin) {} + constructor( + private vault: Vault, + private plugin: TaskProgressBarPlugin, + ) {} /** * Update a task within a Canvas file */ public async updateCanvasTask( task: Task, - updatedTask: Task + updatedTask: Task, ): Promise { try { // Get the Canvas file @@ -76,7 +80,7 @@ export class CanvasTaskUpdater { const updateResult = this.updateTaskInTextNode( textNode, task, - updatedTask + updatedTask, ); if (!updateResult.success) { @@ -88,7 +92,7 @@ export class CanvasTaskUpdater { if (this.plugin.app?.workspace) { this.plugin.app.workspace.trigger( "task-genius:task-completed", - updatedTask + updatedTask, ); } } @@ -133,7 +137,7 @@ export class CanvasTaskUpdater { private updateTaskInTextNode( textNode: CanvasTextData, originalTask: Task, - updatedTask: Task + updatedTask: Task, ): CanvasTaskUpdateResult { try { const lines = textNode.text.split("\n"); @@ -153,7 +157,7 @@ export class CanvasTaskUpdater { const updatedLine = this.updateCompleteTaskLine( line, originalTask, - updatedTask + updatedTask, ); updatedLines[i] = updatedLine; taskFound = true; @@ -205,11 +209,11 @@ export class CanvasTaskUpdater { // Try matching without the checkbox status (in case status changed) const lineWithoutStatus = normalizedLine.replace( /^[-*+]\s*\[[^\]]*\]\s*/, - "- [ ] " + "- [ ] ", ); const originalWithoutStatus = normalizedOriginal.replace( /^[-*+]\s*\[[^\]]*\]\s*/, - "- [ ] " + "- [ ] ", ); if (lineWithoutStatus === originalWithoutStatus) { @@ -280,7 +284,7 @@ export class CanvasTaskUpdater { private updateCompleteTaskLine( taskLine: string, originalTask: Task, - updatedTask: Task + updatedTask: Task, ): string { const useDataviewFormat = this.plugin.settings.preferMetadataFormat === "dataview"; @@ -294,7 +298,7 @@ export class CanvasTaskUpdater { if (updatedTask.status) { updatedLine = updatedLine.replace( /(\s*[-*+]\s*\[)[^\]]*(\]\s*)/, - `$1${updatedTask.status}$2` + `$1${updatedTask.status}$2`, ); } // Otherwise, update completion status if it changed @@ -302,7 +306,7 @@ export class CanvasTaskUpdater { const statusMark = updatedTask.completed ? "x" : " "; updatedLine = updatedLine.replace( /(\s*[-*+]\s*\[)[^\]]*(\]\s*)/, - `$1${statusMark}$2` + `$1${statusMark}$2`, ); } @@ -323,7 +327,7 @@ export class CanvasTaskUpdater { const metadata = this.buildMetadataArray( updatedTask, originalTask, - useDataviewFormat + useDataviewFormat, ); // Append all metadata to the line @@ -346,27 +350,24 @@ export class CanvasTaskUpdater { private buildMetadataArray( updatedTask: Task, originalTask: Task, - useDataviewFormat: boolean + useDataviewFormat: boolean, ): string[] { const metadata: string[] = []; // Helper function to format dates const formatDate = (date: number | undefined): string | undefined => { if (!date) return undefined; - const d = new Date(date); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( - 2, - "0" - )}-${String(d.getDate()).padStart(2, "0")}`; + const formatted = formatDateSmart(date, { includeSeconds: false }); + return formatted || undefined; }; const formattedDueDate = formatDate(updatedTask.metadata.dueDate); const formattedStartDate = formatDate(updatedTask.metadata.startDate); const formattedScheduledDate = formatDate( - updatedTask.metadata.scheduledDate + updatedTask.metadata.scheduledDate, ); const formattedCompletedDate = formatDate( - updatedTask.metadata.completedDate + updatedTask.metadata.completedDate, ); // Helper function to check if project is readonly @@ -497,7 +498,7 @@ export class CanvasTaskUpdater { let dependsList: string[] | undefined; if (Array.isArray(dependsValue)) { dependsList = dependsValue.filter( - (v) => typeof v === "string" && v.trim().length > 0 + (v) => typeof v === "string" && v.trim().length > 0, ); } else if (typeof dependsValue === "string") { dependsList = dependsValue @@ -508,7 +509,7 @@ export class CanvasTaskUpdater { if (dependsList && dependsList.length > 0) { const joined = dependsList.join(", "); metadata.push( - useDataviewFormat ? `[dependsOn:: ${joined}]` : `⛔ ${joined}` + useDataviewFormat ? `[dependsOn:: ${joined}]` : `⛔ ${joined}`, ); } @@ -516,7 +517,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[id:: ${updatedTask.metadata.id}]` - : `🆔 ${updatedTask.metadata.id}` + : `🆔 ${updatedTask.metadata.id}`, ); } @@ -524,7 +525,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[onCompletion:: ${updatedTask.metadata.onCompletion}]` - : `🏁 ${updatedTask.metadata.onCompletion}` + : `🏁 ${updatedTask.metadata.onCompletion}`, ); } @@ -533,7 +534,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[repeat:: ${updatedTask.metadata.recurrence}]` - : `🔁 ${updatedTask.metadata.recurrence}` + : `🔁 ${updatedTask.metadata.recurrence}`, ); } @@ -549,7 +550,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[start:: ${formattedStartDate}]` - : `🛫 ${formattedStartDate}` + : `🛫 ${formattedStartDate}`, ); } } @@ -566,7 +567,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[scheduled:: ${formattedScheduledDate}]` - : `⏳ ${formattedScheduledDate}` + : `⏳ ${formattedScheduledDate}`, ); } } @@ -583,7 +584,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[due:: ${formattedDueDate}]` - : `📅 ${formattedDueDate}` + : `📅 ${formattedDueDate}`, ); } } @@ -593,7 +594,7 @@ export class CanvasTaskUpdater { metadata.push( useDataviewFormat ? `[completion:: ${formattedCompletedDate}]` - : `✅ ${formattedCompletedDate}` + : `✅ ${formattedCompletedDate}`, ); } @@ -606,39 +607,40 @@ export class CanvasTaskUpdater { private removeExistingMetadata(line: string): string { let updatedLine = line; + const dateWithOptionalTime = + "\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)?"; + // Remove emoji dates - updatedLine = updatedLine.replace(/📅\s*\d{4}-\d{2}-\d{2}/g, ""); - updatedLine = updatedLine.replace(/🛫\s*\d{4}-\d{2}-\d{2}/g, ""); - updatedLine = updatedLine.replace(/⏳\s*\d{4}-\d{2}-\d{2}/g, ""); - updatedLine = updatedLine.replace(/✅\s*\d{4}-\d{2}-\d{2}/g, ""); - updatedLine = updatedLine.replace(/➕\s*\d{4}-\d{2}-\d{2}/g, ""); + ["📅", "📆", "✅", "➕", "⏳"].forEach((emoji) => { + updatedLine = updatedLine.replace( + new RegExp(`${emoji}\\s*${dateWithOptionalTime}`, "g"), + "", + ); + }); // Remove dataview dates (inline field format) - updatedLine = updatedLine.replace( - /\[(?:due|🗓️)::\s*\d{4}-\d{2}-\d{2}\]/gi, - "" - ); - updatedLine = updatedLine.replace( - /\[(?:completion|✅)::\s*\d{4}-\d{2}-\d{2}\]/gi, - "" - ); - updatedLine = updatedLine.replace( - /\[(?:created|➕)::\s*\d{4}-\d{2}-\d{2}\]/gi, - "" - ); - updatedLine = updatedLine.replace( - /\[(?:start|🛫)::\s*\d{4}-\d{2}-\d{2}\]/gi, - "" - ); - updatedLine = updatedLine.replace( - /\[(?:scheduled|⏳)::\s*\d{4}-\d{2}-\d{2}\]/gi, - "" - ); + const dataviewDatePatterns = [ + "(?:due|📅)", + "(?:completion|✅)", + "(?:created|➕)", + "(?:start|🛫)", + "(?:scheduled|⏳)", + ]; + + dataviewDatePatterns.forEach((pattern) => { + updatedLine = updatedLine.replace( + new RegExp( + `\\[${pattern}::\\s*${dateWithOptionalTime}\\]`, + "gi", + ), + "", + ); + }); // Remove emoji priority markers updatedLine = updatedLine.replace( /\s+(🔼|🔽|⏫|⏬|🔺|\[#[A-C]\])/g, - "" + "", ); // Remove dataview priority updatedLine = updatedLine.replace(/\[priority::\s*\w+\]/gi, ""); @@ -648,7 +650,7 @@ export class CanvasTaskUpdater { // Remove dataview recurrence updatedLine = updatedLine.replace( /\[(?:repeat|recurrence)::\s*[^\]]+\]/gi, - "" + "", ); // Remove dataview project and context (using configurable prefixes) @@ -662,17 +664,17 @@ export class CanvasTaskUpdater { ] || "@"; updatedLine = updatedLine.replace( new RegExp(`\\[${projectPrefix}::\\s*[^\\]]+\\]`, "gi"), - "" + "", ); updatedLine = updatedLine.replace( new RegExp(`\\[${contextPrefix}::\\s*[^\\]]+\\]`, "gi"), - "" + "", ); // Remove ALL existing tags to prevent duplication updatedLine = updatedLine.replace( /#[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g, - "" + "", ); updatedLine = updatedLine.replace(/@[^\s@]+/g, ""); @@ -684,7 +686,7 @@ export class CanvasTaskUpdater { */ public async deleteCanvasTask( task: Task, - deleteChildren: boolean = false + deleteChildren: boolean = false, ): Promise { try { // Get the Canvas file @@ -728,7 +730,11 @@ export class CanvasTaskUpdater { } // Delete the task from the text node - const deleteResult = this.deleteTaskFromTextNode(textNode, task, deleteChildren); + const deleteResult = this.deleteTaskFromTextNode( + textNode, + task, + deleteChildren, + ); if (!deleteResult.success) { return deleteResult; @@ -757,7 +763,7 @@ export class CanvasTaskUpdater { task: Task, targetFilePath: string, targetNodeId?: string, - targetSection?: string + targetSection?: string, ): Promise { try { // First, get the task content before deletion @@ -775,7 +781,7 @@ export class CanvasTaskUpdater { targetFilePath, taskContent, targetNodeId, - targetSection + targetSection, ); return addResult; @@ -795,7 +801,7 @@ export class CanvasTaskUpdater { targetFilePath?: string, targetNodeId?: string, targetSection?: string, - preserveMetadata: boolean = true + preserveMetadata: boolean = true, ): Promise { try { // Create duplicate task content @@ -805,7 +811,7 @@ export class CanvasTaskUpdater { // Reset completion status duplicateContent = duplicateContent.replace( /^(\s*[-*+]\s*\[)[xX\-](\])/, - "$1 $2" + "$1 $2", ); if (!preserveMetadata) { @@ -824,7 +830,7 @@ export class CanvasTaskUpdater { targetFile, duplicateContent, targetNodeId, - targetSection + targetSection, ); return addResult; @@ -843,7 +849,7 @@ export class CanvasTaskUpdater { filePath: string, taskContent: string, targetNodeId?: string, - targetSection?: string + targetSection?: string, ): Promise { try { // Get the Canvas file @@ -873,7 +879,7 @@ export class CanvasTaskUpdater { if (targetNodeId) { const existingNode = canvasData.nodes.find( (node): node is CanvasTextData => - node.type === "text" && node.id === targetNodeId + node.type === "text" && node.id === targetNodeId, ); if (!existingNode) { @@ -894,7 +900,7 @@ export class CanvasTaskUpdater { const addResult = this.addTaskToTextNode( targetNode, taskContent, - targetSection + targetSection, ); if (!addResult.success) { @@ -923,7 +929,7 @@ export class CanvasTaskUpdater { private deleteTaskFromTextNode( textNode: CanvasTextData, task: Task, - deleteChildren: boolean = false + deleteChildren: boolean = false, ): CanvasTaskUpdateResult { try { const lines = textNode.text.split("\n"); @@ -963,7 +969,10 @@ export class CanvasTaskUpdater { const currentIndent = this.getIndentLevel(line); // Stop if we reach a task at the same or higher level - if (this.isTaskLine(line) && currentIndent <= parentIndent) { + if ( + this.isTaskLine(line) && + currentIndent <= parentIndent + ) { break; } @@ -1008,7 +1017,7 @@ export class CanvasTaskUpdater { private addTaskToTextNode( textNode: CanvasTextData, taskContent: string, - targetSection?: string + targetSection?: string, ): CanvasTaskUpdateResult { try { const lines = textNode.text.split("\n"); @@ -1017,7 +1026,7 @@ export class CanvasTaskUpdater { // Find the target section and insert after it const sectionIndex = this.findSectionIndex( lines, - targetSection + targetSection, ); if (sectionIndex >= 0) { lines.splice(sectionIndex + 1, 0, taskContent); @@ -1063,7 +1072,7 @@ export class CanvasTaskUpdater { if (existingNodes.length > 0) { // Position new node to the right of existing nodes const maxX = Math.max( - ...existingNodes.map((node) => node.x + node.width) + ...existingNodes.map((node) => node.x + node.width), ); x = maxX + 50; } diff --git a/src/services/time-parsing-service.ts b/src/services/time-parsing-service.ts index b92b6436..6f2144ed 100644 --- a/src/services/time-parsing-service.ts +++ b/src/services/time-parsing-service.ts @@ -537,6 +537,43 @@ export class TimeParsingService { text: string, ): ParsedTimeResult | EnhancedParsedTimeResult { const safeText = text ?? ""; + const DATE_TIME_PATTERN = + /(\d{4}-\d{2}-\d{2})\s+(\d{1,2}):([0-5]\d)(?::([0-5]\d))?/; + + const normalizeParsedDate = ( + dateValue: Date, + textFragment: string, + timeExpr?: + | { + timeComponent?: TimeComponent; + rangeStart?: TimeComponent; + rangeEnd?: TimeComponent; + } + | undefined, + ): Date => { + const normalized = new Date(dateValue); + const manualMatch = textFragment.match(DATE_TIME_PATTERN); + const timeComponent = + timeExpr?.timeComponent ?? timeExpr?.rangeStart; + + const hour = + timeComponent?.hour ?? + (manualMatch ? Number(manualMatch[2]) : undefined); + const minute = + timeComponent?.minute ?? + (manualMatch ? Number(manualMatch[3]) : undefined); + const second = + timeComponent?.second ?? + (manualMatch && manualMatch[4] ? Number(manualMatch[4]) : 0); + + if (hour === undefined || minute === undefined) { + normalized.setHours(0, 0, 0, 0); + return normalized; + } + + normalized.setHours(hour, minute, second ?? 0, 0); + return normalized; + }; if (!this.config.enabled) { return { @@ -708,13 +745,21 @@ export class TimeParsingService { matchingTimeExpr.rangeEnd.hour; } + const normalizedDate = normalizeParsedDate( + date, + expressionText, + matchingTimeExpr, + ); + const expression: EnhancedTimeExpression = { text: expressionText, - date: date, + date: normalizedDate, type: type, index: index, length: length, - timeComponent: matchingTimeExpr?.timeComponent, + timeComponent: + matchingTimeExpr?.timeComponent ?? + matchingTimeExpr?.rangeStart, isTimeRange: matchingTimeExpr?.isRange || false, rangeStart: matchingTimeExpr?.rangeStart, rangeEnd: matchingTimeExpr?.rangeEnd, @@ -726,14 +771,16 @@ export class TimeParsingService { // Set the appropriate date field based on type switch (type) { case "start": - if (!result.startDate) result.startDate = date; + if (!result.startDate) + result.startDate = normalizedDate; break; case "due": - if (!result.dueDate) result.dueDate = date; + if (!result.dueDate) + result.dueDate = normalizedDate; break; case "scheduled": if (!result.scheduledDate) - result.scheduledDate = date; + result.scheduledDate = normalizedDate; break; default: console.warn( diff --git a/src/utils/date/date-utils.ts b/src/utils/date/date-utils.ts new file mode 100644 index 00000000..a0587134 --- /dev/null +++ b/src/utils/date/date-utils.ts @@ -0,0 +1,61 @@ +import { moment } from "obsidian"; + +type DateInput = number | string | Date | undefined | null; + +const toDate = (value: DateInput): Date | null => { + if (value === undefined || value === null) return null; + if (value instanceof Date) return value; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +/** + * Check whether a timestamp represents a date-only value (00:00:00.000). + */ +export function isDateOnly(timestamp: DateInput): boolean { + const date = toDate(timestamp); + if (!date) return false; + + return ( + date.getHours() === 0 && + date.getMinutes() === 0 && + date.getSeconds() === 0 && + date.getMilliseconds() === 0 + ); +} + +/** + * Format a timestamp using smart date/time detection. + * - 00:00:00.000 -> "YYYY-MM-DD" + * - Otherwise -> "YYYY-MM-DD HH:mm" (seconds optional) + */ +export function formatDateSmart( + timestamp: DateInput, + options?: { + forceFormat?: "date-only" | "date-time"; + includeSeconds?: boolean; + }, +): string { + const date = toDate(timestamp); + if (!date) return ""; + + const includeSeconds = options?.includeSeconds ?? false; + + if (options?.forceFormat === "date-only") { + return moment(date).format("YYYY-MM-DD"); + } + + if (options?.forceFormat === "date-time") { + return moment(date).format( + includeSeconds ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD HH:mm", + ); + } + + if (isDateOnly(date)) { + return moment(date).format("YYYY-MM-DD"); + } + + return moment(date).format( + includeSeconds ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD HH:mm", + ); +}