Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,13 +874,33 @@ 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);
const fingerprint = this.getCalendarRelevantFingerprint(task);
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++;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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",
Expand Down
Loading