From fdf3b90a1cad6cbc7dfc81b9bfbece59a96756be Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Sun, 16 Aug 2026 22:13:52 -0300 Subject: [PATCH] fix(google-calendar): export tasks created while Obsidian was closed Startup reconciliation baselined every task path it had never seen and skipped the rest of the loop, so a task file that arrived from an external sync (Syncthing, git, another device) was recorded as known without ever being exported. The fingerprint then matched on every later startup, so the task never reached Google Calendar. The live path (handleExternalTaskFileUpdated) already exports an unknown eligible task; startup now makes the same decision instead of a different one. The initial baseline is preserved: when no fingerprints exist yet, all tasks are still baselined so enabling export never floods the calendar. --- src/services/TaskCalendarSyncService.ts | 21 +++ ...endar-external-file-reconciliation.test.ts | 134 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index cd0d1095a..20a09dd25 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -874,6 +874,11 @@ export class TaskCalendarSyncService { let changedTasks = 0; let linkedTasks = 0; let baselineTasks = 0; + let createdTasks = 0; + + // An empty fingerprint map means this vault was never reconciled: every task is + // unknown, so baseline all of them instead of flooding the calendar on first run. + const isFirstReconciliation = fingerprints.size === 0; for (const task of tasks) { activeTaskPaths.add(task.path); @@ -881,6 +886,21 @@ export class TaskCalendarSyncService { const previousFingerprint = fingerprints.get(task.path); if (previousFingerprint === undefined) { + // A task file that appeared while Obsidian was closed (external sync, git pull, + // another device) has no fingerprint yet. Treat it the way the live path in + // handleExternalTaskFileUpdated already does: export it, instead of baselining + // it into permanent invisibility. + if ( + !isFirstReconciliation && + settings.syncOnTaskCreate && + !this.hasTaskCalendarLink(task) && + this.isTaskCalendarEligible(task) + ) { + createdTasks++; + await this.syncTaskToCalendar(task); + continue; + } + fingerprints.set(task.path, fingerprint); changed = true; baselineTasks++; @@ -927,6 +947,7 @@ export class TaskCalendarSyncService { this.profileGauge("initializeExternalFileReconciliation.linkedTasks", linkedTasks); this.profileGauge("initializeExternalFileReconciliation.changedTasks", changedTasks); this.profileGauge("initializeExternalFileReconciliation.baselineTasks", baselineTasks); + this.profileGauge("initializeExternalFileReconciliation.createdTasks", createdTasks); this.profileGauge( "initializeExternalFileReconciliation.removedFingerprints", removedFingerprints 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 index 8286dd637..cdbfb38b8 100644 --- a/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts +++ b/tests/unit/issues/issue-google-calendar-external-file-reconciliation.test.ts @@ -294,6 +294,140 @@ describe("Google Calendar external file reconciliation", () => { ); }); + it("exports a task created while Obsidian was closed", async () => { + const knownTask = { + path: "TaskNotes/Tasks/already-known.md", + title: "Already known", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const newTask = { + path: "TaskNotes/Tasks/created-offline.md", + title: "Created offline", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-15", + } as TaskInfo; + const pluginData: Record = {}; + const plugin = createPlugin([knownTask, newTask], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask), + }; + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).toHaveBeenCalledTimes(1); + expect(googleCalendarService.createEvent).toHaveBeenCalledWith( + "primary", + expect.objectContaining({ summary: "Created offline" }) + ); + }); + + it("does not export tasks on the first reconciliation of a vault", async () => { + const firstTask = { + path: "TaskNotes/Tasks/first-run-a.md", + title: "First run A", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + } as TaskInfo; + const secondTask = { + ...firstTask, + path: "TaskNotes/Tasks/first-run-b.md", + title: "First run B", + } as TaskInfo; + const pluginData: Record = {}; + const plugin = createPlugin([firstTask, secondTask], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({ + [firstTask.path]: (syncService as any).getCalendarRelevantFingerprint(firstTask), + [secondTask.path]: (syncService as any).getCalendarRelevantFingerprint(secondTask), + }); + }); + + it("does not duplicate an event when the new task already carries an event id", async () => { + const knownTask = { + path: "TaskNotes/Tasks/already-known.md", + title: "Already known", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const newTaskWithEvent = { + path: "TaskNotes/Tasks/created-offline-with-event.md", + title: "Created offline with event", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-15", + googleCalendarEventId: "event-created-elsewhere", + } as TaskInfo; + const pluginData: Record = {}; + const plugin = createPlugin([knownTask, newTaskWithEvent], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask), + }; + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(googleCalendarService.updateEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({ + [newTaskWithEvent.path]: (syncService as any).getCalendarRelevantFingerprint( + newTaskWithEvent + ), + }); + }); + + it("does not export an ineligible task created while Obsidian was closed", async () => { + const knownTask = { + path: "TaskNotes/Tasks/already-known.md", + title: "Already known", + status: "ready", + priority: "3-medium", + archived: false, + scheduled: "2026-05-14", + googleCalendarEventId: "event-1", + } as TaskInfo; + const undatedTask = { + path: "TaskNotes/Tasks/created-offline-undated.md", + title: "Created offline undated", + status: "ready", + priority: "3-medium", + archived: false, + } as TaskInfo; + const pluginData: Record = {}; + const plugin = createPlugin([knownTask, undatedTask], {}, pluginData); + const googleCalendarService = createGoogleCalendarService(); + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + pluginData.googleCalendarTaskFingerprints = { + [knownTask.path]: (syncService as any).getCalendarRelevantFingerprint(knownTask), + }; + + await syncService.initializeExternalFileReconciliation(); + + expect(googleCalendarService.createEvent).not.toHaveBeenCalled(); + expect(pluginData.googleCalendarTaskFingerprints).toMatchObject({ + [undatedTask.path]: (syncService as any).getCalendarRelevantFingerprint(undatedTask), + }); + }); + it("baselines missing startup fingerprints for linked tasks without API writes", async () => { const task = { path: "TaskNotes/Tasks/existing-linked.md",