diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa1214690..51f55812c 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -23,3 +23,13 @@ Example: ``` --> + +## Fixed + +- Persist failed Google Calendar task-event deletions in plugin data and retry them after restart or reconnect, preventing orphaned task events when a task file is deleted while Google cleanup fails or sync is not ready. +- Track exported Google Calendar task events in plugin data so startup can recover cleanup for task files deleted while Obsidian was closed. +- Persist Google Calendar task sync requests while Google Calendar is not ready and replay the current task state after reconnect for scheduled, due, or both-date calendar modes. +- Restore cancelled Google Calendar event tombstones when a task is synced to an existing event ID, so deleted-but-still-addressable events become visible again. +- Prevent duplicate Google Calendar task events when concurrent syncs race before the newly created event ID reaches Obsidian metadata. +- Prevent pending intermediate status updates from overwriting completed Google Calendar task events when users quickly cycle a task to done. +- Mark Google Calendar events as completed when tasks were already done before they became calendar-eligible. diff --git a/src/bootstrap/pluginBootstrap.ts b/src/bootstrap/pluginBootstrap.ts index 2249d8e98..0042391a5 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -270,10 +270,11 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { plugin.taskCalendarSyncService = new (await import("../services/TaskCalendarSyncService")) .TaskCalendarSyncService(plugin, plugin.googleCalendarService); + plugin.taskCalendarSyncService.startDeletionQueueProcessor(); plugin.registerEvent( plugin.emitter.on("file-deleted", (data: FileDeletedEventData) => { - if (!plugin.taskCalendarSyncService?.isEnabled()) { + if (!plugin.taskCalendarSyncService) { return; } diff --git a/src/components/BatchContextMenu.ts b/src/components/BatchContextMenu.ts index e44fb6fef..3475f7bfa 100644 --- a/src/components/BatchContextMenu.ts +++ b/src/components/BatchContextMenu.ts @@ -334,7 +334,7 @@ export class BatchContextMenu { const file = plugin.app.vault.getAbstractFileByPath(path); if (file) { // Delete from Google Calendar before trashing file - if (plugin.taskCalendarSyncService?.isEnabled()) { + if (plugin.taskCalendarSyncService) { const task = await plugin.cacheManager.getTaskInfo(path); if (task?.googleCalendarEventId) { try { diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 2dcd35026..279871df9 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -466,14 +466,15 @@ export class TaskContextMenu { }); if (confirmed) { // Delete from Google Calendar before trashing file - if (plugin.taskCalendarSyncService?.isEnabled() && task.googleCalendarEventId) { - plugin.taskCalendarSyncService - .deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId) - .catch((error) => { - console.warn("Failed to delete task from Google Calendar:", error); - }); + if (plugin.taskCalendarSyncService && task.googleCalendarEventId) { + try { + await plugin.taskCalendarSyncService + .deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId); + } catch (error) { + console.warn("Failed to delete task from Google Calendar:", error); + } } - plugin.app.vault.trash(file, true); + await plugin.app.vault.trash(file, true); } }); }); diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 9cbc564aa..6d2c34856 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -602,6 +602,9 @@ export class GoogleCalendarService extends CalendarProvider { // Build update payload const payload: any = { ...currentEvent }; + if (payload.status === "cancelled") { + payload.status = "confirmed"; + } // Support both 'title' and 'summary' if (updates.title !== undefined || updates.summary !== undefined) { diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 01af8cc3a..6a6706a01 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -2,7 +2,12 @@ import { Notice, TFile } from "obsidian"; import { format } from "date-fns"; import TaskNotesPlugin from "../main"; import { GoogleCalendarService } from "./GoogleCalendarService"; -import { TaskInfo } from "../types"; +import { + GoogleCalendarEventIndexEntry, + PendingGoogleCalendarDeletion, + PendingGoogleCalendarSync, + TaskInfo, +} from "../types"; import { convertToGoogleRecurrence } from "../utils/rruleConverter"; import { TokenRefreshError } from "./errors"; @@ -16,6 +21,31 @@ const SYNC_CONCURRENCY_LIMIT = 5; * Google Calendar enforces ~10 req/s per-user; 100ms keeps us comfortably under that. */ const GOOGLE_API_CALL_SPACING_MS = 100; +/** Persistent plugin-data key for Google Calendar deletion retries */ +const GOOGLE_CALENDAR_DELETION_QUEUE_KEY = "googleCalendarDeletionQueue"; + +/** Persistent plugin-data key for task paths that currently own Google Calendar events */ +const GOOGLE_CALENDAR_EVENT_INDEX_KEY = "googleCalendarEventIndex"; + +/** Persistent plugin-data key for task paths that need Google Calendar sync replay */ +const GOOGLE_CALENDAR_SYNC_QUEUE_KEY = "googleCalendarSyncQueue"; + +/** How often to retry queued Google Calendar recovery work */ +const RECOVERY_QUEUE_PROCESSOR_INTERVAL_MS = 60000; + +type CalendarEventPayload = { + summary: string; + description?: string; + start: { date?: string; dateTime?: string; timeZone?: string }; + end: { date?: string; dateTime?: string; timeZone?: string }; + colorId?: string; + reminders?: { + useDefault: boolean; + overrides?: Array<{ method: string; minutes: number }>; + }; + recurrence?: string[]; +}; + /** * Service for syncing TaskNotes tasks to Google Calendar. * Handles creating, updating, and deleting calendar events when tasks change. @@ -25,6 +55,7 @@ export class TaskCalendarSyncService { private googleCalendarService: GoogleCalendarService; private rateLimitChain: Promise = Promise.resolve(); private lastApiCallAt = 0; + private recoveryQueueProcessorInterval: ReturnType | null = null; /** Debounce timers for pending syncs, keyed by task path */ private pendingSyncs: Map> = new Map(); @@ -38,6 +69,12 @@ export class TaskCalendarSyncService { /** Store the latest explicitly passed task object during debounce to avoid cache race conditions */ private pendingTasks: Map = new Map(); + /** In-flight create operations keyed by task path to avoid duplicate Google events */ + private pendingEventCreates: Map> = new Map(); + + /** Event IDs written during this session, used while Obsidian metadata catches up */ + private taskEventIdCache: Map = new Map(); + constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; this.googleCalendarService = googleCalendarService; @@ -50,9 +87,15 @@ export class TaskCalendarSyncService { for (const timer of this.pendingSyncs.values()) { clearTimeout(timer); } + if (this.recoveryQueueProcessorInterval) { + clearInterval(this.recoveryQueueProcessorInterval); + this.recoveryQueueProcessorInterval = null; + } this.pendingSyncs.clear(); this.previousTaskState.clear(); this.pendingTasks.clear(); + this.pendingEventCreates.clear(); + this.taskEventIdCache.clear(); } /** @@ -120,6 +163,480 @@ export class TaskCalendarSyncService { return enabled && hasTargetCalendar && isConnected; } + /** + * Start retrying persisted calendar recovery work. + */ + startDeletionQueueProcessor(): void { + if (this.recoveryQueueProcessorInterval) { + return; + } + + this.processStartupDeletionRecovery().catch((error) => { + console.error("[TaskCalendarSync] Failed to process recovery queues:", error); + }); + + this.recoveryQueueProcessorInterval = setInterval(() => { + this.processRecoveryQueues().catch((error) => { + console.error("[TaskCalendarSync] Failed to process recovery queues:", error); + }); + }, RECOVERY_QUEUE_PROCESSOR_INTERVAL_MS); + } + + private isDeletionQueueReady(): boolean { + const settings = this.plugin.settings.googleCalendarExport; + const isConnected = this.googleCalendarService.getAvailableCalendars().length > 0; + return !!settings?.enabled && !!settings?.syncOnTaskDelete && isConnected; + } + + private isSyncQueueReady(): boolean { + const settings = this.plugin.settings.googleCalendarExport; + const isConnected = this.googleCalendarService.getAvailableCalendars().length > 0; + return !!settings?.enabled && !!settings?.targetCalendarId && isConnected; + } + + private getDeletionQueueKey(item: Pick): string { + return `${item.calendarId}::${item.eventId}`; + } + + private isTaskCalendarEligible(task: TaskInfo): boolean { + if (task.archived) { + return false; + } + + const settings = this.plugin.settings.googleCalendarExport; + switch (settings.syncTrigger) { + case "scheduled": + return !!task.scheduled; + case "due": + return !!task.due; + case "both": + return !!task.scheduled || !!task.due; + default: + return false; + } + } + + private async getDeletionQueue(): Promise { + const data = await this.plugin.loadData(); + return data?.[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] || []; + } + + private async saveDeletionQueue(queue: PendingGoogleCalendarDeletion[]): Promise { + const data = (await this.plugin.loadData()) || {}; + data[GOOGLE_CALENDAR_DELETION_QUEUE_KEY] = queue; + await this.plugin.saveData(data); + } + + private async getEventIndex(): Promise { + const data = await this.plugin.loadData(); + return data?.[GOOGLE_CALENDAR_EVENT_INDEX_KEY] || []; + } + + private async saveEventIndex(index: GoogleCalendarEventIndexEntry[]): Promise { + const data = (await this.plugin.loadData()) || {}; + data[GOOGLE_CALENDAR_EVENT_INDEX_KEY] = index; + await this.plugin.saveData(data); + } + + private async getSyncQueue(): Promise { + const data = await this.plugin.loadData(); + return data?.[GOOGLE_CALENDAR_SYNC_QUEUE_KEY] || []; + } + + private async saveSyncQueue(queue: PendingGoogleCalendarSync[]): Promise { + const data = (await this.plugin.loadData()) || {}; + data[GOOGLE_CALENDAR_SYNC_QUEUE_KEY] = queue; + await this.plugin.saveData(data); + } + + private async upsertEventIndex( + taskPath: string, + calendarId: string, + eventId: string + ): Promise { + const index = await this.getEventIndex(); + const key = this.getDeletionQueueKey({ calendarId, eventId }); + const replacedEntries = index.filter( + (item) => + item.taskPath === taskPath && + item.calendarId === calendarId && + item.eventId !== eventId + ); + const filteredIndex = index.filter( + (item) => + this.getDeletionQueueKey(item) !== key && + !(item.taskPath === taskPath && item.calendarId === calendarId) + ); + + filteredIndex.push({ + taskPath, + calendarId, + eventId, + updatedAt: Date.now(), + }); + + await this.saveEventIndex(filteredIndex); + + for (const item of replacedEntries) { + const deleted = await this.deleteOrQueueCalendarEvent( + item.taskPath, + item.calendarId, + item.eventId + ); + if (!deleted) { + console.warn( + `[TaskCalendarSync] Replaced event cleanup queued for ${item.taskPath}` + ); + } + } + } + + private async removeEventIndexForTask(taskPath: string): Promise { + const index = await this.getEventIndex(); + const filteredIndex = index.filter((item) => item.taskPath !== taskPath); + + if (filteredIndex.length !== index.length) { + await this.saveEventIndex(filteredIndex); + } + } + + private async removeEventIndexForEvent(calendarId: string, eventId: string): Promise { + const index = await this.getEventIndex(); + const key = this.getDeletionQueueKey({ calendarId, eventId }); + const filteredIndex = index.filter((item) => this.getDeletionQueueKey(item) !== key); + + if (filteredIndex.length !== index.length) { + await this.saveEventIndex(filteredIndex); + } + } + + private getErrorStatus(error: any): number | undefined { + return error?.status ?? error?.statusCode; + } + + private isAlreadyDeletedError(error: any): boolean { + const status = this.getErrorStatus(error); + return status === 404 || status === 410; + } + + private getErrorMessage(error: any): string { + if (error instanceof Error) { + return error.message; + } + if (error?.message) { + return String(error.message); + } + return String(error); + } + + private async queueTaskSync(taskPath: string, error?: any, attempted = false): Promise { + const now = Date.now(); + const queue = await this.getSyncQueue(); + const existing = queue.find((item) => item.taskPath === taskPath); + const lastError = error ? this.getErrorMessage(error) : undefined; + + if (existing) { + existing.requestedAt = now; + if (attempted) { + existing.attempts += 1; + existing.lastAttemptAt = now; + } + if (lastError) { + existing.lastError = lastError; + } + } else { + queue.push({ + taskPath, + requestedAt: now, + attempts: attempted ? 1 : 0, + lastAttemptAt: attempted ? now : undefined, + lastError, + }); + } + + await this.saveSyncQueue(queue); + } + + private async removeFromDeletionQueue(calendarId: string, eventId: string): Promise { + const queue = await this.getDeletionQueue(); + const key = this.getDeletionQueueKey({ calendarId, eventId }); + const filteredQueue = queue.filter((item) => this.getDeletionQueueKey(item) !== key); + + if (filteredQueue.length !== queue.length) { + await this.saveDeletionQueue(filteredQueue); + } + } + + private async queueCalendarDeletion( + taskPath: string, + calendarId: string, + eventId: string, + error?: any, + attempted = false + ): Promise { + const now = Date.now(); + const queue = await this.getDeletionQueue(); + const key = this.getDeletionQueueKey({ calendarId, eventId }); + const existing = queue.find((item) => this.getDeletionQueueKey(item) === key); + const lastError = error ? this.getErrorMessage(error) : undefined; + + if (existing) { + existing.taskPath = taskPath; + if (attempted) { + existing.attempts += 1; + existing.lastAttemptAt = now; + } + if (lastError) { + existing.lastError = lastError; + } + } else { + queue.push({ + taskPath, + calendarId, + eventId, + createdAt: now, + attempts: attempted ? 1 : 0, + lastAttemptAt: attempted ? now : undefined, + lastError, + }); + } + + await this.saveDeletionQueue(queue); + } + + private async deleteOrQueueCalendarEvent( + taskPath: string, + calendarId: string, + eventId: string + ): Promise { + if (!this.plugin.settings.googleCalendarExport.syncOnTaskDelete) { + return true; + } + + if (!this.isDeletionQueueReady()) { + await this.queueCalendarDeletion( + taskPath, + calendarId, + eventId, + new Error("Google Calendar sync is not ready") + ); + return false; + } + + try { + await this.withGoogleRateLimit(() => + this.googleCalendarService.deleteEvent(calendarId, eventId) + ); + await this.removeFromDeletionQueue(calendarId, eventId); + return true; + } catch (error: any) { + if (this.isAlreadyDeletedError(error)) { + await this.removeFromDeletionQueue(calendarId, eventId); + return true; + } + + console.error("[TaskCalendarSync] Failed to delete event:", taskPath, error); + await this.queueCalendarDeletion(taskPath, calendarId, eventId, error, true); + return false; + } + } + + private async clearTaskEventIdIfMatching(item: PendingGoogleCalendarDeletion): Promise { + const task = await this.plugin.cacheManager.getTaskInfo(item.taskPath); + if (task?.googleCalendarEventId === item.eventId) { + await this.removeTaskEventId(item.taskPath); + } + } + + private async isQueuedDeletionStillNeeded( + item: PendingGoogleCalendarDeletion + ): Promise { + const task = await this.plugin.cacheManager.getTaskInfo(item.taskPath); + if (!task) { + return true; + } + + const currentEventId = this.getTaskEventId(task); + if (currentEventId !== item.eventId) { + return true; + } + + return !this.isTaskCalendarEligible(task); + } + + async processStartupDeletionRecovery(): Promise { + await this.recoverDeletedTaskEventsFromIndex(); + await this.processRecoveryQueues(); + } + + async processRecoveryQueues(): Promise { + await this.processDeletionQueue(); + await this.processPendingSyncQueue(); + } + + async recoverDeletedTaskEventsFromIndex(): Promise { + if (!this.plugin.settings.googleCalendarExport.syncOnTaskDelete) { + return; + } + + const targetCalendarId = this.plugin.settings.googleCalendarExport.targetCalendarId; + if (!targetCalendarId) { + return; + } + + const tasks = await this.plugin.cacheManager.getAllTasks(); + const activeTasksByEvent = new Map(); + + for (const task of tasks) { + const eventId = this.getTaskEventId(task); + if (!eventId) { + continue; + } + + const key = this.getDeletionQueueKey({ + calendarId: targetCalendarId, + eventId, + }); + activeTasksByEvent.set(key, task); + await this.upsertEventIndex(task.path, targetCalendarId, eventId); + } + + const index = await this.getEventIndex(); + for (const item of index) { + const activeTask = activeTasksByEvent.get(this.getDeletionQueueKey(item)); + if (activeTask && this.isTaskCalendarEligible(activeTask)) { + continue; + } + + await this.queueCalendarDeletion( + activeTask?.path || item.taskPath, + item.calendarId, + item.eventId, + activeTask + ? new Error("Indexed task no longer meets calendar sync criteria") + : new Error("Indexed task file no longer exists") + ); + } + } + + async processPendingSyncQueue(): Promise<{ synced: number; failed: number; deleted: number; dropped: number; remaining: number }> { + const results = { synced: 0, failed: 0, deleted: 0, dropped: 0, remaining: 0 }; + const queue = await this.getSyncQueue(); + + if (queue.length === 0) { + return results; + } + + if (!this.isSyncQueueReady()) { + results.remaining = queue.length; + return results; + } + + const dedupedQueue = new Map(); + for (const item of queue) { + dedupedQueue.set(item.taskPath, item); + } + + const remainingItems: PendingGoogleCalendarSync[] = []; + + for (const item of dedupedQueue.values()) { + const task = await this.plugin.cacheManager.getTaskInfo(item.taskPath); + if (!task) { + results.dropped++; + continue; + } + + if (!this.isTaskCalendarEligible(task)) { + const eventId = this.getTaskEventId(task); + if (eventId) { + const deleted = await this.deleteTaskFromCalendar(task); + if (!deleted) { + console.warn(`[TaskCalendarSync] Calendar deletion queued while replaying sync for ${item.taskPath}`); + } + results.deleted++; + } else { + results.dropped++; + } + continue; + } + + const synced = await this.syncTaskToCalendar(task, undefined, { queueOnFailure: false }); + if (synced) { + results.synced++; + continue; + } + + results.failed++; + remainingItems.push({ + ...item, + attempts: item.attempts + 1, + lastAttemptAt: Date.now(), + lastError: "Failed to replay queued Google Calendar sync", + }); + } + + results.remaining = remainingItems.length; + await this.saveSyncQueue(remainingItems); + return results; + } + + async processDeletionQueue(): Promise<{ deleted: number; failed: number; remaining: number }> { + const results = { deleted: 0, failed: 0, remaining: 0 }; + const queue = await this.getDeletionQueue(); + + if (queue.length === 0) { + return results; + } + + if (!this.isDeletionQueueReady()) { + results.remaining = queue.length; + return results; + } + + const dedupedQueue = new Map(); + for (const item of queue) { + dedupedQueue.set(this.getDeletionQueueKey(item), item); + } + + const remainingItems: PendingGoogleCalendarDeletion[] = []; + + for (const item of dedupedQueue.values()) { + try { + const deletionStillNeeded = await this.isQueuedDeletionStillNeeded(item); + if (!deletionStillNeeded) { + continue; + } + + await this.withGoogleRateLimit(() => + this.googleCalendarService.deleteEvent(item.calendarId, item.eventId) + ); + await this.clearTaskEventIdIfMatching(item); + await this.removeEventIndexForEvent(item.calendarId, item.eventId); + results.deleted++; + } catch (error: any) { + if (this.isAlreadyDeletedError(error)) { + await this.clearTaskEventIdIfMatching(item); + await this.removeEventIndexForEvent(item.calendarId, item.eventId); + results.deleted++; + continue; + } + + results.failed++; + remainingItems.push({ + ...item, + attempts: item.attempts + 1, + lastAttemptAt: Date.now(), + lastError: this.getErrorMessage(error), + }); + console.error("[TaskCalendarSync] Failed to retry queued event deletion:", item, error); + } + } + + results.remaining = remainingItems.length; + await this.saveDeletionQueue(remainingItems); + return results; + } + /** * Determine if a task should be synced based on settings and task properties */ @@ -148,7 +665,7 @@ export class TaskCalendarSyncService { * Get the Google Calendar event ID from the task's frontmatter */ getTaskEventId(task: TaskInfo): string | undefined { - return task.googleCalendarEventId; + return task.googleCalendarEventId || this.taskEventIdCache.get(task.path); } /** @@ -181,6 +698,12 @@ export class TaskCalendarSyncService { await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { frontmatter[fieldName] = eventId; }); + this.taskEventIdCache.set(taskPath, eventId); + + const targetCalendarId = this.plugin.settings.googleCalendarExport.targetCalendarId; + if (targetCalendarId) { + await this.upsertEventIndex(taskPath, targetCalendarId, eventId); + } } /** @@ -190,6 +713,8 @@ export class TaskCalendarSyncService { const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); if (!(file instanceof TFile)) { console.warn(`Cannot remove event ID: file not found at ${taskPath}`); + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTask(taskPath); return; } @@ -197,6 +722,8 @@ export class TaskCalendarSyncService { await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { delete frontmatter[fieldName]; }); + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTask(taskPath); } /** @@ -223,6 +750,13 @@ export class TaskCalendarSyncService { .trim(); } + private getCalendarEventTitle(task: TaskInfo): string { + const title = this.applyTitleTemplate(task); + return this.plugin.statusManager.isCompletedStatus(task.status) + ? `✓ ${title}` + : title; + } + /** * Build the event description from task properties */ @@ -522,18 +1056,7 @@ export class TaskCalendarSyncService { /** * Convert a task to a Google Calendar event payload */ - private taskToCalendarEvent(task: TaskInfo, clearRecurrence?: boolean): { - summary: string; - description?: string; - start: { date?: string; dateTime?: string; timeZone?: string }; - end: { date?: string; dateTime?: string; timeZone?: string }; - colorId?: string; - reminders?: { - useDefault: boolean; - overrides?: Array<{ method: string; minutes: number }>; - }; - recurrence?: string[]; - } | null { + private taskToCalendarEvent(task: TaskInfo, clearRecurrence?: boolean): CalendarEventPayload | null { const eventDate = this.getEventDate(task); if (!eventDate) return null; @@ -563,19 +1086,8 @@ export class TaskCalendarSyncService { }; const end = this.getEventEnd(adjustedStartInfo, task); - const event: { - summary: string; - description?: string; - start: { date?: string; dateTime?: string; timeZone?: string }; - end: { date?: string; dateTime?: string; timeZone?: string }; - colorId?: string; - reminders?: { - useDefault: boolean; - overrides?: Array<{ method: string; minutes: number }>; - }; - recurrence?: string[]; - } = { - summary: this.applyTitleTemplate(task), + const event: CalendarEventPayload = { + summary: this.getCalendarEventTitle(task), start, end, }; @@ -670,59 +1182,113 @@ export class TaskCalendarSyncService { return event; } + private async createCalendarEventForTask( + task: TaskInfo, + eventData: CalendarEventPayload, + calendarId: string + ): Promise { + const createdEvent = await this.withGoogleRateLimit(() => + this.googleCalendarService.createEvent( + calendarId, + { + ...eventData, + isAllDay: !!eventData.start.date, + } + ) + ); + + // Extract the actual event ID from the ICSEvent ID format. + // Format is "google-{calendarId}-{eventId}". Calendar IDs can contain + // hyphens, so strip the known prefix. + const prefix = `google-${calendarId}-`; + const eventId = createdEvent.id.startsWith(prefix) + ? createdEvent.id.slice(prefix.length) + : createdEvent.id; + + await this.saveTaskEventId(task.path, eventId); + return eventId; + } + /** * Sync a task to Google Calendar (create or update) */ - async syncTaskToCalendar(task: TaskInfo, previous?: TaskInfo): Promise { - if (!this.shouldSyncTask(task)) { - return; + async syncTaskToCalendar( + task: TaskInfo, + previous?: TaskInfo, + options: { queueOnFailure?: boolean } = {} + ): Promise { + const queueOnFailure = options.queueOnFailure ?? true; + + if (!this.isTaskCalendarEligible(task)) { + return true; } const settings = this.plugin.settings.googleCalendarExport; const existingEventId = this.getTaskEventId(task); + const targetCalendarId = settings.targetCalendarId; try { + if (!this.isEnabled()) { + if (queueOnFailure) { + await this.queueTaskSync( + task.path, + new Error("Google Calendar sync is not ready") + ); + } + return false; + } + // Check if recurrence was removed (previous had recurrence, current doesn't) const clearRecurrence = !!(previous?.recurrence && !task.recurrence); const eventData = this.taskToCalendarEvent(task, clearRecurrence); if (!eventData) { console.warn("[TaskCalendarSync] Could not convert task to event:", task.path); - return; + return false; + } + + if (!targetCalendarId) { + console.warn("[TaskCalendarSync] Cannot sync task without target calendar:", task.path); + if (queueOnFailure) { + await this.queueTaskSync( + task.path, + new Error("Google Calendar target calendar is not configured") + ); + } + return false; } if (existingEventId) { // Update existing event await this.withGoogleRateLimit(() => this.googleCalendarService.updateEvent( - settings.targetCalendarId, + targetCalendarId, existingEventId, eventData ) ); } else { - // Create new event — pass structured start/end objects to preserve timeZone - const createdEvent = await this.withGoogleRateLimit(() => - this.googleCalendarService.createEvent( - settings.targetCalendarId, - { - ...eventData, - isAllDay: !!eventData.start.date, - } - ) - ); - - // Extract the actual event ID from the ICSEvent ID format - // Format is "google-{calendarId}-{eventId}" - // Calendar IDs can contain hyphens, so strip the known prefix - const prefix = `google-${settings.targetCalendarId}-`; - const eventId = createdEvent.id.startsWith(prefix) - ? createdEvent.id.slice(prefix.length) - : createdEvent.id; + const pendingCreate = this.pendingEventCreates.get(task.path); + if (pendingCreate) { + const eventId = await pendingCreate; + await this.withGoogleRateLimit(() => + this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData) + ); + return true; + } - // Save the event ID to the task's frontmatter - await this.saveTaskEventId(task.path, eventId); + const createPromise = this.createCalendarEventForTask(task, eventData, targetCalendarId); + this.pendingEventCreates.set(task.path, createPromise); + try { + await createPromise; + } finally { + if (this.pendingEventCreates.get(task.path) === createPromise) { + this.pendingEventCreates.delete(task.path); + } + } } + + return true; } catch (error: any) { // Check if it's a 404 error (event was deleted externally) if (error.status === 404 && existingEventId) { @@ -731,11 +1297,14 @@ export class TaskCalendarSyncService { // Retry without the link - refetch task to get updated version const updatedTask = await this.plugin.cacheManager.getTaskInfo(task.path); if (updatedTask) { - return this.syncTaskToCalendar(updatedTask, previous); + return this.syncTaskToCalendar(updatedTask, previous, options); } } console.error("[TaskCalendarSync] Failed to sync task:", task.path, error); + if (queueOnFailure) { + await this.queueTaskSync(task.path, error, true); + } // Show user-friendly message for token refresh errors // TokenRefreshError indicates the OAuth connection expired and user needs to reconnect @@ -744,6 +1313,8 @@ export class TaskCalendarSyncService { } else { new Notice(this.plugin.i18n.translate("settings.integrations.googleCalendarExport.notices.syncFailed", { message: error.message })); } + + return false; } } @@ -812,6 +1383,22 @@ export class TaskCalendarSyncService { }); } + private cancelPendingTaskUpdate(taskPath: string): void { + const existingTimer = this.pendingSyncs.get(taskPath); + if (existingTimer) { + clearTimeout(existingTimer); + this.pendingSyncs.delete(taskPath); + this.pendingTasks.delete(taskPath); + } + } + + private async waitForInFlightTaskSync(taskPath: string): Promise { + const inFlight = this.inFlightSyncs.get(taskPath); + if (inFlight) { + await inFlight.catch(() => {}); + } + } + /** * Internal method that performs the actual task update sync */ @@ -819,11 +1406,11 @@ export class TaskCalendarSyncService { const existingEventId = this.getTaskEventId(task); // If task no longer meets sync criteria, delete the event - if (!this.shouldSyncTask(task)) { + if (!this.isTaskCalendarEligible(task)) { if (existingEventId) { const deleted = await this.deleteTaskFromCalendar(task); if (!deleted) { - throw new Error(`Failed to delete task from Google Calendar: ${task.path}`); + console.warn(`Google Calendar deletion queued for ${task.path}`); } } // Clean up previous state @@ -851,10 +1438,33 @@ export class TaskCalendarSyncService { return; } + this.cancelPendingTaskUpdate(task.path); + await this.waitForInFlightTaskSync(task.path); + + const completionPromise = this.executeTaskCompletion(task); + this.inFlightSyncs.set(task.path, completionPromise); + + try { + await completionPromise; + } finally { + if (this.inFlightSyncs.get(task.path) === completionPromise) { + this.inFlightSyncs.delete(task.path); + } + } + } + + private async executeTaskCompletion(task: TaskInfo): Promise { const settings = this.plugin.settings.googleCalendarExport; - const existingEventId = this.getTaskEventId(task); + let existingEventId = this.getTaskEventId(task); if (!existingEventId) { - return; + const synced = await this.syncTaskToCalendar(task); + if (!synced) { + return; + } + existingEventId = this.getTaskEventId(task); + if (!existingEventId) { + return; + } } // For recurring tasks, update EXDATE to exclude completed instance @@ -865,7 +1475,6 @@ export class TaskCalendarSyncService { try { // Update the event title to indicate completion - const completedTitle = `✓ ${this.applyTitleTemplate(task)}`; const description = settings.includeDescription ? this.buildEventDescription(task) : undefined; @@ -875,7 +1484,7 @@ export class TaskCalendarSyncService { settings.targetCalendarId, existingEventId, { - summary: completedTitle, + summary: this.getCalendarEventTitle(task), description, } ) @@ -942,24 +1551,18 @@ export class TaskCalendarSyncService { return true; } - let deleteFailed = false; - - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent( - settings.targetCalendarId, - existingEventId - ) - ); - } catch (error: any) { - // 404 or 410 means event is already gone - that's fine - if (error.status !== 404 && error.status !== 410) { - deleteFailed = true; - console.error("[TaskCalendarSync] Failed to delete event:", task.path, error); - } + const targetCalendarId = settings.targetCalendarId; + if (!targetCalendarId) { + console.warn("[TaskCalendarSync] Cannot delete task event without target calendar:", task.path); + return false; } - if (deleteFailed) { + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + existingEventId + ); + if (!deleted) { return false; } @@ -971,24 +1574,41 @@ export class TaskCalendarSyncService { /** * Delete a task's calendar event by path (used when task is being deleted) */ - async deleteTaskFromCalendarByPath(taskPath: string, eventId: string): Promise { + async deleteTaskFromCalendarByPath( + taskPath: string, + eventId?: string, + ...additionalEventIds: Array + ): Promise { if (!this.plugin.settings.googleCalendarExport.syncOnTaskDelete) { - return; + return true; } const settings = this.plugin.settings.googleCalendarExport; + const eventIds = [eventId, ...additionalEventIds].filter( + (id): id is string => typeof id === "string" && id.length > 0 + ); - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent(settings.targetCalendarId, eventId) - ); - } catch (error: any) { - // 404 or 410 means event is already gone - that's fine - if (error.status !== 404 && error.status !== 410) { - console.error("[TaskCalendarSync] Failed to delete event:", taskPath, error); + if (eventIds.length === 0) { + return true; + } + + const targetCalendarId = settings.targetCalendarId; + if (!targetCalendarId) { + console.warn("[TaskCalendarSync] Cannot delete task events without target calendar:", taskPath); + return false; + } + + const results: boolean[] = []; + for (const id of eventIds) { + const deleted = await this.deleteOrQueueCalendarEvent(taskPath, targetCalendarId, id); + if (deleted) { + await this.removeEventIndexForEvent(targetCalendarId, id); } + results.push(deleted); } - // No need to remove from frontmatter since the task file is being deleted + + // No need to remove from frontmatter since the task file is being deleted. + return results.every(Boolean); } // handleTaskPathChange is no longer needed - event ID is stored in frontmatter @@ -1023,8 +1643,12 @@ export class TaskCalendarSyncService { // Process tasks in parallel with concurrency limit await this.processInParallel(tasksToSync, async (task) => { try { - await this.syncTaskToCalendar(task); - results.synced++; + const synced = await this.syncTaskToCalendar(task); + if (synced) { + results.synced++; + } else { + results.failed++; + } } catch (error) { results.failed++; console.error(`[TaskCalendarSync] Failed to sync task ${task.path}:`, error); @@ -1046,7 +1670,7 @@ export class TaskCalendarSyncService { * Remove all task-event links and optionally delete events. * Iterates over all tasks and removes the googleCalendarEventId from frontmatter. */ - async unlinkAllTasks(deleteEvents: boolean = false): Promise { + async unlinkAllTasks(deleteEvents = false): Promise { const settings = this.plugin.settings.googleCalendarExport; const tasks = await this.plugin.cacheManager.getAllTasks(); let unlinkedCount = 0; @@ -1058,15 +1682,20 @@ export class TaskCalendarSyncService { const eventId = task.googleCalendarEventId; if (deleteEvents) { - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent( - settings.targetCalendarId, - eventId - ) - ); - } catch (error) { - console.warn(`[TaskCalendarSync] Failed to delete event for ${task.path}:`, error); + const targetCalendarId = settings.targetCalendarId; + if (!targetCalendarId) { + console.warn(`[TaskCalendarSync] Cannot delete event without target calendar for ${task.path}`); + continue; + } + + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + eventId + ); + if (!deleted) { + console.warn(`[TaskCalendarSync] Event deletion queued; keeping link for ${task.path}`); + continue; } } diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 0ba30e8d0..c0de6e524 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -1234,7 +1234,7 @@ export class TaskService { } // Delete from Google Calendar first (before file deletion, so we have the event ID) - if (this.plugin.taskCalendarSyncService?.isEnabled() && task.googleCalendarEventId) { + if (this.plugin.taskCalendarSyncService && task.googleCalendarEventId) { try { await this.plugin.taskCalendarSyncService .deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId); diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 68d61f323..8fda3e094 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -1,4 +1,4 @@ -import { Notice, TFile, normalizePath, stringifyYaml } from "obsidian"; +import { TFile, stringifyYaml } from "obsidian"; import { EVENT_TASK_UPDATED, IWebhookNotifier, @@ -219,7 +219,7 @@ export class TaskCreationService { } if ( - plugin.taskCalendarSyncService?.isEnabled() && + plugin.taskCalendarSyncService && plugin.settings.googleCalendarExport.syncOnTaskCreate ) { plugin.taskCalendarSyncService.syncTaskToCalendar(taskInfo).catch((error) => { diff --git a/src/types.ts b/src/types.ts index 5d777591d..98f620a04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -847,6 +847,31 @@ export interface PendingAutoArchive { statusValue: string; } +export interface PendingGoogleCalendarDeletion { + taskPath: string; + calendarId: string; + eventId: string; + createdAt: number; + attempts: number; + lastAttemptAt?: number; + lastError?: string; +} + +export interface GoogleCalendarEventIndexEntry { + taskPath: string; + calendarId: string; + eventId: string; + updatedAt: number; +} + +export interface PendingGoogleCalendarSync { + taskPath: string; + requestedAt: number; + attempts: number; + lastAttemptAt?: number; + lastError?: string; +} + // Webhook notification interface for loose coupling export interface IWebhookNotifier { triggerWebhook(event: WebhookEvent, data: any): Promise; diff --git a/tests/services/GoogleCalendarService.test.ts b/tests/services/GoogleCalendarService.test.ts index 6f3686794..6fd2c92dc 100644 --- a/tests/services/GoogleCalendarService.test.ts +++ b/tests/services/GoogleCalendarService.test.ts @@ -422,6 +422,47 @@ describe('GoogleCalendarService', () => { ); }); + test('should restore cancelled events when updating existing event IDs', async () => { + mockRequestUrl.mockResolvedValueOnce({ + status: 200, + json: { + id: 'event1', + status: 'cancelled', + summary: 'Deleted Task', + start: { date: '2026-04-29' }, + end: { date: '2026-04-30' } + }, + text: '', + arrayBuffer: new ArrayBuffer(0), + headers: {} + }); + + mockRequestUrl.mockResolvedValueOnce({ + status: 200, + json: { + id: 'event1', + status: 'confirmed', + summary: 'Restored Task', + start: { date: '2026-04-29' }, + end: { date: '2026-04-30' }, + htmlLink: 'https://calendar.google.com/event' + }, + text: '', + arrayBuffer: new ArrayBuffer(0), + headers: {} + }); + + await service.updateEvent('primary', 'event1', { + summary: 'Restored Task', + start: { date: '2026-04-29' }, + end: { date: '2026-04-30' } + }); + + const requestBody = JSON.parse(mockRequestUrl.mock.calls[1][0].body as string); + expect(requestBody.status).toBe('confirmed'); + expect(requestBody.summary).toBe('Restored Task'); + }); + test('should handle converting timed event to all-day', async () => { const updates = { start: '2025-10-23', diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts index 21c0dd4be..6c594f2eb 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -13,14 +13,22 @@ describe("TaskCalendarSyncService", () => { settings: { googleCalendarExport: { syncOnTaskUpdate: true, + syncOnTaskComplete: true, + enabled: true, targetCalendarId: "test-calendar", + eventTitleTemplate: "{{title}}", + includeDescription: false, + syncTrigger: "scheduled", + createAsAllDay: true, + defaultEventDuration: 60, } }, cacheManager: { getTaskInfo: jest.fn() }, statusManager: { - getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" }) + getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" }), + isCompletedStatus: jest.fn((status?: string) => status === "done") }, priorityManager: { getPriorityConfig: jest.fn().mockReturnValue({ label: "High" }) @@ -31,6 +39,7 @@ describe("TaskCalendarSyncService", () => { }; mockGoogleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "test-calendar" }]), updateEvent: jest.fn().mockResolvedValue({}), createEvent: jest.fn().mockResolvedValue({ id: "test-id" }) }; @@ -78,4 +87,55 @@ describe("TaskCalendarSyncService", () => { expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1); expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload); }); + + it("should cancel a pending status update before syncing completion", async () => { + syncService.withGoogleRateLimit = (fn: () => Promise) => fn(); + + const taskPath = "test/path.md"; + const somedayPayload: TaskInfo = { + path: taskPath, + title: "Task Title", + status: "someday", + scheduled: "2026-04-29", + googleCalendarEventId: "event-1" + }; + const donePayload: TaskInfo = { + ...somedayPayload, + status: "done" + }; + + syncService.updateTaskInCalendar(somedayPayload); + await syncService.completeTaskInCalendar(donePayload); + + jest.advanceTimersByTime(500); + await Promise.resolve(); + await Promise.resolve(); + + expect(syncService.executeTaskUpdate).not.toHaveBeenCalled(); + expect(mockGoogleCalendarService.updateEvent).toHaveBeenCalledTimes(1); + expect(mockGoogleCalendarService.updateEvent).toHaveBeenCalledWith( + "test-calendar", + "event-1", + { + summary: "✓ Task Title", + description: undefined + } + ); + }); + + it("should mark already-completed tasks when a later schedule change creates a calendar event", () => { + const event = syncService.taskToCalendarEvent({ + path: "test/path.md", + title: "Task Title", + status: "done", + scheduled: "2026-04-29" + } as TaskInfo); + + expect(event).toEqual( + expect.objectContaining({ + summary: "✓ Task Title", + start: { date: "2026-04-29" } + }) + ); + }); }); diff --git a/tests/unit/issues/issue-google-calendar-archive-reliability.test.ts b/tests/unit/issues/issue-google-calendar-archive-reliability.test.ts index 96463d92f..01c8ba313 100644 --- a/tests/unit/issues/issue-google-calendar-archive-reliability.test.ts +++ b/tests/unit/issues/issue-google-calendar-archive-reliability.test.ts @@ -31,6 +31,7 @@ const createGoogleCleanupEnabledPlugin = () => describe("Google Calendar archive reliability", () => { it("preserves the Google Calendar event ID when deletion fails so cleanup can be retried", async () => { const frontmatter: Record = {}; + const pluginData: Record = {}; const plugin: any = { settings: { googleCalendarExport: { @@ -83,6 +84,14 @@ describe("Google Calendar archive reliability", () => { getTaskInfo: jest.fn().mockResolvedValue(null), getAllTasks: jest.fn().mockResolvedValue([]), }, + loadData: jest.fn().mockImplementation(async () => pluginData), + saveData: jest.fn().mockImplementation(async (data: Record) => { + const nextData = { ...data }; + for (const key of Object.keys(pluginData)) { + delete pluginData[key]; + } + Object.assign(pluginData, nextData); + }), }; const googleCalendarService = { getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), @@ -105,6 +114,13 @@ describe("Google Calendar archive reliability", () => { expect(deleted).toBe(false); expect(frontmatter.googleCalendarEventId).toBe("master-event-id"); + expect(pluginData.googleCalendarDeletionQueue).toEqual([ + expect.objectContaining({ + calendarId: "primary", + eventId: "master-event-id", + taskPath: "TaskNotes/Tasks/archive-me.md", + }), + ]); }); it("keeps an auto-archive queue item pending when Google cleanup is still incomplete after archiving", async () => { diff --git a/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts new file mode 100644 index 000000000..8b4732a82 --- /dev/null +++ b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts @@ -0,0 +1,552 @@ +import { describe, it, expect, jest } from "@jest/globals"; + +import { TaskCalendarSyncService } from "../../../src/services/TaskCalendarSyncService"; +import { EventNotFoundError } from "../../../src/services/errors"; +import { PluginFactory, TaskFactory } from "../../helpers/mock-factories"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: class MockTFile { + path: string; + + constructor(path = "") { + this.path = path; + } + }, +})); + +const createPlugin = (pluginData: Record = {}, calendarSettings = {}) => { + const basePlugin = PluginFactory.createMockPlugin(); + const plugin = PluginFactory.createMockPlugin({ + settings: { + ...basePlugin.settings, + googleCalendarExport: { + enabled: true, + targetCalendarId: "primary", + syncOnTaskCreate: true, + syncOnTaskUpdate: true, + syncOnTaskComplete: true, + syncOnTaskDelete: true, + eventTitleTemplate: "{{title}}", + includeDescription: false, + eventColorId: null, + syncTrigger: "scheduled", + createAsAllDay: true, + defaultEventDuration: 60, + includeObsidianLink: false, + defaultReminderMinutes: null, + ...calendarSettings, + }, + }, + }); + + plugin.loadData = jest.fn().mockImplementation(async () => pluginData); + plugin.saveData = jest.fn().mockImplementation(async (data: Record) => { + const nextData = { ...data }; + for (const key of Object.keys(pluginData)) { + delete pluginData[key]; + } + Object.assign(pluginData, nextData); + }); + plugin.statusManager = { + ...plugin.statusManager, + getStatusConfig: jest.fn().mockReturnValue(null), + }; + plugin.priorityManager = { + ...plugin.priorityManager, + getPriorityConfig: jest.fn().mockReturnValue(null), + }; + + return plugin; +}; + +const createGoogleCalendarService = (overrides: Record = {}) => ({ + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn(), + updateEvent: jest.fn(), + deleteEvent: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +describe("Google Calendar deletion retry queue", () => { + it("persists failed task-file deletion cleanup so it survives plugin restarts", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData); + const deleteError = Object.assign(new Error("temporary Google failure"), { status: 500 }); + const googleCalendarService = createGoogleCalendarService({ + deleteEvent: jest.fn().mockRejectedValue(deleteError), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const deleted = await syncService.deleteTaskFromCalendarByPath( + "TaskNotes/Tasks/delete-me.md", + "primary-event-id" + ); + + expect(deleted).toBe(false); + expect(pluginData.googleCalendarDeletionQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/delete-me.md", + calendarId: "primary", + eventId: "primary-event-id", + attempts: 1, + lastError: "temporary Google failure", + }), + ]); + }); + + it("dedupes queued cleanup by calendar and event id while preserving retry metadata", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + deleteEvent: jest + .fn() + .mockRejectedValueOnce(Object.assign(new Error("first failure"), { status: 500 })) + .mockRejectedValueOnce(Object.assign(new Error("second failure"), { status: 500 })), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.deleteTaskFromCalendarByPath("TaskNotes/Tasks/a.md", "event-1"); + await syncService.deleteTaskFromCalendarByPath("TaskNotes/Tasks/a.md", "event-1"); + + expect(pluginData.googleCalendarDeletionQueue).toHaveLength(1); + expect(pluginData.googleCalendarDeletionQueue[0]).toEqual( + expect.objectContaining({ + calendarId: "primary", + eventId: "event-1", + attempts: 2, + lastError: "second failure", + }) + ); + }); + + it("retries persisted cleanup and clears the queue after a later successful deletion", async () => { + const pluginData = { + googleCalendarDeletionQueue: [ + { + taskPath: "TaskNotes/Tasks/delete-me.md", + calendarId: "primary", + eventId: "event-1", + createdAt: 1, + attempts: 1, + lastAttemptAt: 1, + lastError: "temporary Google failure", + }, + ], + }; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processDeletionQueue(); + + expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 }); + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-1"); + expect(pluginData.googleCalendarDeletionQueue).toEqual([]); + }); + + it("treats already-deleted Google events as successful cleanup", async () => { + const pluginData = { + googleCalendarDeletionQueue: [ + { + taskPath: "TaskNotes/Tasks/delete-me.md", + calendarId: "primary", + eventId: "missing-event", + createdAt: 1, + attempts: 1, + lastAttemptAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + deleteEvent: jest.fn().mockRejectedValue(new EventNotFoundError("missing-event")), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processDeletionQueue(); + + expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 }); + expect(pluginData.googleCalendarDeletionQueue).toEqual([]); + }); + + it("queues deletion from a previous frontmatter event id when sync is not ready", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + getAvailableCalendars: jest.fn().mockReturnValue([]), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const deleted = await syncService.deleteTaskFromCalendarByPath( + "TaskNotes/Tasks/external-delete.md", + "event-from-prev-cache" + ); + + expect(deleted).toBe(false); + expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarDeletionQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/external-delete.md", + calendarId: "primary", + eventId: "event-from-prev-cache", + attempts: 0, + lastError: "Google Calendar sync is not ready", + }), + ]); + }); + + it("queues primary and recurring exception event ids together when both are supplied", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + deleteEvent: jest + .fn() + .mockRejectedValue(Object.assign(new Error("temporary Google failure"), { status: 500 })), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const deleted = await syncService.deleteTaskFromCalendarByPath( + "TaskNotes/Tasks/recurring.md", + "primary-event-id", + "exception-event-id" + ); + + expect(deleted).toBe(false); + expect(pluginData.googleCalendarDeletionQueue).toEqual( + expect.arrayContaining([ + expect.objectContaining({ eventId: "primary-event-id", calendarId: "primary" }), + expect.objectContaining({ eventId: "exception-event-id", calendarId: "primary" }), + ]) + ); + }); + + it("recovers cleanup for indexed task events whose files were deleted while Obsidian was closed", async () => { + const pluginData = { + googleCalendarEventIndex: [ + { + taskPath: "TaskNotes/Tasks/deleted-while-closed.md", + calendarId: "primary", + eventId: "event-from-index", + updatedAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getAllTasks = jest.fn().mockResolvedValue([]); + plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue(null); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.recoverDeletedTaskEventsFromIndex(); + + expect(pluginData.googleCalendarDeletionQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/deleted-while-closed.md", + calendarId: "primary", + eventId: "event-from-index", + lastError: "Indexed task file no longer exists", + }), + ]); + + const result = await syncService.processDeletionQueue(); + + expect(result).toEqual({ deleted: 1, failed: 0, remaining: 0 }); + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-from-index"); + expect(pluginData.googleCalendarDeletionQueue).toEqual([]); + expect(pluginData.googleCalendarEventIndex).toEqual([]); + }); + + it("updates the event index instead of deleting events when an indexed task moved while Obsidian was closed", async () => { + const pluginData = { + googleCalendarEventIndex: [ + { + taskPath: "TaskNotes/Tasks/old-path.md", + calendarId: "primary", + eventId: "moved-event", + updatedAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getAllTasks = jest.fn().mockResolvedValue([ + TaskFactory.createTask({ + path: "TaskNotes/Tasks/new-path.md", + scheduled: "2026-04-29", + googleCalendarEventId: "moved-event", + }), + ]); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.recoverDeletedTaskEventsFromIndex(); + + expect(pluginData.googleCalendarDeletionQueue).toBeUndefined(); + expect(pluginData.googleCalendarEventIndex).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/new-path.md", + calendarId: "primary", + eventId: "moved-event", + }), + ]); + expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); + }); + + it("cleans up an older indexed event when the same task receives a replacement event id", async () => { + const pluginData = { + googleCalendarEventIndex: [ + { + taskPath: "TaskNotes/Tasks/status-race.md", + calendarId: "primary", + eventId: "old-event", + updatedAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + createEvent: jest.fn().mockResolvedValue({ id: "google-primary-new-event" }), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/status-race.md", + scheduled: "2026-04-29", + }) + ); + + expect(synced).toBe(true); + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "old-event"); + expect(pluginData.googleCalendarDeletionQueue).toBeUndefined(); + expect(pluginData.googleCalendarEventIndex).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/status-race.md", + calendarId: "primary", + eventId: "new-event", + }), + ]); + }); + + it("drops queued cleanup without deleting Google events when the task still exists and remains calendar-eligible", async () => { + const pluginData = { + googleCalendarDeletionQueue: [ + { + taskPath: "TaskNotes/Tasks/still-active.md", + calendarId: "primary", + eventId: "active-event", + createdAt: 1, + attempts: 1, + lastAttemptAt: 1, + lastError: "previous delete failure", + }, + ], + googleCalendarEventIndex: [ + { + taskPath: "TaskNotes/Tasks/still-active.md", + calendarId: "primary", + eventId: "active-event", + updatedAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/still-active.md", + scheduled: "2026-04-29", + googleCalendarEventId: "active-event", + }) + ); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processDeletionQueue(); + + expect(result).toEqual({ deleted: 0, failed: 0, remaining: 0 }); + expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarDeletionQueue).toEqual([]); + expect(pluginData.googleCalendarEventIndex).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/still-active.md", + eventId: "active-event", + }), + ]); + }); + + it("queues scheduled task sync when Google Calendar is not connected", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData); + const googleCalendarService = createGoogleCalendarService({ + getAvailableCalendars: jest.fn().mockReturnValue([]), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/offline-scheduled.md", + scheduled: "2026-04-29", + }) + ); + + expect(synced).toBe(false); + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarSyncQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/offline-scheduled.md", + attempts: 0, + lastError: "Google Calendar sync is not ready", + }), + ]); + }); + + it("queues due-date task sync when the calendar trigger is configured for due dates", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData, { syncTrigger: "due" }); + const googleCalendarService = createGoogleCalendarService({ + getAvailableCalendars: jest.fn().mockReturnValue([]), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/offline-due.md", + due: "2026-04-30", + }) + ); + + expect(synced).toBe(false); + expect(pluginData.googleCalendarSyncQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/offline-due.md", + }), + ]); + }); + + it("queues task sync when the calendar trigger is configured for scheduled or due dates", async () => { + const pluginData: Record = {}; + const plugin = createPlugin(pluginData, { syncTrigger: "both" }); + const googleCalendarService = createGoogleCalendarService({ + getAvailableCalendars: jest.fn().mockReturnValue([]), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/offline-both.md", + due: "2026-05-01", + }) + ); + + expect(synced).toBe(false); + expect(pluginData.googleCalendarSyncQueue).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/offline-both.md", + }), + ]); + }); + + it("replays queued task sync by creating the current task event after reconnect", async () => { + const pluginData = { + googleCalendarSyncQueue: [ + { + taskPath: "TaskNotes/Tasks/replay-create.md", + requestedAt: 1, + attempts: 0, + lastError: "Google Calendar sync is not ready", + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/replay-create.md", + scheduled: "2026-04-29", + }) + ); + const googleCalendarService = createGoogleCalendarService({ + createEvent: jest.fn().mockResolvedValue({ id: "google-primary-created-event-id" }), + }); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processPendingSyncQueue(); + + expect(result).toEqual({ synced: 1, failed: 0, deleted: 0, dropped: 0, remaining: 0 }); + expect(googleCalendarService.createEvent).toHaveBeenCalledWith( + "primary", + expect.objectContaining({ + start: { date: "2026-04-29" }, + }) + ); + expect(pluginData.googleCalendarSyncQueue).toEqual([]); + expect(pluginData.googleCalendarEventIndex).toEqual([ + expect.objectContaining({ + taskPath: "TaskNotes/Tasks/replay-create.md", + calendarId: "primary", + eventId: "created-event-id", + }), + ]); + }); + + it("replays queued task sync by updating the current task event after reconnect", async () => { + const pluginData = { + googleCalendarSyncQueue: [ + { + taskPath: "TaskNotes/Tasks/replay-update.md", + requestedAt: 1, + attempts: 0, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/replay-update.md", + scheduled: "2026-05-02", + googleCalendarEventId: "existing-event-id", + }) + ); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processPendingSyncQueue(); + + expect(result).toEqual({ synced: 1, failed: 0, deleted: 0, dropped: 0, remaining: 0 }); + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "existing-event-id", + expect.objectContaining({ + start: { date: "2026-05-02" }, + }) + ); + expect(pluginData.googleCalendarSyncQueue).toEqual([]); + }); + + it("replays queued task sync by deleting the event when the task no longer has the configured date", async () => { + const pluginData = { + googleCalendarSyncQueue: [ + { + taskPath: "TaskNotes/Tasks/replay-delete.md", + requestedAt: 1, + attempts: 0, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getTaskInfo = jest.fn().mockResolvedValue( + TaskFactory.createTask({ + path: "TaskNotes/Tasks/replay-delete.md", + scheduled: undefined, + googleCalendarEventId: "event-to-delete", + }) + ); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + const result = await syncService.processPendingSyncQueue(); + + expect(result).toEqual({ synced: 0, failed: 0, deleted: 1, dropped: 0, remaining: 0 }); + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith("primary", "event-to-delete"); + expect(pluginData.googleCalendarSyncQueue).toEqual([]); + }); +}); diff --git a/tests/unit/issues/issue-google-calendar-duplicate-investigation.test.ts b/tests/unit/issues/issue-google-calendar-duplicate-investigation.test.ts new file mode 100644 index 000000000..f7baf0221 --- /dev/null +++ b/tests/unit/issues/issue-google-calendar-duplicate-investigation.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, jest } from "@jest/globals"; +import { TFile } from "obsidian"; + +import { TaskCalendarSyncService } from "../../../src/services/TaskCalendarSyncService"; +import { TaskInfo } from "../../../src/types"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: class MockTFile { + path: string; + + constructor(path = "") { + this.path = path; + } + }, +})); + +const createPlugin = (frontmatter: Record) => ({ + settings: { + googleCalendarExport: { + enabled: true, + targetCalendarId: "primary", + syncOnTaskCreate: true, + syncOnTaskUpdate: true, + syncOnTaskComplete: true, + syncOnTaskDelete: true, + eventTitleTemplate: "{{title}}", + includeDescription: false, + eventColorId: null, + syncTrigger: "scheduled", + createAsAllDay: true, + defaultEventDuration: 60, + includeObsidianLink: false, + defaultReminderMinutes: null, + }, + }, + app: { + vault: { + getAbstractFileByPath: jest.fn().mockImplementation((path: string) => new TFile(path)), + getName: jest.fn().mockReturnValue("MyVault"), + }, + fileManager: { + processFrontMatter: jest + .fn() + .mockImplementation(async (_file: TFile, fn: (fm: Record) => void) => { + fn(frontmatter); + }), + }, + }, + fieldMapper: { + toUserField: jest.fn((field: string) => field), + }, + priorityManager: { + getPriorityConfig: jest.fn().mockReturnValue(null), + }, + statusManager: { + getStatusConfig: jest.fn().mockReturnValue(null), + isCompletedStatus: jest.fn((status?: string) => status === "done"), + }, + i18n: { + translate: jest.fn((key: string) => key), + }, + cacheManager: { + getTaskInfo: jest.fn().mockResolvedValue(null), + getAllTasks: jest.fn().mockResolvedValue([]), + }, + loadData: jest.fn().mockResolvedValue({}), + saveData: jest.fn().mockResolvedValue(undefined), +}); + +describe("Google Calendar duplicate investigation", () => { + it("does not create duplicate events when two syncs race before the event id reaches task metadata", async () => { + const frontmatter: Record = {}; + const plugin = createPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest + .fn() + .mockResolvedValue({ id: "google-primary-created-event-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any); + const task: TaskInfo = { + path: "TaskNotes/Tasks/race.md", + title: "Race", + status: "open", + priority: "normal", + scheduled: "2026-04-29", + archived: false, + }; + + await Promise.all([ + syncService.syncTaskToCalendar(task), + syncService.syncTaskToCalendar(task), + ]); + + expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1); + expect(frontmatter.googleCalendarEventId).toBe("created-event-id"); + }); + + it("updates the newly created event when a follow-up sync still has stale task metadata", async () => { + const frontmatter: Record = {}; + const plugin = createPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest + .fn() + .mockResolvedValue({ id: "google-primary-created-event-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any); + const task: TaskInfo = { + path: "TaskNotes/Tasks/stale.md", + title: "Stale", + status: "open", + priority: "normal", + scheduled: "2026-04-29", + archived: false, + }; + + await syncService.syncTaskToCalendar(task); + await syncService.syncTaskToCalendar({ + ...task, + scheduled: "2026-04-30", + }); + + expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1); + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "created-event-id", + expect.objectContaining({ + start: { date: "2026-04-30" }, + }) + ); + }); + + it("uses the existing event id when rescheduling a task that already has Google metadata", async () => { + const frontmatter: Record = {}; + const plugin = createPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn().mockResolvedValue({ id: "google-primary-new-event-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any); + + await syncService.syncTaskToCalendar({ + path: "TaskNotes/Tasks/reschedule.md", + title: "Reschedule", + status: "open", + priority: "normal", + scheduled: "2026-04-30", + archived: false, + googleCalendarEventId: "existing-event-id", + }); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "existing-event-id", + expect.objectContaining({ + start: { date: "2026-04-30" }, + }) + ); + }); + + it("does not leave a failed create in flight and allows a later retry", async () => { + const frontmatter: Record = {}; + const plugin = createPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest + .fn() + .mockRejectedValueOnce(new Error("create failed")) + .mockResolvedValueOnce({ id: "google-primary-created-event-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin as any, googleCalendarService as any); + const task: TaskInfo = { + path: "TaskNotes/Tasks/retry.md", + title: "Retry", + status: "open", + priority: "normal", + scheduled: "2026-04-29", + archived: false, + }; + + await syncService.syncTaskToCalendar(task); + await syncService.syncTaskToCalendar(task); + + expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(2); + expect(frontmatter.googleCalendarEventId).toBe("created-event-id"); + }); +});