From 5ff79ac07592e108a10a74864e9307cb59bf308f Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:12:15 +0100 Subject: [PATCH] Fix Google Calendar task sync reliability and rendering --- docs/releases/unreleased.md | 19 + src/bases/calendar-core.ts | 142 +- src/bases/helpers.ts | 10 + src/bootstrap/pluginBootstrap.ts | 3 +- src/components/BatchContextMenu.ts | 10 +- src/components/TaskContextMenu.ts | 22 +- src/core/fieldMapping.ts | 45 + src/services/AutoArchiveService.ts | 2 +- src/services/GoogleCalendarService.ts | 3 + src/services/MdbaseSpecService.ts | 8 + src/services/TaskCalendarSyncService.ts | 1267 +++++++++++++++-- src/services/TaskService.ts | 205 ++- .../task-service/TaskCreationService.ts | 4 +- .../task-service/TaskUpdateService.ts | 65 +- src/settings/defaults.ts | 3 + src/types.ts | 32 + src/ui/TaskCard.ts | 2 +- src/utils/rruleConverter.ts | 17 +- tests/helpers/mock-factories.ts | 9 +- tests/services/GoogleCalendarService.test.ts | 41 + .../services/TaskCalendarSyncService.test.ts | 120 +- ...sue-1696-gcal-recurring-reschedule.test.ts | 307 +++- ...ocal-calendar-recurring-reschedule.test.ts | 92 ++ ...n-google-calendar-list-duplication.test.ts | 106 ++ ...oogle-calendar-archive-reliability.test.ts | 16 + ...google-calendar-delete-retry-queue.test.ts | 552 +++++++ ...e-calendar-duplicate-investigation.test.ts | 198 +++ 27 files changed, 3112 insertions(+), 188 deletions(-) create mode 100644 tests/unit/issues/issue-1696-local-calendar-recurring-reschedule.test.ts create mode 100644 tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts create mode 100644 tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts create mode 100644 tests/unit/issues/issue-google-calendar-duplicate-investigation.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa1214690..66a1d481f 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -23,3 +23,22 @@ Example: ``` --> + +## Fixed + +- (#1696) Fixed Google Calendar recurring tasks creating duplicate moved occurrences instead of converging on one series instance plus one detached exception event + - Scheduled-anchor recurring moves now preserve the original series date, add the correct Google `EXDATE`, and create or remove the detached Google event as the moved occurrence is resolved + - Archive, delete, and retry flows now clean up both the recurring master link and any detached exception link so stale Google events do not linger + - Thanks to @martin-forge for reporting, reproducing, and patching the recurring exception sync failure +- (#1823) Fixed zero-duration timed external calendar events rendering on multiple days in list-style calendar views + - Adds a minimal display duration before passing point-in-time external events to FullCalendar + - Preserves the original provider event data for context menus and debugging + - Thanks to @martin-forge for reporting and debugging +- 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. +- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links. diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 2ddf46d11..452cce1f9 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -578,11 +578,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal subscriptionName = subscription.name; } + const { start, end } = normalizeExternalTimedEventRange( + icsEvent.start, + icsEvent.end, + icsEvent.allDay + ); + return { id: icsEvent.id, title: icsEvent.title, - start: icsEvent.start, - end: icsEvent.end, + start: start, + end: end, allDay: icsEvent.allDay, backgroundColor: backgroundColor, borderColor: borderColor, @@ -602,6 +608,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal } } +/** + * FullCalendar list views can render a timed external event under multiple day + * headers when the provider supplies a true zero-duration range (end === start). + * Clamp those point-in-time external events to a minimal positive duration + * before handing them to FullCalendar, while preserving the raw provider event + * unchanged in extendedProps for display and debugging. + */ +function normalizeExternalTimedEventRange( + start: string, + end: string | undefined, + allDay: boolean +): { start: string; end?: string } { + if (allDay || !end) { + return { start, end }; + } + + const startDate = new Date(start); + const endDate = new Date(end); + + if ( + Number.isNaN(startDate.getTime()) || + Number.isNaN(endDate.getTime()) || + endDate.getTime() !== startDate.getTime() + ) { + return { start, end }; + } + + const normalizedEnd = new Date(endDate.getTime() + 1); + return { + start, + end: formatExternalTimedEventEnd(normalizedEnd, end), + }; +} + +function formatExternalTimedEventEnd(date: Date, originalEnd: string): string { + if (/Z$/i.test(originalEnd)) { + return date.toISOString(); + } + + const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/); + if (offsetMatch) { + const [, sign, hours, minutes] = offsetMatch; + const offsetMinutes = Number(hours) * 60 + Number(minutes); + const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1); + const shifted = new Date(date.getTime() + offsetMs); + const pad = (value: number, length = 2) => String(value).padStart(length, "0"); + const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`; + const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`; + return `${datePart}T${timePart}${sign}${hours}:${minutes}`; + } + + return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS"); +} + /** * Get recurring time from task recurrence rule */ @@ -744,6 +804,75 @@ export function createRecurringEvent( }; } +function buildRecurringInstanceExclusionSet( + task: TaskInfo, + nextScheduledDate: string +): Set { + const exclusions = new Set(); + const normalizeDateValue = (value: unknown): string | undefined => { + if (typeof value === "string") { + const normalized = getDatePart(value); + return typeof normalized === "string" && normalized ? normalized : undefined; + } + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return undefined; + return formatDateForStorage(value); + } + if (typeof value === "number") { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + return formatDateForStorage(date); + } + if (value && typeof value === "object") { + const record = value as Record; + if (record.date instanceof Date) { + if (Number.isNaN(record.date.getTime())) return undefined; + return formatDateForStorage(record.date); + } + if (typeof record.data === "string") { + return normalizeDateValue(record.data); + } + if (typeof (value as { toISOString?: () => string }).toISOString === "function") { + try { + return normalizeDateValue( + (value as { toISOString: () => string }).toISOString() + ); + } catch { + return undefined; + } + } + } + return undefined; + }; + const addDate = (value: unknown): void => { + const normalized = normalizeDateValue(value); + if (normalized) exclusions.add(normalized); + }; + + addDate(nextScheduledDate); + addDate(task.googleCalendarExceptionOriginalScheduled); + + if (Array.isArray(task.googleCalendarMovedOriginalDates)) { + for (const date of task.googleCalendarMovedOriginalDates) { + addDate(date); + } + } + + // Calendar pipeline sometimes flattens these values into customProperties. + const customProperties = task.customProperties as Record | undefined; + if (customProperties) { + addDate(customProperties.googleCalendarExceptionOriginalScheduled); + const movedDates = customProperties.googleCalendarMovedOriginalDates; + if (Array.isArray(movedDates)) { + for (const date of movedDates) { + addDate(date); + } + } + } + + return exclusions; +} + /** * Generate recurring task instances for calendar display */ @@ -761,6 +890,10 @@ export function generateRecurringTaskInstances( const hasOriginalTime = hasTimeComponent(task.scheduled); const templateTime = getRecurringTime(task); const nextScheduledDate = getDatePart(task.scheduled); + const recurringInstanceExclusions = buildRecurringInstanceExclusionSet( + task, + nextScheduledDate + ); // 1. Create next scheduled occurrence event const scheduledTime = hasOriginalTime ? getTimePart(task.scheduled) : null; @@ -802,8 +935,9 @@ export function generateRecurringTaskInstances( continue; } - // Skip if conflicts with next scheduled occurrence - if (instanceDate === nextScheduledDate) { + // Skip if this date is already represented by the concrete current occurrence + // or by known moved-occurrence exclusions. + if (recurringInstanceExclusions.has(instanceDate)) { continue; } diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 3ba954a8a..ea2474aac 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -89,11 +89,16 @@ function createTaskInfoFromProperties( "timeEstimate", "completedDate", "recurrence", + "recurrence_anchor", "dateCreated", "dateModified", "timeEntries", "reminders", "icsEventId", + "googleCalendarEventId", + "googleCalendarExceptionEventId", + "googleCalendarExceptionOriginalScheduled", + "googleCalendarMovedOriginalDates", "complete_instances", "skipped_instances", "blockedBy", @@ -162,6 +167,11 @@ function createTaskInfoFromProperties( totalTrackedTime: totalTrackedTime, reminders: props.reminders, icsEventId: props.icsEventId, + googleCalendarEventId: props.googleCalendarEventId, + googleCalendarExceptionEventId: props.googleCalendarExceptionEventId, + googleCalendarExceptionOriginalScheduled: props.googleCalendarExceptionOriginalScheduled, + googleCalendarMovedOriginalDates: props.googleCalendarMovedOriginalDates, + recurrence_anchor: props.recurrence_anchor, complete_instances: props.complete_instances, skipped_instances: props.skipped_instances, blockedBy: props.blockedBy, 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..daf6edd60 100644 --- a/src/components/BatchContextMenu.ts +++ b/src/components/BatchContextMenu.ts @@ -334,12 +334,16 @@ 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) { + if (task?.googleCalendarEventId || task?.googleCalendarExceptionEventId) { try { await plugin.taskCalendarSyncService - .deleteTaskFromCalendarByPath(path, task.googleCalendarEventId); + .deleteTaskFromCalendarByPath( + path, + task.googleCalendarEventId, + task.googleCalendarExceptionEventId + ); } catch (error) { console.warn("Failed to delete task from Google Calendar:", error); } diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 2dcd35026..6025f4477 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -466,14 +466,22 @@ 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 || task.googleCalendarExceptionEventId) + ) { + try { + await plugin.taskCalendarSyncService + .deleteTaskFromCalendarByPath( + task.path, + task.googleCalendarEventId, + task.googleCalendarExceptionEventId + ); + } 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/core/fieldMapping.ts b/src/core/fieldMapping.ts index 060235a2a..beb2c08fe 100644 --- a/src/core/fieldMapping.ts +++ b/src/core/fieldMapping.ts @@ -131,6 +131,32 @@ export function mapTaskFromFrontmatter( mapped.googleCalendarEventId = frontmatter[mapping.googleCalendarEventId]; } + if ( + mapping.googleCalendarExceptionEventId && + frontmatter[mapping.googleCalendarExceptionEventId] !== undefined + ) { + mapped.googleCalendarExceptionEventId = + frontmatter[mapping.googleCalendarExceptionEventId]; + } + + if ( + mapping.googleCalendarExceptionOriginalScheduled && + frontmatter[mapping.googleCalendarExceptionOriginalScheduled] !== undefined + ) { + mapped.googleCalendarExceptionOriginalScheduled = + frontmatter[mapping.googleCalendarExceptionOriginalScheduled]; + } + + if ( + mapping.googleCalendarMovedOriginalDates && + frontmatter[mapping.googleCalendarMovedOriginalDates] !== undefined + ) { + const movedDates = frontmatter[mapping.googleCalendarMovedOriginalDates]; + mapped.googleCalendarMovedOriginalDates = Array.isArray(movedDates) + ? movedDates + : [movedDates]; + } + if (frontmatter[mapping.reminders] !== undefined) { const reminders = frontmatter[mapping.reminders]; if (Array.isArray(reminders)) { @@ -268,6 +294,25 @@ export function mapTaskToFrontmatter( frontmatter[mapping.icsEventId] = taskData.icsEventId; } + if (taskData.googleCalendarEventId !== undefined) { + frontmatter[mapping.googleCalendarEventId] = taskData.googleCalendarEventId; + } + + if (taskData.googleCalendarExceptionEventId !== undefined) { + frontmatter[mapping.googleCalendarExceptionEventId] = + taskData.googleCalendarExceptionEventId; + } + + if (taskData.googleCalendarExceptionOriginalScheduled !== undefined) { + frontmatter[mapping.googleCalendarExceptionOriginalScheduled] = + taskData.googleCalendarExceptionOriginalScheduled; + } + + if (taskData.googleCalendarMovedOriginalDates !== undefined) { + frontmatter[mapping.googleCalendarMovedOriginalDates] = + taskData.googleCalendarMovedOriginalDates; + } + if (taskData.reminders !== undefined && taskData.reminders.length > 0) { frontmatter[mapping.reminders] = taskData.reminders; } diff --git a/src/services/AutoArchiveService.ts b/src/services/AutoArchiveService.ts index d1918bbcc..7eebb4494 100644 --- a/src/services/AutoArchiveService.ts +++ b/src/services/AutoArchiveService.ts @@ -16,7 +16,7 @@ export class AutoArchiveService { } private hasGoogleCalendarLink(task: TaskInfo): boolean { - return !!task.googleCalendarEventId; + return !!(task.googleCalendarEventId || task.googleCalendarExceptionEventId); } private getCalendarCleanupState(): "ready" | "retry" | "skip" { 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/MdbaseSpecService.ts b/src/services/MdbaseSpecService.ts index 4989e0d73..ccb28703c 100644 --- a/src/services/MdbaseSpecService.ts +++ b/src/services/MdbaseSpecService.ts @@ -230,6 +230,14 @@ export class MdbaseSpecService { items: { type: "string" }, }); this.addRoleField(lines, "googleCalendarEventId", { type: "string" }); + this.addRoleField(lines, "googleCalendarExceptionEventId", { type: "string" }); + this.addRoleField(lines, "googleCalendarExceptionOriginalScheduled", { + type: "string", + }); + this.addRoleField(lines, "googleCalendarMovedOriginalDates", { + type: "list", + items: { type: "date" }, + }); // User-defined fields if (settings.userFields && settings.userFields.length > 0) { diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 01af8cc3a..29e12a7aa 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 { getDatePart } from "../utils/dateUtils"; import { TokenRefreshError } from "./errors"; /** Debounce delay for rapid task updates (ms) */ @@ -16,6 +22,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 +56,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 +70,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 +88,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 +164,516 @@ 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, + eventRole: GoogleCalendarEventIndexEntry["eventRole"] = "primary" + ): Promise { + const index = await this.getEventIndex(); + const key = this.getDeletionQueueKey({ calendarId, eventId }); + const role = eventRole || "primary"; + const replacedEntries = index.filter( + (item) => + item.taskPath === taskPath && + item.calendarId === calendarId && + (item.eventRole || "primary") === role && + item.eventId !== eventId + ); + const filteredIndex = index.filter( + (item) => + this.getDeletionQueueKey(item) !== key && + !( + item.taskPath === taskPath && + item.calendarId === calendarId && + (item.eventRole || "primary") === role + ) + ); + + filteredIndex.push({ + taskPath, + calendarId, + eventId, + eventRole: role, + 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 removeEventIndexForTaskRole( + taskPath: string, + eventRole: GoogleCalendarEventIndexEntry["eventRole"] + ): Promise { + const role = eventRole || "primary"; + const index = await this.getEventIndex(); + const filteredIndex = index.filter( + (item) => !(item.taskPath === taskPath && (item.eventRole || "primary") === role) + ); + + 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); + } + if (task?.googleCalendarExceptionEventId === item.eventId) { + await this.saveTaskGoogleCalendarMetadata(item.taskPath, { + googleCalendarExceptionEventId: undefined, + }); + await this.removeEventIndexForTaskRole(item.taskPath, "exception"); + } + } + + private async isQueuedDeletionStillNeeded( + item: PendingGoogleCalendarDeletion + ): Promise { + const task = await this.plugin.cacheManager.getTaskInfo(item.taskPath); + if (!task) { + return true; + } + + const currentEventIds = [ + this.getTaskEventId(task), + this.getTaskExceptionEventId(task), + ].filter((eventId): eventId is string => !!eventId); + if (!currentEventIds.includes(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 taskEvents = [ + { eventId: this.getTaskEventId(task), eventRole: "primary" as const }, + { eventId: this.getTaskExceptionEventId(task), eventRole: "exception" as const }, + ].filter((event): event is { eventId: string; eventRole: "primary" | "exception" } => + !!event.eventId + ); + + for (const { eventId, eventRole } of taskEvents) { + const key = this.getDeletionQueueKey({ + calendarId: targetCalendarId, + eventId, + }); + activeTasksByEvent.set(key, task); + await this.upsertEventIndex(task.path, targetCalendarId, eventId, eventRole); + } + } + + 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 +702,14 @@ 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); + } + + /** + * Get the detached recurring exception event ID from the task's frontmatter. + */ + getTaskExceptionEventId(task: TaskInfo): string | undefined { + return task.googleCalendarExceptionEventId; } /** @@ -167,36 +728,181 @@ export class TaskCalendarSyncService { return anchor === "scheduled"; } + /** + * True when the task still has any Google recurring exception metadata. + */ + private hasStoredRecurringExceptionMetadata(task: TaskInfo): boolean { + return Boolean( + task.googleCalendarExceptionEventId || + task.googleCalendarExceptionOriginalScheduled || + (task.googleCalendarMovedOriginalDates && + task.googleCalendarMovedOriginalDates.length > 0) + ); + } + + /** + * Additional recurrence exclusions beyond completed/skipped instances. + */ + private getAdditionalRecurringExdates(task: TaskInfo): string[] { + const excluded = new Set(); + + if (Array.isArray(task.googleCalendarMovedOriginalDates)) { + for (const date of task.googleCalendarMovedOriginalDates) { + const normalized = getDatePart(date); + if (normalized) { + excluded.add(normalized); + } + } + } + + const pendingOriginal = getDatePart(task.googleCalendarExceptionOriginalScheduled || ""); + if (pendingOriginal) { + excluded.add(pendingOriginal); + } + + return Array.from(excluded).sort(); + } + /** * Save the Google Calendar event ID to the task's frontmatter */ private async saveTaskEventId(taskPath: string, eventId: string): Promise { + await this.saveTaskGoogleCalendarMetadata(taskPath, { + googleCalendarEventId: eventId, + }); + } + + /** + * Save Google Calendar metadata fields to frontmatter. + */ + private async saveTaskGoogleCalendarMetadata( + taskPath: string, + updates: Partial< + Pick< + TaskInfo, + | "googleCalendarEventId" + | "googleCalendarExceptionEventId" + | "googleCalendarExceptionOriginalScheduled" + | "googleCalendarMovedOriginalDates" + > + > + ): Promise { const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); if (!(file instanceof TFile)) { - console.warn(`Cannot save event ID: file not found at ${taskPath}`); + console.warn(`Cannot save Google Calendar metadata: file not found at ${taskPath}`); return; } - const fieldName = this.plugin.fieldMapper.toUserField("googleCalendarEventId"); + const eventIdField = + this.plugin.fieldMapper.toUserField("googleCalendarEventId") || + "googleCalendarEventId"; + const exceptionEventIdField = + this.plugin.fieldMapper.toUserField("googleCalendarExceptionEventId") || + "googleCalendarExceptionEventId"; + const exceptionOriginalField = + this.plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled") || + "googleCalendarExceptionOriginalScheduled"; + const movedDatesField = + this.plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates") || + "googleCalendarMovedOriginalDates"; + await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { - frontmatter[fieldName] = eventId; + if ("googleCalendarEventId" in updates) { + if (updates.googleCalendarEventId) { + frontmatter[eventIdField] = updates.googleCalendarEventId; + } else { + delete frontmatter[eventIdField]; + } + } + + if ("googleCalendarExceptionEventId" in updates) { + if (updates.googleCalendarExceptionEventId) { + frontmatter[exceptionEventIdField] = + updates.googleCalendarExceptionEventId; + } else { + delete frontmatter[exceptionEventIdField]; + } + } + + if ("googleCalendarExceptionOriginalScheduled" in updates) { + if (updates.googleCalendarExceptionOriginalScheduled) { + frontmatter[exceptionOriginalField] = + updates.googleCalendarExceptionOriginalScheduled; + } else { + delete frontmatter[exceptionOriginalField]; + } + } + + if ("googleCalendarMovedOriginalDates" in updates) { + if ( + updates.googleCalendarMovedOriginalDates && + updates.googleCalendarMovedOriginalDates.length > 0 + ) { + frontmatter[movedDatesField] = updates.googleCalendarMovedOriginalDates; + } else { + delete frontmatter[movedDatesField]; + } + } }); + + const targetCalendarId = this.plugin.settings.googleCalendarExport.targetCalendarId; + if ("googleCalendarEventId" in updates) { + if (updates.googleCalendarEventId) { + this.taskEventIdCache.set(taskPath, updates.googleCalendarEventId); + if (targetCalendarId) { + await this.upsertEventIndex( + taskPath, + targetCalendarId, + updates.googleCalendarEventId, + "primary" + ); + } + } else { + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTaskRole(taskPath, "primary"); + } + } + + if ( + "googleCalendarExceptionEventId" in updates && + updates.googleCalendarExceptionEventId && + targetCalendarId + ) { + await this.upsertEventIndex( + taskPath, + targetCalendarId, + updates.googleCalendarExceptionEventId, + "exception" + ); + } + if ("googleCalendarExceptionEventId" in updates && !updates.googleCalendarExceptionEventId) { + await this.removeEventIndexForTaskRole(taskPath, "exception"); + } } /** - * Remove the Google Calendar event ID from the task's frontmatter + * Remove all Google Calendar metadata from frontmatter. */ - private async removeTaskEventId(taskPath: string): Promise { - const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); - if (!(file instanceof TFile)) { - console.warn(`Cannot remove event ID: file not found at ${taskPath}`); - return; - } + private async clearTaskGoogleCalendarMetadata(taskPath: string): Promise { + await this.saveTaskGoogleCalendarMetadata(taskPath, { + googleCalendarEventId: undefined, + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + googleCalendarMovedOriginalDates: undefined, + }); + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTask(taskPath); + } - const fieldName = this.plugin.fieldMapper.toUserField("googleCalendarEventId"); - await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { - delete frontmatter[fieldName]; + /** + * Remove the Google Calendar event ID from the task's frontmatter + */ + private async removeTaskEventId(taskPath: string): Promise { + await this.saveTaskGoogleCalendarMetadata(taskPath, { + googleCalendarEventId: undefined, }); + this.taskEventIdCache.delete(taskPath); + await this.removeEventIndexForTaskRole(taskPath, "primary"); } /** @@ -223,6 +929,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 */ @@ -266,12 +979,12 @@ export class TaskCalendarSyncService { // Add contexts if (task.contexts && task.contexts.length > 0) { - parts.push(t("contexts", { value: task.contexts.map((c) => `@${c}`).join(", ") })); + parts.push(t("contexts", { value: task.contexts.map((c) => `@${this.toCalendarDescriptionLabel(c)}`).join(", ") })); } // Add projects if (task.projects && task.projects.length > 0) { - parts.push(t("projects", { value: task.projects.join(", ") })); + parts.push(t("projects", { value: task.projects.map((p) => this.toCalendarDescriptionLabel(p)).join(", ") })); } // Add separator before link @@ -280,19 +993,33 @@ export class TaskCalendarSyncService { parts.push("---"); } - // Add Obsidian link (as HTML anchor for clickability in Google Calendar) + // Add Obsidian link as a plain URI so external clients do not render raw HTML. if (settings.includeObsidianLink) { const vaultName = this.plugin.app.vault.getName(); const encodedPath = encodeURIComponent(task.path); const obsidianUri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedPath}`; - // Google Calendar renders HTML in descriptions, so use an anchor tag const linkText = t("openInObsidian"); - parts.push(`${linkText}`); + parts.push(`${linkText}: ${obsidianUri}`); } return parts.join("\n"); } + private toCalendarDescriptionLabel(value: string): string { + return value + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]]+)\]\]/g, (_match, target: string) => this.basenameForDisplay(target)) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .trim(); + } + + private basenameForDisplay(target: string): string { + const withoutHeading = target.split("#")[0]; + const withoutExtension = withoutHeading.replace(/\.md$/i, ""); + const basename = withoutExtension.split("/").pop(); + return basename || withoutExtension || target; + } + /** * Get the date to use for the calendar event based on settings */ @@ -522,18 +1249,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 +1279,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, }; @@ -630,6 +1335,7 @@ export class TaskCalendarSyncService { const recurrenceData = convertToGoogleRecurrence(task.recurrence, { completedInstances: task.complete_instances, skippedInstances: task.skipped_instances, + additionalExcludedDates: this.getAdditionalRecurringExdates(task), }); if (recurrenceData) { @@ -670,72 +1376,310 @@ 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) + * Whether a recurring task currently needs a detached Google exception event. + */ + private shouldCreateDetachedRecurringException(task: TaskInfo): boolean { + if (!this.shouldSyncAsRecurring(task)) { + return false; + } + + const movedScheduled = getDatePart(task.scheduled || ""); + const originalScheduled = getDatePart(task.googleCalendarExceptionOriginalScheduled || ""); + + return Boolean(movedScheduled && originalScheduled && movedScheduled !== originalScheduled); + } + + /** + * Build payload for detached recurring exception event. + * This always tracks the concrete moved scheduled occurrence, not the RRULE. + */ + private buildRecurringExceptionEvent(task: TaskInfo): { + 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 }>; + }; + } | null { + if (!task.scheduled) { + return null; + } + + const settings = this.plugin.settings.googleCalendarExport; + const startInfo = this.parseDateForEvent(task.scheduled); + + let start: { date?: string; dateTime?: string; timeZone?: string }; + if (settings.createAsAllDay && !startInfo.isAllDay) { + const localDate = new Date(task.scheduled); + start = { date: format(localDate, "yyyy-MM-dd") }; + } else if (startInfo.isAllDay) { + start = { date: startInfo.date }; + } else { + start = { dateTime: startInfo.dateTime, timeZone: startInfo.timeZone }; + } + + const adjustedStartInfo = { + ...startInfo, + isAllDay: settings.createAsAllDay || startInfo.isAllDay, + date: start.date, + dateTime: start.dateTime, + }; + 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 }>; + }; + } = { + summary: this.applyTitleTemplate(task), + start, + end, + }; + + if (settings.includeDescription) { + event.description = this.buildEventDescription(task); + } + + if (settings.eventColorId) { + event.colorId = settings.eventColorId; + } + + const taskReminders = this.convertTaskRemindersToGoogleFormat( + task, + task.scheduled, + "scheduled" + ); + + if (taskReminders && taskReminders.length > 0) { + event.reminders = { + useDefault: false, + overrides: taskReminders, + }; + } + + return event; + } + + /** + * Create/update/delete the detached Google event for the current moved recurring occurrence. */ - async syncTaskToCalendar(task: TaskInfo, previous?: TaskInfo): Promise { - if (!this.shouldSyncTask(task)) { + private async syncRecurringExceptionEvent(task: TaskInfo): Promise { + const settings = this.plugin.settings.googleCalendarExport; + const hasActiveException = this.shouldCreateDetachedRecurringException(task); + const existingExceptionEventId = this.getTaskExceptionEventId(task); + + if (!hasActiveException) { + if (existingExceptionEventId) { + try { + await this.withGoogleRateLimit(() => + this.googleCalendarService.deleteEvent( + settings.targetCalendarId, + existingExceptionEventId + ) + ); + } catch (error: any) { + if (error.status !== 404 && error.status !== 410) { + throw error; + } + } + } + + await this.saveTaskGoogleCalendarMetadata(task.path, { + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + }); return; } + const eventData = this.buildRecurringExceptionEvent(task); + if (!eventData) { + return; + } + + try { + if (existingExceptionEventId) { + await this.withGoogleRateLimit(() => + this.googleCalendarService.updateEvent( + settings.targetCalendarId, + existingExceptionEventId, + eventData + ) + ); + return; + } + + const createdEvent = await this.withGoogleRateLimit(() => + this.googleCalendarService.createEvent(settings.targetCalendarId, { + ...eventData, + isAllDay: !!eventData.start.date, + }) + ); + + const prefix = `google-${settings.targetCalendarId}-`; + const eventId = createdEvent.id.startsWith(prefix) + ? createdEvent.id.slice(prefix.length) + : createdEvent.id; + + await this.saveTaskGoogleCalendarMetadata(task.path, { + googleCalendarExceptionEventId: eventId, + googleCalendarExceptionOriginalScheduled: getDatePart( + task.googleCalendarExceptionOriginalScheduled || "" + ), + }); + } catch (error: any) { + if (error.status === 404 && existingExceptionEventId) { + await this.saveTaskGoogleCalendarMetadata(task.path, { + googleCalendarExceptionEventId: undefined, + }); + const updatedTask = await this.plugin.cacheManager.getTaskInfo(task.path); + if (updatedTask) { + return this.syncRecurringExceptionEvent(updatedTask); + } + } + throw error; + } + } + + /** + * Sync a task to Google Calendar (create or update) + */ + 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, - } - ) - ); + const pendingCreate = this.pendingEventCreates.get(task.path); + if (pendingCreate) { + const eventId = await pendingCreate; + await this.withGoogleRateLimit(() => + this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData) + ); + return true; + } - // 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 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); + } + } + } - // Save the event ID to the task's frontmatter - await this.saveTaskEventId(task.path, eventId); + // Keep the detached recurring exception event aligned with the current moved occurrence. + if (this.shouldSyncAsRecurring(task) || task.googleCalendarExceptionEventId) { + await this.syncRecurringExceptionEvent(task); } + + return true; } catch (error: any) { // Check if it's a 404 error (event was deleted externally) if (error.status === 404 && existingEventId) { // Clear the stale link and retry as create - await this.removeTaskEventId(task.path); + await this.saveTaskGoogleCalendarMetadata(task.path, { + googleCalendarEventId: undefined, + }); // 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 +1688,8 @@ export class TaskCalendarSyncService { } else { new Notice(this.plugin.i18n.translate("settings.integrations.googleCalendarExport.notices.syncFailed", { message: error.message })); } + + return false; } } @@ -812,6 +1758,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 +1781,11 @@ export class TaskCalendarSyncService { const existingEventId = this.getTaskEventId(task); // If task no longer meets sync criteria, delete the event - if (!this.shouldSyncTask(task)) { - if (existingEventId) { + if (!this.isTaskCalendarEligible(task)) { + if (existingEventId || this.hasStoredRecurringExceptionMetadata(task)) { 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 +1813,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 +1850,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 +1859,7 @@ export class TaskCalendarSyncService { settings.targetCalendarId, existingEventId, { - summary: completedTitle, + summary: this.getCalendarEventTitle(task), description, } ) @@ -905,6 +1889,7 @@ export class TaskCalendarSyncService { const recurrenceData = convertToGoogleRecurrence(task.recurrence, { completedInstances: task.complete_instances, skippedInstances: task.skipped_instances, + additionalExcludedDates: this.getAdditionalRecurringExdates(task), }); if (recurrenceData) { @@ -938,57 +1923,80 @@ export class TaskCalendarSyncService { const settings = this.plugin.settings.googleCalendarExport; const existingEventId = this.getTaskEventId(task); - if (!existingEventId) { + const exceptionEventId = this.getTaskExceptionEventId(task); + if (!existingEventId && !this.hasStoredRecurringExceptionMetadata(task)) { return true; } - let deleteFailed = false; + const targetCalendarId = settings.targetCalendarId; + if (!targetCalendarId) { + console.warn("[TaskCalendarSync] Cannot delete task event without target calendar:", task.path); + return false; + } - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent( - settings.targetCalendarId, - existingEventId - ) + const eventIds = [existingEventId, exceptionEventId].filter( + (id): id is string => typeof id === "string" && id.length > 0 + ); + let allDeleted = true; + for (const eventId of eventIds) { + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + eventId ); - } 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); + if (deleted) { + await this.removeEventIndexForEvent(targetCalendarId, eventId); + } else { + allDeleted = false; } } - if (deleteFailed) { + if (!allDeleted) { return false; } - // Only remove the event ID when deletion succeeded or the event is already gone - await this.removeTaskEventId(task.path); + await this.clearTaskGoogleCalendarMetadata(task.path); return true; } /** * 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 +2031,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,32 +2058,47 @@ 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; for (const task of tasks) { - if (!task.googleCalendarEventId) { + if (!task.googleCalendarEventId && !this.hasStoredRecurringExceptionMetadata(task)) { continue; } - const eventId = task.googleCalendarEventId; if (deleteEvents) { - try { - await this.withGoogleRateLimit(() => - this.googleCalendarService.deleteEvent( - settings.targetCalendarId, - eventId - ) + const targetCalendarId = settings.targetCalendarId; + if (!targetCalendarId) { + console.warn(`[TaskCalendarSync] Cannot delete event without target calendar for ${task.path}`); + continue; + } + + const eventIds = [ + task.googleCalendarEventId, + task.googleCalendarExceptionEventId, + ].filter((eventId): eventId is string => !!eventId); + let allDeleted = true; + for (const eventId of eventIds) { + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + eventId ); - } catch (error) { - console.warn(`[TaskCalendarSync] Failed to delete event for ${task.path}:`, error); + if (deleted) { + await this.removeEventIndexForEvent(targetCalendarId, eventId); + } else { + allDeleted = false; + } + } + if (!allDeleted) { + console.warn(`[TaskCalendarSync] Event deletion queued; keeping link for ${task.path}`); + continue; } } - // Remove the event ID from task frontmatter - await this.removeTaskEventId(task.path); + await this.clearTaskGoogleCalendarMetadata(task.path); unlinkedCount++; } diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 0ba30e8d0..4cf21bbd0 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -80,19 +80,28 @@ export class TaskService { }); } - private hasGoogleCalendarLink(task: TaskInfo): boolean { - return !!task.googleCalendarEventId; + private hasGoogleCalendarLinks(task: TaskInfo): boolean { + return Boolean(task.googleCalendarEventId || task.googleCalendarExceptionEventId); } private createArchiveCalendarDeletionTask(task: TaskInfo, updatedTask: TaskInfo): TaskInfo { return { ...updatedTask, googleCalendarEventId: task.googleCalendarEventId, + googleCalendarExceptionEventId: task.googleCalendarExceptionEventId, + googleCalendarExceptionOriginalScheduled: + task.googleCalendarExceptionOriginalScheduled, + googleCalendarMovedOriginalDates: task.googleCalendarMovedOriginalDates + ? [...task.googleCalendarMovedOriginalDates] + : undefined, }; } private clearGoogleCalendarMetadata(task: TaskInfo): void { task.googleCalendarEventId = undefined; + task.googleCalendarExceptionEventId = undefined; + task.googleCalendarExceptionOriginalScheduled = undefined; + task.googleCalendarMovedOriginalDates = undefined; } private async deleteArchivedTaskFromCalendar(task: TaskInfo): Promise { @@ -116,6 +125,90 @@ export class TaskService { return this.plugin.i18n.translate(key, variables); } + private shouldTrackGoogleCalendarRecurringException( + recurrence?: string, + recurrenceAnchor?: "scheduled" | "completion" + ): boolean { + return Boolean(recurrence) && (recurrenceAnchor || "scheduled") === "scheduled"; + } + + private applyGoogleCalendarRecurringExceptionForScheduledChange( + task: TaskInfo, + newScheduledValue: unknown, + updatedTask: TaskInfo + ): void { + if ( + !this.shouldTrackGoogleCalendarRecurringException(task.recurrence, task.recurrence_anchor) || + newScheduledValue === task.scheduled + ) { + return; + } + + const priorOccurrence = + task.googleCalendarExceptionOriginalScheduled || task.scheduled || task.due; + if (!priorOccurrence) { + return; + } + + if (!newScheduledValue || newScheduledValue === priorOccurrence) { + updatedTask.googleCalendarExceptionOriginalScheduled = undefined; + return; + } + + updatedTask.googleCalendarExceptionOriginalScheduled = getDatePart(priorOccurrence); + } + + private resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + task: TaskInfo, + actionDate: string, + updatedTask: TaskInfo + ): void { + const originalScheduled = task.googleCalendarExceptionOriginalScheduled; + if (!originalScheduled) { + return; + } + + const currentScheduled = getDatePart(task.scheduled || ""); + if (!currentScheduled || actionDate !== currentScheduled) { + return; + } + + const originalDate = getDatePart(originalScheduled); + if (!originalDate) { + return; + } + + updatedTask.googleCalendarMovedOriginalDates = Array.from( + new Set([...(task.googleCalendarMovedOriginalDates || []), originalDate]) + ).sort(); + updatedTask.googleCalendarExceptionOriginalScheduled = undefined; + } + + private applyGoogleCalendarRecurringExceptionCleanup( + recurrence: TaskInfo["recurrence"], + recurrenceAnchor: TaskInfo["recurrence_anchor"], + updatedTask: TaskInfo + ): void { + if (this.shouldTrackGoogleCalendarRecurringException(recurrence, recurrenceAnchor)) { + return; + } + + updatedTask.googleCalendarExceptionOriginalScheduled = undefined; + updatedTask.googleCalendarMovedOriginalDates = undefined; + } + + private writeOptionalFrontmatterField( + frontmatter: Record, + fieldName: string, + value: unknown + ): void { + if (value == null || (Array.isArray(value) && value.length === 0)) { + delete frontmatter[fieldName]; + return; + } + frontmatter[fieldName] = value; + } + /** * Sanitize title by removing problematic characters that could cause issues in filenames * This is used when storeTitleInFilename is true, to ensure the title is safe for filenames @@ -506,6 +599,28 @@ export class TaskService { } } + if (property === "scheduled") { + this.applyGoogleCalendarRecurringExceptionForScheduledChange( + freshTask, + value, + updatedTask as TaskInfo + ); + } + + if (property === "recurrence") { + this.applyGoogleCalendarRecurringExceptionCleanup( + value as TaskInfo["recurrence"], + updatedTask.recurrence_anchor, + updatedTask as TaskInfo + ); + } else if (property === "recurrence_anchor") { + this.applyGoogleCalendarRecurringExceptionCleanup( + updatedTask.recurrence, + value as TaskInfo["recurrence_anchor"], + updatedTask as TaskInfo + ); + } + // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { // Use field mapper to get the correct frontmatter property name @@ -533,6 +648,23 @@ export class TaskService { // Always update the modification timestamp using field mapper const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); frontmatter[dateModifiedField] = updatedTask.dateModified; + + const exceptionOriginalField = this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionOriginalScheduled" + ); + const movedOriginalDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ); + this.writeOptionalFrontmatterField( + frontmatter, + exceptionOriginalField, + (updatedTask as TaskInfo).googleCalendarExceptionOriginalScheduled + ); + this.writeOptionalFrontmatterField( + frontmatter, + movedOriginalDatesField, + (updatedTask as TaskInfo).googleCalendarMovedOriginalDates + ); }); // Step 3: Run post-write side effects (cache, events, webhooks, calendar, auto-archive) @@ -846,7 +978,7 @@ export class TaskService { let archiveCalendarCleanupComplete = true; if (this.plugin.taskCalendarSyncService?.isEnabled() && updatedTask.archived) { - if (this.hasGoogleCalendarLink(task)) { + if (this.hasGoogleCalendarLinks(task)) { const archiveCalendarTask = this.createArchiveCalendarDeletionTask( task, updatedTask @@ -905,7 +1037,10 @@ export class TaskService { .catch((error) => { console.warn("Failed to sync unarchived task to Google Calendar:", error); }); - } else if (!archiveCalendarCleanupComplete && this.hasGoogleCalendarLink(updatedTask)) { + } else if ( + !archiveCalendarCleanupComplete && + this.hasGoogleCalendarLinks(updatedTask) + ) { console.warn( "Archived task still has Google Calendar links and will need retry cleanup:", updatedTask.path @@ -1234,10 +1369,14 @@ 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 && this.hasGoogleCalendarLinks(task)) { try { await this.plugin.taskCalendarSyncService - .deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId); + .deleteTaskFromCalendarByPath( + task.path, + task.googleCalendarEventId, + task.googleCalendarExceptionEventId + ); } catch (error) { console.warn("Failed to delete task from Google Calendar:", error); } @@ -1383,6 +1522,16 @@ export class TaskService { if (nextDates.due) { updatedTask.due = nextDates.due; } + this.resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + freshTask, + dateStr, + updatedTask + ); + this.applyGoogleCalendarRecurringExceptionCleanup( + updatedTask.recurrence, + updatedTask.recurrence_anchor, + updatedTask + ); // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { @@ -1392,6 +1541,12 @@ export class TaskService { const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); const recurrenceField = this.plugin.fieldMapper.toUserField("recurrence"); + const exceptionOriginalField = this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionOriginalScheduled" + ) || "googleCalendarExceptionOriginalScheduled"; + const movedDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ) || "googleCalendarMovedOriginalDates"; // Ensure complete_instances array exists if (!frontmatter[completeInstancesField]) { @@ -1433,6 +1588,17 @@ export class TaskService { frontmatter[dueField] = updatedTask.due; } + this.writeOptionalFrontmatterField( + frontmatter, + exceptionOriginalField, + updatedTask.googleCalendarExceptionOriginalScheduled + ); + this.writeOptionalFrontmatterField( + frontmatter, + movedDatesField, + updatedTask.googleCalendarMovedOriginalDates + ); + frontmatter[dateModifiedField] = updatedTask.dateModified; }); @@ -1576,6 +1742,16 @@ export class TaskService { if (nextDates.due) { updatedTask.due = nextDates.due; } + this.resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + freshTask, + dateStr, + updatedTask + ); + this.applyGoogleCalendarRecurringExceptionCleanup( + updatedTask.recurrence, + updatedTask.recurrence_anchor, + updatedTask + ); // Step 3: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { @@ -1584,6 +1760,12 @@ export class TaskService { const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); + const exceptionOriginalField = this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionOriginalScheduled" + ) || "googleCalendarExceptionOriginalScheduled"; + const movedDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ) || "googleCalendarMovedOriginalDates"; // Ensure skipped_instances array exists if (!frontmatter[skippedField]) { @@ -1607,6 +1789,17 @@ export class TaskService { frontmatter[dueField] = updatedTask.due; } + this.writeOptionalFrontmatterField( + frontmatter, + exceptionOriginalField, + updatedTask.googleCalendarExceptionOriginalScheduled + ); + this.writeOptionalFrontmatterField( + frontmatter, + movedDatesField, + updatedTask.googleCalendarMovedOriginalDates + ); + frontmatter[dateModifiedField] = updatedTask.dateModified; }); 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/services/task-service/TaskUpdateService.ts b/src/services/task-service/TaskUpdateService.ts index 6b1263ef3..88e125e56 100644 --- a/src/services/task-service/TaskUpdateService.ts +++ b/src/services/task-service/TaskUpdateService.ts @@ -10,7 +10,11 @@ import { splitFrontmatterAndBody, } from "../../utils/helpers"; import { generateUniqueFilename } from "../../utils/filenameGenerator"; -import { getCurrentDateString, getCurrentTimestamp } from "../../utils/dateUtils"; +import { + getCurrentDateString, + getCurrentTimestamp, + getDatePart, +} from "../../utils/dateUtils"; export interface TaskUpdateServiceDependencies { plugin: TaskNotesPlugin; @@ -71,6 +75,7 @@ export class TaskUpdateService { } const recurrenceUpdates = this.getRecurrenceUpdates(originalTask, updates); + const effectiveUpdates: Partial = { ...updates, ...recurrenceUpdates }; let normalizedDetails: string | null = null; if (Object.prototype.hasOwnProperty.call(updates, "details")) { normalizedDetails = @@ -130,7 +135,7 @@ export class TaskUpdateService { }); } - this.removeUnsetMappedFields(frontmatter, updates); + this.removeUnsetMappedFields(frontmatter, effectiveUpdates); if (isRenameNeeded) { delete frontmatter[plugin.fieldMapper.toUserField("title")]; @@ -329,9 +334,48 @@ export class TaskUpdateService { } } + if ( + Object.prototype.hasOwnProperty.call(updates, "scheduled") && + updates.scheduled !== originalTask.scheduled && + this.shouldTrackGoogleCalendarRecurringException( + originalTask.recurrence, + originalTask.recurrence_anchor + ) + ) { + const priorOccurrence = this.getRecurringExceptionOriginalDate(originalTask); + if (priorOccurrence) { + const nextScheduled = getDatePart(updates.scheduled || ""); + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = + nextScheduled && nextScheduled !== priorOccurrence ? priorOccurrence : undefined; + } + } + + const nextRecurrence = + updates.recurrence !== undefined ? updates.recurrence : originalTask.recurrence; + const nextAnchor = + updates.recurrence_anchor !== undefined + ? updates.recurrence_anchor + : originalTask.recurrence_anchor; + if (!this.shouldTrackGoogleCalendarRecurringException(nextRecurrence, nextAnchor)) { + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = undefined; + recurrenceUpdates.googleCalendarMovedOriginalDates = undefined; + } + return recurrenceUpdates; } + private shouldTrackGoogleCalendarRecurringException( + recurrence?: string, + recurrenceAnchor?: "scheduled" | "completion" + ): boolean { + return Boolean(recurrence) && (recurrenceAnchor || "scheduled") === "scheduled"; + } + + private getRecurringExceptionOriginalDate(task: TaskInfo): string | undefined { + const original = task.googleCalendarExceptionOriginalScheduled || task.scheduled || task.due; + return getDatePart(original || ""); + } + private removeUnsetMappedFields( frontmatter: Record, updates: Partial @@ -385,6 +429,23 @@ export class TaskUpdateService { ) { delete frontmatter[plugin.fieldMapper.toUserField("blockedBy")]; } + if ( + Object.prototype.hasOwnProperty.call(updates, "googleCalendarExceptionOriginalScheduled") && + updates.googleCalendarExceptionOriginalScheduled === undefined + ) { + delete frontmatter[ + plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled") + ]; + } + if ( + Object.prototype.hasOwnProperty.call(updates, "googleCalendarMovedOriginalDates") && + (!Array.isArray(updates.googleCalendarMovedOriginalDates) || + updates.googleCalendarMovedOriginalDates.length === 0) + ) { + delete frontmatter[ + plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates") + ]; + } } private async handleAutoArchive( diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 6e11b929c..140d7d7c0 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -46,6 +46,9 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { icsEventId: "icsEventId", icsEventTag: "ics_event", googleCalendarEventId: "googleCalendarEventId", + googleCalendarExceptionEventId: "googleCalendarExceptionEventId", + googleCalendarExceptionOriginalScheduled: "googleCalendarExceptionOriginalScheduled", + googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", reminders: "reminders", sortOrder: "tasknotes_manual_order", }; diff --git a/src/types.ts b/src/types.ts index 5d777591d..86626a8ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -457,6 +457,9 @@ export interface TaskInfo { dateModified?: string; // Last modification date (ISO timestamp) icsEventId?: string[]; // Links to ICS calendar event IDs googleCalendarEventId?: string; // Google Calendar event ID for sync + googleCalendarExceptionEventId?: string; // Detached Google event for a moved recurring occurrence + googleCalendarExceptionOriginalScheduled?: string; // Original series date replaced by the current moved occurrence + googleCalendarMovedOriginalDates?: string[]; // Historical series dates moved off-pattern and excluded from the master event reminders?: Reminder[]; // Task reminders customProperties?: Record; // Custom properties from Bases or other sources basesData?: any; // Raw Bases data for formula computation (internal use) @@ -677,6 +680,9 @@ export interface FieldMapping { icsEventId: string; // For linking to ICS calendar events (stored as array in frontmatter) icsEventTag: string; // Tag used for ICS event-related content googleCalendarEventId: string; // For Google Calendar sync (stores event ID) + googleCalendarExceptionEventId: string; // Detached Google event ID for moved recurring occurrences + googleCalendarExceptionOriginalScheduled: string; // Original series date replaced by current moved occurrence + googleCalendarMovedOriginalDates: string; // Historical moved series dates excluded from recurring master reminders: string; // For task reminders sortOrder: string; // Numeric ordering within column (lower = higher) } @@ -847,6 +853,32 @@ 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; + eventRole?: "primary" | "exception"; + 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/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 123303ae8..7fe2ec515 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -1660,7 +1660,7 @@ export function createTaskCard( // Google Calendar sync indicator if (propertyId === "googleCalendarSync") { // Check if task has a Google Calendar event ID in frontmatter - if (task.googleCalendarEventId) { + if (task.googleCalendarEventId || task.googleCalendarExceptionEventId) { const syncPill = metadataLine.createSpan({ cls: "task-card__metadata-pill task-card__metadata-pill--google-calendar", }); diff --git a/src/utils/rruleConverter.ts b/src/utils/rruleConverter.ts index cf189cada..cd5ca806d 100644 --- a/src/utils/rruleConverter.ts +++ b/src/utils/rruleConverter.ts @@ -26,6 +26,8 @@ export interface ConversionOptions { completedInstances?: string[]; /** Skipped instances to exclude via EXDATE (YYYY-MM-DD format) */ skippedInstances?: string[]; + /** Additional dates to exclude via EXDATE (YYYY-MM-DD format) */ + additionalExcludedDates?: string[]; } /** @@ -71,11 +73,16 @@ export function convertToGoogleRecurrence( // Build the recurrence array const recurrence: string[] = [`RRULE:${rruleWithoutDtstart}`]; - // Add EXDATE entries for completed and skipped instances - const exdates = formatExdates([ - ...(options?.completedInstances || []), - ...(options?.skippedInstances || []), - ]); + // Add EXDATE entries for completed, skipped, and moved original instances. + const exdates = formatExdates( + Array.from( + new Set([ + ...(options?.completedInstances || []), + ...(options?.skippedInstances || []), + ...(options?.additionalExcludedDates || []), + ]) + ).sort() + ); recurrence.push(...exdates); return { diff --git a/tests/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index f0fadf959..b2d60c0b9 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -230,9 +230,16 @@ export const SettingsFactory = { archiveTag: 'archived', timeEntries: 'timeEntries', completeInstances: 'complete_instances', + skippedInstances: 'skipped_instances', pomodoros: 'pomodoros', icsEventId: 'icsEventId', + icsEventTag: 'ics_event', + googleCalendarEventId: 'googleCalendarEventId', + googleCalendarExceptionEventId: 'googleCalendarExceptionEventId', + googleCalendarExceptionOriginalScheduled: 'googleCalendarExceptionOriginalScheduled', + googleCalendarMovedOriginalDates: 'googleCalendarMovedOriginalDates', reminders: 'reminders', + sortOrder: 'tasknotes_manual_order', ...overrides }), @@ -616,4 +623,4 @@ export default { FileSystemFactory, PluginFactory, TestDataSets -}; \ No newline at end of file +}; 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..fa9cd10d5 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -13,24 +13,52 @@ describe("TaskCalendarSyncService", () => { settings: { googleCalendarExport: { syncOnTaskUpdate: true, + syncOnTaskComplete: true, + enabled: true, targetCalendarId: "test-calendar", + eventTitleTemplate: "{{title}}", + includeDescription: false, + syncTrigger: "scheduled", + createAsAllDay: true, + defaultEventDuration: 60, + includeObsidianLink: true, } }, + app: { + vault: { + getName: jest.fn().mockReturnValue("Example Vault"), + }, + }, cacheManager: { getTaskInfo: jest.fn() }, statusManager: { - getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" }) + getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" })), + isCompletedStatus: jest.fn((status?: string) => status === "done") }, priorityManager: { - getPriorityConfig: jest.fn().mockReturnValue({ label: "High" }) + getPriorityConfig: jest.fn((priority: string) => ({ label: priority === "2-high" ? "High" : "Medium" })) }, i18n: { - translate: jest.fn().mockReturnValue("Untitled Task") + translate: jest.fn((key: string, params?: Record) => { + const translations: Record = { + "settings.integrations.googleCalendarExport.eventDescription.untitledTask": "Untitled Task", + "settings.integrations.googleCalendarExport.eventDescription.priority": "Priority: {value}", + "settings.integrations.googleCalendarExport.eventDescription.status": "Status: {value}", + "settings.integrations.googleCalendarExport.eventDescription.scheduled": "Scheduled: {value}", + "settings.integrations.googleCalendarExport.eventDescription.timeEstimate": "Time Estimate: {value}", + "settings.integrations.googleCalendarExport.eventDescription.contexts": "Contexts: {value}", + "settings.integrations.googleCalendarExport.eventDescription.projects": "Projects: {value}", + "settings.integrations.googleCalendarExport.eventDescription.openInObsidian": "Open in Obsidian", + }; + const translation = translations[key] || key; + return translation.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? "")); + }) } }; mockGoogleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "test-calendar" }]), updateEvent: jest.fn().mockResolvedValue({}), createEvent: jest.fn().mockResolvedValue({ id: "test-id" }) }; @@ -78,4 +106,90 @@ 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 build plain-text calendar descriptions for external calendar clients", () => { + const description = syncService.buildEventDescription({ + path: "Tasks/Export sample records.md", + title: "Export sample records", + status: "ready", + priority: "2-high", + scheduled: "2026-04-29", + timeEstimate: 180, + projects: [ + "[[Projects/Example Project|Example Project]]", + "[[Projects/Nested Project.md]]", + "[Markdown Project](Projects/Markdown%20Project.md)", + ], + contexts: ["[[People/Example Person|Example Person]]", "admin"], + } as TaskInfo); + + expect(description).toContain("Priority: High"); + expect(description).toContain("Status: Ready"); + expect(description).toContain("Scheduled: 2026-04-29"); + expect(description).toContain("Time Estimate: 3h 0m"); + expect(description).toContain("Contexts: @Example Person, @admin"); + expect(description).toContain( + "Projects: Example Project, Nested Project, Markdown Project" + ); + expect(description).toContain( + "Open in Obsidian: obsidian://open?vault=Example%20Vault&file=Tasks%2FExport%20sample%20records.md" + ); + expect(description).not.toContain("[["); + expect(description).not.toContain("]]"); + expect(description).not.toContain(""); + expect(description).not.toContain("]("); + }); + }); diff --git a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts index 724c9d361..cca73cdb6 100644 --- a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts +++ b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts @@ -1,41 +1,284 @@ -/** - * Reproduction tests for issue #1696. - * - * Reported behavior: - * - When a recurring task's next occurrence is rescheduled (scheduled date - * changed without modifying the recurrence pattern), the Google Calendar - * export uses DTSTART from the recurrence rule instead of the rescheduled - * scheduled date. - */ - -describe('Issue #1696: Google Calendar export ignores rescheduled next occurrence', () => { - it.skip('reproduces issue #1696 - event start uses DTSTART, not rescheduled scheduled date', () => { - // Simulate the data scenario from the bug report: - // - scheduled: 2026-03-16 (rescheduled next occurrence) - // - recurrence: DTSTART:20260313;FREQ=WEEKLY;INTERVAL=4;BYDAY=FR - // - recurrence_anchor: scheduled +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { TFile } from "obsidian"; +import { TaskCalendarSyncService } from "../../../src/services/TaskCalendarSyncService"; +import { TaskService } from "../../../src/services/TaskService"; +import { TaskInfo } from "../../../src/types"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: class MockTFile { + path: string; + + constructor(path = "") { + this.path = path; + } + }, +})); + +function createGoogleSyncPlugin(frontmatter: Record = {}) { + let pluginData: Record = {}; + return { + settings: { + storeTitleInFilename: false, + taskIdentificationMethod: "property", + taskPropertyName: "type", + taskPropertyValue: "task", + maintainDueDateOffsetInRecurring: false, + resetCheckboxesOnRecurrence: false, + googleCalendarExport: { + enabled: true, + targetCalendarId: "primary", + syncOnTaskCreate: true, + syncOnTaskUpdate: true, + syncOnTaskComplete: true, + syncOnTaskDelete: true, + eventTitleTemplate: "{{title}}", + includeDescription: false, + eventColorId: null, + syncTrigger: "scheduled", + createAsAllDay: false, + defaultEventDuration: 60, + includeObsidianLink: false, + defaultReminderMinutes: null, + }, + }, + app: { + vault: { + getAbstractFileByPath: jest + .fn() + .mockImplementation((path: string) => new TFile(path)), + getName: jest.fn().mockReturnValue("Example Vault"), + read: jest.fn().mockResolvedValue(""), + modify: jest.fn().mockResolvedValue(undefined), + }, + fileManager: { + processFrontMatter: jest + .fn() + .mockImplementation( + async (_file: TFile, fn: (fm: Record) => void) => { + fn(frontmatter); + } + ), + renameFile: jest.fn().mockResolvedValue(undefined), + }, + }, + fieldMapper: { + toUserField: jest.fn((field: string) => field), + mapToFrontmatter: jest.fn((taskData: Record) => { + const mapped: Record = {}; + for (const [key, value] of Object.entries(taskData)) { + if (key === "details" || value === undefined) { + continue; + } + mapped[key] = value; + } + return mapped; + }), + }, + priorityManager: { + getPriorityConfig: jest.fn().mockReturnValue(null), + }, + statusManager: { + getStatusConfig: jest.fn().mockReturnValue(null), + isCompletedStatus: jest.fn().mockImplementation((status: string) => status === "done"), + }, + i18n: { + translate: jest.fn((key: string) => key), + }, + loadData: jest.fn(async () => pluginData), + saveData: jest.fn(async (data: Record) => { + pluginData = data; + }), + cacheManager: { + getTaskInfo: jest.fn().mockResolvedValue(null), + getAllTasks: jest.fn().mockResolvedValue([]), + updateTaskInfoInCache: jest.fn(), + waitForFreshTaskData: jest.fn().mockResolvedValue(undefined), + clearCacheEntry: jest.fn(), + }, + emitter: { + trigger: jest.fn(), + }, + } as any; +} + +describe("Issue #1696: Google Calendar recurring reschedule sync", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("persists the original series date when a moved occurrence is completed", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); const task = { - scheduled: '2026-03-16', - recurrence: 'DTSTART:20260313;FREQ=WEEKLY;INTERVAL=4;BYDAY=FR', - recurrence_anchor: 'scheduled', + path: "TaskNotes/Tasks/Water plants.md", + title: "Water plants", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-15", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + } as TaskInfo & { googleCalendarExceptionOriginalScheduled: string }; + + plugin.cacheManager.getTaskInfo.mockResolvedValue(task); + + const updatedTask = await taskService.toggleRecurringTaskComplete(task); + + expect(updatedTask.complete_instances).toContain("2026-04-15"); + expect(updatedTask.googleCalendarMovedOriginalDates).toContain("2026-04-13"); + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(frontmatter.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); + + it("stores the original series date when a scheduled recurring occurrence is moved", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); + const task = { + path: "TaskNotes/Tasks/Water plants.md", + title: "Water plants", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-13", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + } as TaskInfo; + + const updatedTask = await taskService.updateTask(task, { + scheduled: "2026-04-15", + }); + + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + }); + + it("adds moved original dates to the recurring master's EXDATE list", () => { + const plugin = createGoogleSyncPlugin(); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn(), + updateEvent: jest.fn(), + deleteEvent: jest.fn(), + }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Water plants.md", + title: "Water plants", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-05-11", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: ["2026-04-15"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarMovedOriginalDates: ["2026-04-13"], + } as TaskInfo & { googleCalendarMovedOriginalDates: string[] }; + + const event = (syncService as any).taskToCalendarEvent(task); + + expect(event?.recurrence).toContain("EXDATE;VALUE=DATE:20260413"); + expect(event?.recurrence).toContain("EXDATE;VALUE=DATE:20260415"); + }); + + it("creates a detached exception event for a pending moved occurrence", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest + .fn() + .mockResolvedValue({ id: "google-primary-detached-exception-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Water plants.md", + title: "Water plants", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-15", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + } as TaskInfo & { googleCalendarExceptionOriginalScheduled: string }; + + await syncService.syncTaskToCalendar(task); + + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "master-event-id", + expect.objectContaining({ + recurrence: expect.arrayContaining(["EXDATE;VALUE=DATE:20260413"]), + }) + ); + expect(googleCalendarService.createEvent).toHaveBeenCalledWith( + "primary", + expect.objectContaining({ + summary: "Water plants", + start: { date: "2026-04-15" }, + end: { date: "2026-04-16" }, + isAllDay: true, + }) + ); + expect(frontmatter.googleCalendarExceptionEventId).toBe("detached-exception-id"); + }); - // Simulate what convertToGoogleRecurrence returns - const recurrenceData = { - recurrence: ['RRULE:FREQ=WEEKLY;INTERVAL=4;BYDAY=FR'], - dtstart: '2026-03-13', // From DTSTART in recurrence rule - hasTime: false, - time: null, + it("deletes stale detached exception events once the moved occurrence has been resolved", async () => { + const frontmatter: Record = { + googleCalendarExceptionEventId: "detached-exception-id", + }; + const plugin = createGoogleSyncPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn(), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Water plants.md", + title: "Water plants", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-05-11", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: ["2026-04-15"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionEventId: "detached-exception-id", + googleCalendarMovedOriginalDates: ["2026-04-13"], + } as TaskInfo & { + googleCalendarExceptionEventId: string; + googleCalendarMovedOriginalDates: string[]; }; - // Simulate buildCalendarEvent behavior (lines 634-638): - // The event start is overridden with recurrenceData.dtstart - const eventStart = recurrenceData.dtstart; // '2026-03-13' + await syncService.syncTaskToCalendar(task); - // BUG: The event should use the rescheduled date (2026-03-16) - // but instead uses DTSTART from the recurrence rule (2026-03-13) - expect(eventStart).toBe('2026-03-13'); // Documents the bug - expect(eventStart).not.toBe(task.scheduled); // The scheduled date is ignored + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith( + "primary", + "detached-exception-id" + ); + expect(frontmatter.googleCalendarExceptionEventId).toBeUndefined(); }); }); diff --git a/tests/unit/issues/issue-1696-local-calendar-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-local-calendar-recurring-reschedule.test.ts new file mode 100644 index 000000000..f21f28396 --- /dev/null +++ b/tests/unit/issues/issue-1696-local-calendar-recurring-reschedule.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { + createTaskInfoFromBasesData, + type BasesDataItem, +} from "../../../src/bases/helpers"; +import { generateRecurringTaskInstances } from "../../../src/bases/calendar-core"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; +import { TaskFactory } from "../../helpers/mock-factories"; + +function createCalendarPlugin(): TaskNotesPlugin { + return { + priorityManager: { + getPriorityConfig: jest.fn().mockReturnValue({ color: "#1f2937" }), + }, + statusManager: { + isCompletedStatus: jest.fn().mockReturnValue(false), + }, + } as unknown as TaskNotesPlugin; +} + +function createWeeklyGroceryOrderTask(overrides: Partial = {}): TaskInfo { + return TaskFactory.createRecurringTask("DTSTART:20260314;FREQ=WEEKLY;BYDAY=SA", { + title: "Weekly grocery order", + path: "TaskNotes/Tasks/Weekly grocery order.md", + status: "open", + priority: "normal", + scheduled: "2026-03-20", + recurrence_anchor: "scheduled", + skipped_instances: [], + ...overrides, + }); +} + +describe("Issue #1696: local recurring calendar reschedule rendering", () => { + it("preserves recurring exception metadata when Bases data is flattened", () => { + const basesItem: BasesDataItem = { + path: "TaskNotes/Tasks/Weekly grocery order.md", + properties: { + title: "Weekly grocery order", + status: "open", + priority: "normal", + scheduled: "2026-03-20", + recurrence: "DTSTART:20260314;FREQ=WEEKLY;BYDAY=SA", + recurrence_anchor: "scheduled", + googleCalendarExceptionOriginalScheduled: "2026-03-21", + googleCalendarMovedOriginalDates: ["2026-03-14"], + }, + }; + + const task = createTaskInfoFromBasesData(basesItem); + + expect(task?.googleCalendarExceptionOriginalScheduled).toBe("2026-03-21"); + expect(task?.googleCalendarMovedOriginalDates).toEqual(["2026-03-14"]); + expect(task?.customProperties).toBeUndefined(); + }); + + it("suppresses original-pattern dates recorded on top-level exception fields", () => { + const task = createWeeklyGroceryOrderTask({ + googleCalendarExceptionOriginalScheduled: "2026-03-21", + googleCalendarMovedOriginalDates: ["2026-03-14"], + }); + + const events = generateRecurringTaskInstances( + task, + new Date("2026-03-13T00:00:00Z"), + new Date("2026-03-22T00:00:00Z"), + createCalendarPlugin() + ); + + expect(events.map((event) => event.start)).toEqual(["2026-03-20"]); + }); + + it("suppresses original-pattern dates even when they only survive in customProperties", () => { + const task = createWeeklyGroceryOrderTask({ + customProperties: { + googleCalendarExceptionOriginalScheduled: "2026-03-21", + googleCalendarMovedOriginalDates: ["2026-03-14"], + }, + }); + + const events = generateRecurringTaskInstances( + task, + new Date("2026-03-13T00:00:00Z"), + new Date("2026-03-22T00:00:00Z"), + createCalendarPlugin() + ); + + expect(events.map((event) => event.start)).toEqual(["2026-03-20"]); + }); +}); diff --git a/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts b/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts new file mode 100644 index 000000000..9cbca0b2d --- /dev/null +++ b/tests/unit/issues/issue-1823-zero-duration-google-calendar-list-duplication.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; + +import { createICSEvent } from "../../../src/bases/calendar-core"; +import type TaskNotesPlugin from "../../../src/main"; +import type { ICSEvent } from "../../../src/types"; + +function createCalendarPlugin(): TaskNotesPlugin { + return {} as TaskNotesPlugin; +} + +beforeEach(() => { + (globalThis as typeof globalThis & { + activeDocument?: { + body: { + classList: { + contains: (className: string) => boolean; + }; + }; + }; + }).activeDocument = { + body: { + classList: { + contains: (_className: string) => false, + }, + }, + }; +}); + +function createGoogleCalendarEvent(overrides: Partial = {}): ICSEvent { + return { + id: "google-primary-zero-duration-event", + subscriptionId: "google-primary", + title: "Reserved pickup cutoff", + start: "2026-04-22T23:12:00", + end: "2026-04-22T23:12:00", + allDay: false, + color: "#16a765", + ...overrides, + }; +} + +describe("Issue #1823: zero-duration Google Calendar list duplication", () => { + it("adds a minimal duration to zero-duration timed Google Calendar events", () => { + const icsEvent = createGoogleCalendarEvent(); + + const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin()); + + expect(calendarEvent).not.toBeNull(); + expect(calendarEvent?.start).toBe("2026-04-22T23:12:00"); + expect(calendarEvent?.end).not.toBe("2026-04-22T23:12:00"); + expect(calendarEvent?.allDay).toBe(false); + expect(calendarEvent?.extendedProps.icsEvent?.end).toBe("2026-04-22T23:12:00"); + expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1); + }); + + it("preserves an explicit offset when normalizing zero-duration timed events", () => { + const icsEvent = createGoogleCalendarEvent({ + start: "2026-04-22T23:12:00+01:00", + end: "2026-04-22T23:12:00+01:00", + }); + + const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin()); + + expect(calendarEvent?.start).toBe("2026-04-22T23:12:00+01:00"); + expect(calendarEvent?.end).toBe("2026-04-22T23:12:00.001+01:00"); + expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1); + }); + + it("preserves UTC formatting when normalizing zero-duration timed events", () => { + const icsEvent = createGoogleCalendarEvent({ + start: "2026-04-22T22:12:00.000Z", + end: "2026-04-22T22:12:00.000Z", + }); + + const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin()); + + expect(calendarEvent?.start).toBe("2026-04-22T22:12:00.000Z"); + expect(calendarEvent?.end).toBe("2026-04-22T22:12:00.001Z"); + expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1); + }); + + it("leaves non-zero timed Google Calendar events unchanged", () => { + const icsEvent = createGoogleCalendarEvent({ + end: "2026-04-22T23:42:00", + }); + + const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin()); + + expect(calendarEvent?.start).toBe("2026-04-22T23:12:00"); + expect(calendarEvent?.end).toBe("2026-04-22T23:42:00"); + }); + + it("leaves all-day Google Calendar events unchanged", () => { + const icsEvent = createGoogleCalendarEvent({ + start: "2026-04-22", + end: "2026-04-23", + allDay: true, + }); + + const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin()); + + expect(calendarEvent?.start).toBe("2026-04-22"); + expect(calendarEvent?.end).toBe("2026-04-23"); + expect(calendarEvent?.allDay).toBe(true); + }); +}); 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"); + }); +});