From ce9366153fb419a0079077e4f013007abab9e7b8 Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Thu, 14 May 2026 10:16:33 +0100 Subject: [PATCH] Improve Google Calendar task sync recovery --- docs/releases/unreleased.md | 7 + src/bootstrap/pluginBootstrap.ts | 3 +- src/components/BatchContextMenu.ts | 2 +- src/components/TaskContextMenu.ts | 2 +- src/services/GoogleCalendarService.ts | 3 + src/services/TaskCalendarSyncService.ts | 925 +++++++++++++++--- src/services/TaskService.ts | 2 +- .../task-service/TaskCreationService.ts | 9 +- src/types.ts | 25 + tests/services/GoogleCalendarService.test.ts | 41 + .../services/TaskCalendarSyncService.test.ts | 103 +- ...oogle-calendar-archive-reliability.test.ts | 16 + ...google-calendar-delete-retry-queue.test.ts | 465 +++++++++ ...sue-google-calendar-duplicate-sync.test.ts | 167 ++++ 14 files changed, 1626 insertions(+), 144 deletions(-) create mode 100644 tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts create mode 100644 tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 67d0144ce..c44251d2d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -28,3 +28,10 @@ Example: - Made the markdown editor areas in task modals easier to click and focus. - Reduced false-positive plugin review warnings by making background auto-export and auto-archive schedulers non-overlapping and tightening type/string conversion paths. +- 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 5eae050a8..dd2c2d47b 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -313,10 +313,11 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { plugin.taskCalendarSyncService = new ( await import("../services/TaskCalendarSyncService") ).TaskCalendarSyncService(plugin, plugin.googleCalendarService); + plugin.taskCalendarSyncService.startRecoveryQueueProcessor(); 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 0f08b3dca..2a933be30 100644 --- a/src/components/BatchContextMenu.ts +++ b/src/components/BatchContextMenu.ts @@ -353,7 +353,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 c0a88264b..c754e1d7a 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -497,7 +497,7 @@ export class TaskContextMenu { if (confirmed) { // Delete from Google Calendar before trashing file if ( - plugin.taskCalendarSyncService?.isEnabled() && + plugin.taskCalendarSyncService && task.googleCalendarEventId ) { plugin.taskCalendarSyncService diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 6895b6b78..514f3eb78 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -645,6 +645,9 @@ export class GoogleCalendarService extends CalendarProvider { // Build update payload const payload: GoogleCalendarEventPayload = { ...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 71b162ab4..aa9dd5f84 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -2,8 +2,14 @@ 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 { stringifyUnknown } from "../utils/stringUtils"; import { TokenRefreshError } from "./errors"; /** Debounce delay for rapid task updates (ms) */ @@ -16,16 +22,62 @@ 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"; + +/** Delay between queued Google Calendar recovery retry attempts */ +const RECOVERY_QUEUE_RETRY_DELAY_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[]; +}; + function getErrorStatus(error: unknown): number | undefined { - return error !== null && - typeof error === "object" && - typeof (error as { status?: unknown }).status === "number" - ? (error as { status: number }).status - : undefined; + if (error === null || typeof error !== "object") { + return undefined; + } + + const { status, statusCode } = error as { status?: unknown; statusCode?: unknown }; + if (typeof status === "number") { + return status; + } + if (typeof statusCode === "number") { + return statusCode; + } + return undefined; } function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + if (error instanceof Error) { + return error.message; + } + if (error !== null && typeof error === "object") { + const message = (error as { message?: unknown }).message; + if (message !== undefined) { + return stringifyUnknown(message); + } + } + return stringifyUnknown(error); +} + +function isAlreadyDeletedError(error: unknown): boolean { + const status = getErrorStatus(error); + return status === 404 || status === 410; } /** @@ -37,6 +89,8 @@ export class TaskCalendarSyncService { private googleCalendarService: GoogleCalendarService; private rateLimitChain: Promise = Promise.resolve(); private lastApiCallAt = 0; + private recoveryQueueProcessorStarted = false; + private recoveryQueueProcessorTimeout: number | null = null; /** Debounce timers for pending syncs, keyed by task path */ private pendingSyncs: Map = new Map(); @@ -50,6 +104,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; @@ -62,9 +122,16 @@ export class TaskCalendarSyncService { for (const timer of this.pendingSyncs.values()) { window.clearTimeout(timer); } + this.recoveryQueueProcessorStarted = false; + if (this.recoveryQueueProcessorTimeout) { + window.clearTimeout(this.recoveryQueueProcessorTimeout); + this.recoveryQueueProcessorTimeout = null; + } this.pendingSyncs.clear(); this.previousTaskState.clear(); this.pendingTasks.clear(); + this.pendingEventCreates.clear(); + this.taskEventIdCache.clear(); } /** @@ -96,32 +163,26 @@ export class TaskCalendarSyncService { * Calls are queued and executed with at least GOOGLE_API_CALL_SPACING_MS between them. */ private withGoogleRateLimit(fn: () => Promise): Promise { - return new Promise((resolve, reject) => { - this.rateLimitChain = this.rateLimitChain.then( - async () => { - const now = Date.now(); - const wait = Math.max( - 0, - GOOGLE_API_CALL_SPACING_MS - (now - this.lastApiCallAt) - ); - if (wait > 0) { - await new Promise((r) => window.setTimeout(r, wait)); - } - try { - const result = await fn(); - this.lastApiCallAt = Date.now(); - resolve(result); - } catch (e) { - this.lastApiCallAt = Date.now(); - reject(e instanceof Error ? e : new Error(String(e))); - } - }, - () => { - // Previous call in chain failed; continue the chain - fn().then(resolve, reject); - } + const run = async (): Promise => { + const now = Date.now(); + const wait = Math.max( + 0, + GOOGLE_API_CALL_SPACING_MS - (now - this.lastApiCallAt) ); - }); + if (wait > 0) { + await new Promise((resolve) => window.setTimeout(resolve, wait)); + } + + try { + return await fn(); + } finally { + this.lastApiCallAt = Date.now(); + } + }; + + const operation = this.rateLimitChain.then(run, run); + this.rateLimitChain = operation.catch(() => undefined); + return operation; } /** @@ -138,6 +199,479 @@ export class TaskCalendarSyncService { return enabled && hasTargetCalendar && isConnected; } + /** + * Start retrying persisted calendar recovery work. + */ + startRecoveryQueueProcessor(): void { + if (this.recoveryQueueProcessorStarted) { + return; + } + + this.recoveryQueueProcessorStarted = true; + this.runRecoveryQueueProcessor(true); + } + + private runRecoveryQueueProcessor(includeStartupRecovery: boolean): void { + const recovery = includeStartupRecovery + ? this.processStartupRecovery() + : this.processRecoveryQueues(); + + recovery + .catch((error) => { + console.error("[TaskCalendarSync] Failed to process recovery queues:", error); + }) + .finally(() => { + this.scheduleRecoveryQueueProcessor(); + }); + } + + private scheduleRecoveryQueueProcessor(): void { + if (!this.recoveryQueueProcessorStarted || this.recoveryQueueProcessorTimeout) { + return; + } + + this.recoveryQueueProcessorTimeout = window.setTimeout(() => { + this.recoveryQueueProcessorTimeout = null; + this.runRecoveryQueueProcessor(false); + }, RECOVERY_QUEUE_RETRY_DELAY_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 async queueTaskSync(taskPath: string, error?: unknown, attempted = false): Promise { + const now = Date.now(); + const queue = await this.getSyncQueue(); + const existing = queue.find((item) => item.taskPath === taskPath); + const lastError = error ? 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?: unknown, + 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 ? 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: unknown) { + if (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 processStartupRecovery(): 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: unknown) { + if (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: 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 */ @@ -166,7 +700,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); } /** @@ -199,6 +733,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); + } } /** @@ -208,6 +748,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; } @@ -215,6 +757,8 @@ export class TaskCalendarSyncService { await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { delete frontmatter[fieldName]; }); + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTask(taskPath); } /** @@ -243,6 +787,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 */ @@ -557,21 +1108,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; @@ -601,19 +1138,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, }; @@ -714,56 +1240,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: unknown) { // Check if it's a 404 error (event was deleted externally) if (getErrorStatus(error) === 404 && existingEventId) { @@ -772,11 +1355,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 @@ -794,6 +1380,8 @@ export class TaskCalendarSyncService { ) ); } + + return false; } } @@ -865,6 +1453,22 @@ export class TaskCalendarSyncService { }); } + private cancelPendingTaskUpdate(taskPath: string): void { + const existingTimer = this.pendingSyncs.get(taskPath); + if (existingTimer) { + window.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 */ @@ -872,11 +1476,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 @@ -904,10 +1508,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 @@ -916,19 +1543,22 @@ export class TaskCalendarSyncService { return; } - try { - // Update the event title to indicate completion - const completedTitle = `✓ ${this.applyTitleTemplate(task)}`; - const description = settings.includeDescription - ? this.buildEventDescription(task) - : undefined; + try { + // Update the event title to indicate completion + const description = settings.includeDescription + ? this.buildEventDescription(task) + : undefined; - await this.withGoogleRateLimit(() => - this.googleCalendarService.updateEvent(settings.targetCalendarId, existingEventId, { - summary: completedTitle, - description, - }) - ); + await this.withGoogleRateLimit(() => + this.googleCalendarService.updateEvent( + settings.targetCalendarId, + existingEventId, + { + summary: this.getCalendarEventTitle(task), + description, + } + ) + ); } catch (error: unknown) { if (getErrorStatus(error) === 404) { // Event was deleted externally, clean up the link @@ -993,22 +1623,18 @@ export class TaskCalendarSyncService { return true; } - let deleteFailed = false; - - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent(settings.targetCalendarId, existingEventId) - ); - } catch (error: unknown) { - // 404 or 410 means event is already gone - that's fine - const status = getErrorStatus(error); - if (status !== 404 && 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; } @@ -1020,25 +1646,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: unknown) { - // 404 or 410 means event is already gone - that's fine - const status = getErrorStatus(error); - if (status !== 404 && 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 @@ -1082,8 +1724,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); @@ -1120,15 +1766,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 e59e32e38..c02a5057a 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -1289,7 +1289,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, diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 4a044aae8..2b55d7184 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -1,5 +1,10 @@ import { TFile, stringifyYaml } from "obsidian"; -import { EVENT_TASK_UPDATED, IWebhookNotifier, TaskCreationData, TaskInfo } from "../../types"; +import { + EVENT_TASK_UPDATED, + IWebhookNotifier, + TaskCreationData, + TaskInfo, +} from "../../types"; import { addDTSTARTToRecurrenceRule } from "../../core/recurrence"; import { FilenameContext, @@ -237,7 +242,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 9f003d0e3..ea09f82f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -856,6 +856,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: unknown): 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..f929d1daf 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -6,6 +6,14 @@ describe("TaskCalendarSyncService", () => { let mockPlugin: any; let mockGoogleCalendarService: any; + const deferred = () => { + let resolve!: () => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; + }; + beforeEach(() => { jest.useFakeTimers(); @@ -13,14 +21,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 +47,7 @@ describe("TaskCalendarSyncService", () => { }; mockGoogleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "test-calendar" }]), updateEvent: jest.fn().mockResolvedValue({}), createEvent: jest.fn().mockResolvedValue({ id: "test-id" }) }; @@ -78,4 +95,88 @@ 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" } + }) + ); + }); + + it("should retry recovery queues without overlapping runs", async () => { + const startupRecovery = deferred(); + const firstRetry = deferred(); + + syncService.processStartupRecovery = jest.fn().mockReturnValue(startupRecovery.promise); + syncService.processRecoveryQueues = jest.fn().mockReturnValue(firstRetry.promise); + + syncService.startRecoveryQueueProcessor(); + + expect(syncService.processStartupRecovery).toHaveBeenCalledTimes(1); + expect(syncService.processRecoveryQueues).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(60000); + expect(syncService.processRecoveryQueues).not.toHaveBeenCalled(); + + startupRecovery.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.advanceTimersByTime(60000); + expect(syncService.processRecoveryQueues).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(60000); + expect(syncService.processRecoveryQueues).toHaveBeenCalledTimes(1); + + firstRetry.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.advanceTimersByTime(60000); + expect(syncService.processRecoveryQueues).toHaveBeenCalledTimes(2); + }); }); 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..4f1d8ea29 --- /dev/null +++ b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts @@ -0,0 +1,465 @@ +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("dedupes failed task-file cleanup by 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({ + taskPath: "TaskNotes/Tasks/a.md", + 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 primary and recurring exception event ids together 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/recurring.md", + "primary-event-id", + "exception-event-id" + ); + + expect(deleted).toBe(false); + expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarDeletionQueue).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventId: "primary-event-id", + calendarId: "primary", + attempts: 0, + lastError: "Google Calendar sync is not ready", + }), + expect.objectContaining({ + eventId: "exception-event-id", + calendarId: "primary", + attempts: 0, + lastError: "Google Calendar sync is not ready", + }), + ]) + ); + }); + + 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 eligible 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 taskPath = "TaskNotes/Tasks/offline-scheduled.md"; + + const synced = await syncService.syncTaskToCalendar( + TaskFactory.createTask({ + path: taskPath, + scheduled: "2026-04-29", + }) + ); + + expect(synced).toBe(false); + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarSyncQueue).toEqual([ + expect.objectContaining({ + taskPath, + attempts: 0, + lastError: "Google Calendar sync is not ready", + }), + ]); + }); + + 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-sync.test.ts b/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts new file mode 100644 index 000000000..3a22ef20b --- /dev/null +++ b/tests/unit/issues/issue-google-calendar-duplicate-sync.test.ts @@ -0,0 +1,167 @@ +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 sync prevention", () => { + 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("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"); + }); +});