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
This commit is contained in:
Quorafind 2025-11-26 10:30:06 +08:00
parent 42b466a826
commit 568dcbefa6
6 changed files with 358 additions and 175 deletions

View file

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

View file

@ -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<T>(operation: () => Promise<T>): Promise<T> {
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

View file

@ -17,7 +17,7 @@ export class DuplicateActionExecutor extends BaseActionExecutor {
*/
protected async executeForCanvas(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
config: OnCompletionConfig,
): Promise<OnCompletionExecutionResult> {
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<OnCompletionExecutionResult> {
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<OnCompletionExecutionResult> {
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();
}

View file

@ -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<CanvasTaskMetadata>,
updatedTask: Task<CanvasTaskMetadata>
updatedTask: Task<CanvasTaskMetadata>,
): Promise<CanvasTaskUpdateResult> {
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<CanvasTaskMetadata>,
deleteChildren: boolean = false
deleteChildren: boolean = false,
): Promise<CanvasTaskUpdateResult> {
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<CanvasTaskMetadata>,
targetFilePath: string,
targetNodeId?: string,
targetSection?: string
targetSection?: string,
): Promise<CanvasTaskUpdateResult> {
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<CanvasTaskUpdateResult> {
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<CanvasTaskUpdateResult> {
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;
}

View file

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

View file

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