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",