From f27af5c5f27054825841d861dbb39f952a9881cc Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Fri, 22 May 2026 04:01:26 +0100 Subject: [PATCH 1/2] Reconcile external Google Calendar task edits --- docs/releases/unreleased.md | 2 + src/bootstrap/pluginBootstrap.ts | 23 ++ src/services/TaskCalendarSyncService.ts | 321 +++++++++++++++++- .../services/TaskCalendarSyncService.test.ts | 9 + ...endar-external-file-reconciliation.test.ts | 319 +++++++++++++++++ 5 files changed, 661 insertions(+), 13 deletions(-) create mode 100644 tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 9f47c2d9..583b687c 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -26,5 +26,7 @@ Example: ## Fixed +- (#1921) Fixed Google Calendar and auto-archive side effects falling stale after direct task file edits. + - Reconciles lifecycle-relevant frontmatter changes through the existing task file update event, with a first-run fingerprint baseline to avoid bulk startup API writes. - (#1911) Fixed recurrence choices starting from today instead of the selected calendar date when creating a task from Calendar view. - Thanks to @mikhailmarka for reporting. diff --git a/src/bootstrap/pluginBootstrap.ts b/src/bootstrap/pluginBootstrap.ts index 741ed71b..fc0c39b3 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -58,6 +58,7 @@ import { EVENT_USER_NOTICE, type UserNoticePayload } from "../core/userNotices"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bootstrap/PluginBootstrap" }); type FileDeletedEventData = { path: string; prevCache?: unknown }; +type FileUpdatedEventData = { path: string; file?: unknown; updatedTask?: TaskInfo }; type EditorWithCodeMirror = { cm?: unknown; @@ -343,8 +344,30 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { plugin.taskCalendarSyncService = new ( await import("../services/TaskCalendarSyncService") ).TaskCalendarSyncService(plugin, plugin.googleCalendarService); + await plugin.taskCalendarSyncService.initializeExternalFileReconciliation(); plugin.taskCalendarSyncService.startRecoveryQueueProcessor(); + plugin.registerEvent( + plugin.emitter.on("file-updated", (data: FileUpdatedEventData) => { + if (!plugin.taskCalendarSyncService || !data?.path) { + return; + } + + plugin.taskCalendarSyncService + .handleExternalTaskFileUpdated(data.path, data.updatedTask) + .catch((error) => { + tasknotesLogger.warn( + "Failed to reconcile externally updated task with Google Calendar:", + { + category: "provider", + operation: "reconcile-external-task-file-update", + error: error, + } + ); + }); + }) + ); + plugin.registerEvent( plugin.emitter.on("file-deleted", (data: FileDeletedEventData) => { if (!plugin.taskCalendarSyncService) { diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 999504af..fd636b75 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -37,6 +37,9 @@ 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"; +/** Persistent plugin-data key for the last task state known to match Google Calendar */ +const GOOGLE_CALENDAR_FINGERPRINTS_KEY = "googleCalendarTaskFingerprints"; + /** Delay between queued Google Calendar recovery retry attempts */ const RECOVERY_QUEUE_RETRY_DELAY_MS = 60000; @@ -102,7 +105,7 @@ export class TaskCalendarSyncService { private pendingSyncs: Map = new Map(); /** In-flight sync operations to prevent concurrent syncs for the same task */ - private inFlightSyncs: Map> = new Map(); + private inFlightSyncs: Map> = new Map(); /** Track previous task state for detecting recurrence removal */ private previousTaskState: Map = new Map(); @@ -116,6 +119,9 @@ export class TaskCalendarSyncService { /** Event IDs written during this session, used while Obsidian metadata catches up */ private taskEventIdCache: Map = new Map(); + /** Last calendar-relevant task fingerprint persisted after successful syncs */ + private calendarFingerprints: Map | null = null; + constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; this.googleCalendarService = googleCalendarService; @@ -138,6 +144,7 @@ export class TaskCalendarSyncService { this.pendingTasks.clear(); this.pendingEventCreates.clear(); this.taskEventIdCache.clear(); + this.calendarFingerprints = null; } /** @@ -312,6 +319,183 @@ export class TaskCalendarSyncService { await this.plugin.saveData(data); } + private async getCalendarFingerprints(): Promise> { + if (this.calendarFingerprints) { + return this.calendarFingerprints; + } + + const data = await this.plugin.loadData(); + const rawFingerprints = data?.[GOOGLE_CALENDAR_FINGERPRINTS_KEY]; + const fingerprints = new Map(); + + if (rawFingerprints && typeof rawFingerprints === "object") { + for (const [path, fingerprint] of Object.entries(rawFingerprints)) { + if (typeof path === "string" && typeof fingerprint === "string") { + fingerprints.set(path, fingerprint); + } + } + } + + this.calendarFingerprints = fingerprints; + return fingerprints; + } + + private async saveCalendarFingerprints(fingerprints?: Map): Promise { + const map = fingerprints || (await this.getCalendarFingerprints()); + const data = (await this.plugin.loadData()) || {}; + data[GOOGLE_CALENDAR_FINGERPRINTS_KEY] = Object.fromEntries(map.entries()); + await this.plugin.saveData(data); + } + + private getCalendarRelevantFingerprint(task: TaskInfo): string { + return JSON.stringify({ + title: task.title || "", + status: task.status || "", + priority: task.priority || "", + archived: !!task.archived, + scheduled: task.scheduled || null, + due: task.due || null, + timeEstimate: task.timeEstimate ?? null, + recurrence: task.recurrence || null, + recurrence_anchor: task.recurrence_anchor || null, + complete_instances: task.complete_instances || [], + skipped_instances: task.skipped_instances || [], + reminders: task.reminders || [], + tags: task.tags || [], + contexts: task.contexts || [], + projects: task.projects || [], + }); + } + + private parseCalendarRelevantFingerprint( + fingerprint: string | undefined + ): Record | null { + if (!fingerprint) { + return null; + } + + try { + const parsed = JSON.parse(fingerprint); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + + private getTaskStateFromFingerprint( + task: TaskInfo, + fingerprint: string | undefined + ): TaskInfo | undefined { + const fingerprintData = this.parseCalendarRelevantFingerprint(fingerprint); + if (!fingerprintData) { + return undefined; + } + + const stringValue = (key: string): string | undefined => { + const value = fingerprintData[key]; + return typeof value === "string" ? value : undefined; + }; + const stringArrayValue = (key: string): string[] | undefined => { + const value = fingerprintData[key]; + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : undefined; + }; + const recurrenceAnchor = fingerprintData.recurrence_anchor; + + return { + ...task, + title: stringValue("title") || task.title, + status: stringValue("status") || task.status, + priority: stringValue("priority") || task.priority, + archived: + typeof fingerprintData.archived === "boolean" + ? fingerprintData.archived + : task.archived, + scheduled: stringValue("scheduled"), + due: stringValue("due"), + timeEstimate: + typeof fingerprintData.timeEstimate === "number" + ? fingerprintData.timeEstimate + : undefined, + recurrence: stringValue("recurrence"), + recurrence_anchor: + recurrenceAnchor === "scheduled" || recurrenceAnchor === "completion" + ? recurrenceAnchor + : undefined, + complete_instances: stringArrayValue("complete_instances"), + skipped_instances: stringArrayValue("skipped_instances"), + tags: stringArrayValue("tags"), + contexts: stringArrayValue("contexts"), + projects: stringArrayValue("projects"), + }; + } + + private hasTaskCalendarLink(task: TaskInfo): boolean { + return !!this.getTaskEventId(task); + } + + private normalizeStatusValue(value: unknown): string { + return typeof value === "boolean" ? (value ? "true" : "false") : String(value); + } + + private async reconcileExternalAutoArchive( + task: TaskInfo, + previousTask?: TaskInfo + ): Promise { + if (!previousTask || !this.plugin.autoArchiveService) { + return; + } + + const previousStatus = this.normalizeStatusValue(previousTask.status); + const currentStatus = this.normalizeStatusValue(task.status); + if (previousStatus === currentStatus) { + return; + } + + try { + const statusConfig = this.plugin.statusManager.getStatusConfig(currentStatus); + if (!statusConfig) { + return; + } + + if (statusConfig.autoArchive) { + await this.plugin.autoArchiveService.scheduleAutoArchive(task, statusConfig); + } else { + await this.plugin.autoArchiveService.cancelAutoArchive(task.path); + } + } catch (error) { + tasknotesLogger.warn("Failed to reconcile auto-archive for external task update:", { + category: "persistence", + operation: "reconcile-external-auto-archive", + error: error, + }); + } + } + + private async recordCalendarSyncFingerprint(task: TaskInfo): Promise { + const fingerprints = await this.getCalendarFingerprints(); + const fingerprint = this.getCalendarRelevantFingerprint(task); + + if (fingerprints.get(task.path) === fingerprint) { + return; + } + + fingerprints.set(task.path, fingerprint); + await this.saveCalendarFingerprints(fingerprints); + } + + private async removeCalendarSyncFingerprint(taskPath: string): Promise { + const fingerprints = await this.getCalendarFingerprints(); + if (!fingerprints.delete(taskPath)) { + return; + } + + await this.saveCalendarFingerprints(fingerprints); + } + private async upsertEventIndex( taskPath: string, calendarId: string, @@ -528,6 +712,106 @@ export class TaskCalendarSyncService { await this.processPendingSyncQueue(); } + async initializeExternalFileReconciliation(): Promise { + const settings = this.plugin.settings.googleCalendarExport; + if (!settings.enabled) { + return; + } + + const fingerprints = await this.getCalendarFingerprints(); + const tasks = await this.plugin.cacheManager.getAllTasks(); + const activeTaskPaths = new Set(); + let changed = false; + + for (const task of tasks) { + activeTaskPaths.add(task.path); + const fingerprint = this.getCalendarRelevantFingerprint(task); + const previousFingerprint = fingerprints.get(task.path); + + if (previousFingerprint === undefined) { + fingerprints.set(task.path, fingerprint); + changed = true; + continue; + } + + const previousTask = this.getTaskStateFromFingerprint(task, previousFingerprint); + + if (previousFingerprint !== fingerprint) { + await this.reconcileExternalAutoArchive(task, previousTask); + } + + if (this.hasTaskCalendarLink(task) && previousFingerprint !== fingerprint) { + if (settings.syncOnTaskUpdate) { + await this.executeTaskUpdate(task, previousTask); + } else { + fingerprints.set(task.path, fingerprint); + changed = true; + } + continue; + } + + if (previousFingerprint !== fingerprint) { + fingerprints.set(task.path, fingerprint); + changed = true; + } + } + + for (const path of Array.from(fingerprints.keys())) { + if (!activeTaskPaths.has(path)) { + fingerprints.delete(path); + changed = true; + } + } + + if (changed) { + await this.saveCalendarFingerprints(fingerprints); + } + } + + async handleExternalTaskFileUpdated(taskPath: string, updatedTask?: TaskInfo): Promise { + const settings = this.plugin.settings.googleCalendarExport; + if (!settings.enabled) { + return; + } + + const task = updatedTask || (await this.plugin.cacheManager.getTaskInfo(taskPath)); + if (!task) { + await this.removeCalendarSyncFingerprint(taskPath); + return; + } + + const fingerprints = await this.getCalendarFingerprints(); + const fingerprint = this.getCalendarRelevantFingerprint(task); + const previousFingerprint = fingerprints.get(task.path); + + if (previousFingerprint === fingerprint) { + return; + } + + const previousTask = this.getTaskStateFromFingerprint(task, previousFingerprint); + await this.reconcileExternalAutoArchive(task, previousTask); + + if (this.hasTaskCalendarLink(task)) { + if (settings.syncOnTaskUpdate) { + await this.executeTaskUpdate(task, previousTask); + } else { + await this.recordCalendarSyncFingerprint(task); + } + return; + } + + if (this.isTaskCalendarEligible(task)) { + if (settings.syncOnTaskCreate) { + await this.syncTaskToCalendar(task); + } else { + await this.recordCalendarSyncFingerprint(task); + } + return; + } + + await this.recordCalendarSyncFingerprint(task); + } + async recoverDeletedTaskEventsFromIndex(): Promise { if (!this.plugin.settings.googleCalendarExport.syncOnTaskDelete) { return; @@ -1488,6 +1772,7 @@ export class TaskCalendarSyncService { } } + await this.recordCalendarSyncFingerprint(task); return true; } catch (error: unknown) { // Check if it's a 404 error (event was deleted externally) @@ -1619,7 +1904,7 @@ export class TaskCalendarSyncService { /** * Internal method that performs the actual task update sync */ - private async executeTaskUpdate(task: TaskInfo): Promise { + private async executeTaskUpdate(task: TaskInfo, previousOverride?: TaskInfo): Promise { const existingEventId = this.getTaskEventId(task); // If task no longer meets sync criteria, delete the event @@ -1631,6 +1916,8 @@ export class TaskCalendarSyncService { category: "provider", operation: "google-calendar-deletion-queued", }); + } else { + await this.removeCalendarSyncFingerprint(task.path); } } // Clean up previous state @@ -1639,13 +1926,13 @@ export class TaskCalendarSyncService { } // Get previous state for recurrence change detection - const previousState = this.previousTaskState.get(task.path); + const previousState = previousOverride || this.previousTaskState.get(task.path); // Sync the updated task - await this.syncTaskToCalendar(task, previousState); - - // Update previous state with current task - this.previousTaskState.set(task.path, task); + const synced = await this.syncTaskToCalendar(task, previousState); + if (synced) { + this.previousTaskState.set(task.path, task); + } } /** @@ -1665,7 +1952,10 @@ export class TaskCalendarSyncService { this.inFlightSyncs.set(task.path, completionPromise); try { - await completionPromise; + const completed = await completionPromise; + if (completed) { + await this.recordCalendarSyncFingerprint(task); + } } finally { if (this.inFlightSyncs.get(task.path) === completionPromise) { this.inFlightSyncs.delete(task.path); @@ -1673,24 +1963,24 @@ export class TaskCalendarSyncService { } } - private async executeTaskCompletion(task: TaskInfo): Promise { + private async executeTaskCompletion(task: TaskInfo): Promise { const settings = this.plugin.settings.googleCalendarExport; let existingEventId = this.getTaskEventId(task); if (!existingEventId) { const synced = await this.syncTaskToCalendar(task); if (!synced) { - return; + return false; } existingEventId = this.getTaskEventId(task); if (!existingEventId) { - return; + return false; } } // For recurring tasks, update EXDATE to exclude completed instance if (this.shouldSyncAsRecurring(task)) { await this.updateRecurringEventExdates(task); - return; + return true; } try { @@ -1705,11 +1995,12 @@ export class TaskCalendarSyncService { description, }) ); + return true; } catch (error: unknown) { if (getErrorStatus(error) === 404) { // Event was deleted externally, clean up the link await this.removeTaskEventId(task.path); - return; + return false; } tasknotesLogger.error("[TaskCalendarSync] Failed to update completed task:", { category: "provider", @@ -1717,6 +2008,7 @@ export class TaskCalendarSyncService { details: { value: task.path }, error: error, }); + return false; } } @@ -1799,6 +2091,7 @@ export class TaskCalendarSyncService { // Only remove the event ID when deletion succeeded or the event is already gone await this.removeTaskEventId(task.path); + await this.removeCalendarSyncFingerprint(task.path); return true; } @@ -1846,6 +2139,7 @@ export class TaskCalendarSyncService { } // No need to remove from frontmatter since the task file is being deleted. + await this.removeCalendarSyncFingerprint(taskPath); return results.every(Boolean); } @@ -1961,6 +2255,7 @@ export class TaskCalendarSyncService { // Remove the event ID from task frontmatter await this.removeTaskEventId(task.path); + await this.removeCalendarSyncFingerprint(task.path); unlinkedCount++; } diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts index fdd59a96..4f431f14 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -16,6 +16,7 @@ describe("TaskCalendarSyncService", () => { beforeEach(() => { jest.useFakeTimers(); + const pluginData: Record = {}; mockPlugin = { settings: { @@ -40,6 +41,14 @@ describe("TaskCalendarSyncService", () => { cacheManager: { getTaskInfo: jest.fn() }, + 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); + }), statusManager: { getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" })), isCompletedStatus: jest.fn((status?: string) => status === "done") diff --git a/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts b/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts new file mode 100644 index 00000000..e171ca6e --- /dev/null +++ b/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, jest, beforeEach } 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; + } + }, +})); + +function createPlugin( + tasks: TaskInfo[], + frontmatter: Record = {}, + pluginData: Record = {} +) { + return { + settings: { + googleCalendarExport: { + enabled: true, + targetCalendarId: "primary", + syncOnTaskCreate: true, + syncOnTaskUpdate: true, + syncOnTaskComplete: true, + syncOnTaskDelete: true, + eventTitleTemplate: "{{title}}", + includeDescription: true, + 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("Example Vault"), + }, + 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((priority: string) => ({ + label: priority === "3-medium" ? "Medium" : priority, + })), + }, + statusManager: { + getStatusConfig: jest.fn((status: string) => ({ + label: status === "done" ? "Done" : status === "ready" ? "Ready" : status, + })), + isCompletedStatus: jest.fn().mockImplementation((status: string) => status === "done"), + }, + autoArchiveService: { + scheduleAutoArchive: jest.fn().mockResolvedValue(undefined), + cancelAutoArchive: jest.fn().mockResolvedValue(undefined), + }, + i18n: { + 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.openInObsidian": + "Open in Obsidian", + }; + const translation = translations[key] || key; + return translation.replace(/\{(\w+)\}/g, (_match, name) => + String(params?.[name] ?? "") + ); + }), + }, + cacheManager: { + getAllTasks: jest.fn().mockResolvedValue(tasks), + getTaskInfo: jest.fn().mockImplementation(async (path: string) => { + return tasks.find((task) => task.path === path) || null; + }), + }, + emitter: { + trigger: jest.fn(), + }, + 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); + }), + } as any; +} + +function createGoogleCalendarService() { + return { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn().mockResolvedValue({ id: "google-primary-created-event-id" }), + updateEvent: jest.fn().mockResolvedValue({}), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("Google Calendar external file reconciliation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("updates a linked event when an external edit marks a one-off task done", async () => { + const readyTask = { + path: "TaskNotes/Tasks/prepare-plan.md", + title: "Prepare plan", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + timeEstimate: 180, + googleCalendarEventId: "event-1", + } as TaskInfo; + const doneTask = { + ...readyTask, + status: "done", + completedDate: "2026-05-14", + }; + const pluginData: Record = {}; + const plugin = createPlugin([doneTask], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [readyTask.path]: (syncService as any).getCalendarRelevantFingerprint(readyTask), + }; + + await syncService.handleExternalTaskFileUpdated(doneTask.path, doneTask); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "event-1", + expect.objectContaining({ + summary: "✓ Prepare plan", + description: expect.stringContaining("Status: Done"), + }) + ); + expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({ + [doneTask.path]: (syncService as any).getCalendarRelevantFingerprint(doneTask), + }); + }); + + it("schedules auto-archive when an external edit enters an auto-archived status", async () => { + const readyTask = { + path: "TaskNotes/Tasks/archive-after-done.md", + title: "Archive after done", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const doneTask = { + ...readyTask, + status: "done", + completedDate: "2026-05-14", + }; + const pluginData: Record = {}; + const plugin = createPlugin([doneTask], {}, pluginData); + const doneStatus = { + id: "done", + value: "done", + label: "Done", + color: "#00aa00", + isCompleted: true, + order: 1, + autoArchive: true, + autoArchiveDelay: 5, + }; + plugin.statusManager.getStatusConfig.mockImplementation((status: string) => + status === "done" ? doneStatus : undefined + ); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [readyTask.path]: (syncService as any).getCalendarRelevantFingerprint(readyTask), + }; + + await syncService.handleExternalTaskFileUpdated(doneTask.path, doneTask); + + expect(plugin.autoArchiveService.scheduleAutoArchive).toHaveBeenCalledWith( + doneTask, + doneStatus + ); + expect(plugin.autoArchiveService.cancelAutoArchive).not.toHaveBeenCalled(); + }); + + it("cancels auto-archive when an external edit leaves an auto-archived status", async () => { + const doneTask = { + path: "TaskNotes/Tasks/reopened.md", + title: "Reopened", + status: "done", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const readyTask = { + ...doneTask, + status: "ready", + completedDate: undefined, + }; + const pluginData: Record = {}; + const plugin = createPlugin([readyTask], {}, pluginData); + const readyStatus = { + id: "ready", + value: "ready", + label: "Ready", + color: "#999999", + isCompleted: false, + order: 1, + autoArchive: false, + autoArchiveDelay: 5, + }; + plugin.statusManager.getStatusConfig.mockImplementation((status: string) => + status === "ready" ? readyStatus : undefined + ); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [doneTask.path]: (syncService as any).getCalendarRelevantFingerprint(doneTask), + }; + + await syncService.handleExternalTaskFileUpdated(readyTask.path, readyTask); + + expect(plugin.autoArchiveService.cancelAutoArchive).toHaveBeenCalledWith(readyTask.path); + expect(plugin.autoArchiveService.scheduleAutoArchive).not.toHaveBeenCalled(); + }); + + it("repairs a linked task changed while Obsidian was closed", async () => { + const readyTask = { + path: "TaskNotes/Tasks/offline-linked.md", + title: "Offline linked", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const doneTask = { + ...readyTask, + status: "done", + completedDate: "2026-05-14", + }; + const pluginData: Record = {}; + const plugin = createPlugin([doneTask], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [readyTask.path]: (syncService as any).getCalendarRelevantFingerprint(readyTask), + }; + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "event-1", + expect.objectContaining({ + summary: "✓ Offline linked", + description: expect.stringContaining("Status: Done"), + }) + ); + }); + + it("baselines missing startup fingerprints for linked tasks without API writes", async () => { + const task = { + path: "TaskNotes/Tasks/existing-linked.md", + title: "Existing linked", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const pluginData: Record = {}; + const plugin = createPlugin([task], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(googleCalendarService.updateEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({ + [task.path]: (syncService as any).getCalendarRelevantFingerprint(task), + }); + }); +}); From f1f73c3711950fcaffc69acda3f1f2f36bac4166 Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Fri, 22 May 2026 16:36:56 +0100 Subject: [PATCH 2/2] Recover stale calendar event indexes periodically --- docs/releases/unreleased.md | 1 + src/services/TaskCalendarSyncService.ts | 17 +++++- ...google-calendar-delete-retry-queue.test.ts | 61 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 583b687c..e5397105 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -28,5 +28,6 @@ Example: - (#1921) Fixed Google Calendar and auto-archive side effects falling stale after direct task file edits. - Reconciles lifecycle-relevant frontmatter changes through the existing task file update event, with a first-run fingerprint baseline to avoid bulk startup API writes. + - Periodic recovery also cleans up indexed Google Calendar events whose task files were deleted or replaced while TaskNotes was running. - (#1911) Fixed recurrence choices starting from today instead of the selected calendar date when creating a task from Calendar view. - Thanks to @mikhailmarka for reporting. diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index fd636b75..59996b54 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -503,6 +503,13 @@ export class TaskCalendarSyncService { ): Promise { const index = await this.getEventIndex(); const key = this.getDeletionQueueKey({ calendarId, eventId }); + const matchingEntries = index.filter( + (item) => + this.getDeletionQueueKey(item) === key && + item.taskPath === taskPath && + item.calendarId === calendarId && + item.eventId === eventId + ); const replacedEntries = index.filter( (item) => item.taskPath === taskPath && @@ -515,6 +522,14 @@ export class TaskCalendarSyncService { !(item.taskPath === taskPath && item.calendarId === calendarId) ); + if ( + matchingEntries.length === 1 && + replacedEntries.length === 0 && + filteredIndex.length === index.length - 1 + ) { + return; + } + filteredIndex.push({ taskPath, calendarId, @@ -703,11 +718,11 @@ export class TaskCalendarSyncService { } async processStartupRecovery(): Promise { - await this.recoverDeletedTaskEventsFromIndex(); await this.processRecoveryQueues(); } async processRecoveryQueues(): Promise { + await this.recoverDeletedTaskEventsFromIndex(); await this.processDeletionQueue(); await this.processPendingSyncQueue(); } 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 index 4f1d8ea2..a92703a7 100644 --- a/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts +++ b/tests/unit/issues/issue-google-calendar-delete-retry-queue.test.ts @@ -215,6 +215,30 @@ describe("Google Calendar deletion retry queue", () => { expect(pluginData.googleCalendarEventIndex).toEqual([]); }); + it("recovers indexed deleted task events during normal recovery queue processing", async () => { + const pluginData = { + googleCalendarEventIndex: [ + { + taskPath: "TaskNotes/Tasks/deleted-before-retry.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.processRecoveryQueues(); + + 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: [ @@ -250,6 +274,43 @@ describe("Google Calendar deletion retry queue", () => { expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); }); + it("does not rewrite unchanged event index entries during recovery", async () => { + const taskPath = "TaskNotes/Tasks/still-indexed.md"; + const pluginData = { + googleCalendarEventIndex: [ + { + taskPath, + calendarId: "primary", + eventId: "stable-event", + updatedAt: 1, + }, + ], + }; + const plugin = createPlugin(pluginData); + plugin.cacheManager.getAllTasks = jest.fn().mockResolvedValue([ + TaskFactory.createTask({ + path: taskPath, + scheduled: "2026-04-29", + googleCalendarEventId: "stable-event", + }), + ]); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.recoverDeletedTaskEventsFromIndex(); + + expect(plugin.saveData).not.toHaveBeenCalled(); + expect(googleCalendarService.deleteEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarEventIndex).toEqual([ + expect.objectContaining({ + taskPath, + calendarId: "primary", + eventId: "stable-event", + updatedAt: 1, + }), + ]); + }); + it("cleans up an older indexed event when the same task receives a replacement event id", async () => { const pluginData = { googleCalendarEventIndex: [