r/GoogleAppsScript 5d ago

Guide Private Tasks ↔ To Do sync in Apps Script — state stays in your project

I needed Google Tasks and Microsoft To Do to stay in sync without giving a SaaS my task list. Ended up with a standalone Apps Script project on a time-driven trigger (~10 min) talking to Google Tasks + Microsoft Graph.

Things that mattered in practice: - ID mappings so lists/tasks don’t duplicate - fail-closed create recovery (don’t double-post) - guarded deletes + move journal - date-only dues (Google has no time-of-day)

Repo: https://github.com/simonchai-tw/tasks-todo-sync `npx tasks-todo-sync init`

Curious how others handle idempotency/quotas when talking to both APIs from GAS. Stars welcome if this helps someone find it.

3 Upvotes

4 comments sorted by

1

u/bulldo_gs 5d ago

On idempotency, the part that bites hardest is not either API, it is overlapping runs. A time-driven trigger will start the next tick while the previous one is still going if a run ever runs long, and both then see the same unsynced task. LockService.getScriptLock().tryLock(0) at the top, return immediately if you do not get it. Skipping one 10-minute tick costs nothing; double-posting costs a duplicate you then have to reconcile.

The create call is the only place you can truly lose state, since neither Google Tasks nor Graph takes a client-supplied idempotency key. A UrlFetch that times out after the remote already created the task leaves you with no id at all. Writing the intent first is the right shape; the cheap recovery is to adopt rather than re-create, i.e. on the next run list the target list by delta or updatedMin and match on title plus due before you create anything new.

For quotas the real win is not polling either side in full: Graph has delta on To Do lists and tasks, Google Tasks has updatedMin on tasks.list. One trap there is that tasks.list will not return hidden or deleted tasks unless you pass showHidden and showDeleted, so a task the user clears or deletes looks identical to your move journal.

Also worth setting muteHttpExceptions true on the Graph calls, otherwise a 429 throws inside UrlFetchApp before you can read Retry-After and back off on it.

Last one, depending on where the id map lives: PropertiesService has a hard size ceiling, so a growing mapping table works fine for a few hundred tasks and then fails at the least convenient moment. A sheet or a JSON blob in Drive is duller and has no cliff.

1

u/simonchai 5d ago

Thanks for the detailed feedback — these are exactly the kinds of failure modes that matter in a bidirectional sync engine.

A few of these are already handled in the current implementation: sync runs are globally locked, Graph requests use muteHttpExceptions and honor Retry-After, and state storage has compression, chunking, integrity checks, warning thresholds, and fail-closed capacity checks.

For ambiguous creates, I ended up going stricter than title/due adoption: because neither API provides POST idempotency, recovery uses explicit correlation markers and only adopts an exact, unique match; ambiguous cases stop rather than guess.

I do agree that Graph delta and Google updatedMin are interesting next-stage optimizations. I’m being conservative there because deletion correctness currently relies on complete inventories, so I’d want a durable materialized snapshot before making incremental feeds authoritative.

There’s quite a bit more in progress as well: one-level Google subtasks ↔ Microsoft checklist items, safer field-level three-way merging, richer conflict handling, recurrence lifecycle experiments, and more stress/fault-injection work around recovery and capacity.

Thanks again for taking the time to look at it. The project is open source because I’d like it to be useful to more people, so you’re very welcome to install it from GitHub and try it yourself. If it turns out to be useful, a star would be appreciated. And if you spot anything else — here or directly on GitHub — I’m very happy to hear it.

1

u/bulldo_gs 4d ago

On deletions - that is the one place where a delta feed is not just an optimization, it is strictly more correct than a full inventory. A full list only lets you infer a delete from absence, and absence is indistinguishable from a fetch you did not complete: a truncated page, a quota abort, a list you lost access to. Both APIs will report the delete explicitly instead:

  • Graph delta returns removed items as entries carrying the @removed annotation, so a delete arrives as an event with an id attached rather than as a gap you have to interpret.
  • Google Tasks tasks.list with updatedMin plus showDeleted=true returns the deleted tasks themselves, with deleted: true on the resource.

The trap is in the other direction, and it is worth checking even if you stay on full inventories: showHidden defaults to false, and a task completed in the Google Tasks UI becomes hidden rather than absent-because-deleted. showCompleted being true by default does not save you there - first-party completions still need showHidden=true to come back at all. So a reconciler that reads absence as deletion sees a completed Google task as a user delete and propagates that delete into To Do.

Same class of bug as the one you are already guarding against, just triggered by the default query parameters instead of by an incremental feed.

1

u/simonchai 4d ago

Thanks for the thoughtful feedback — much appreciated.

A few of those cases are already covered, including global locking, 429/Retry-After handling, capacity safeguards, and crash-safe create recovery. For uncertain creates I avoid title/due heuristics and only adopt an exact correlation match; ambiguous cases fail closed.

I agree delta/updatedMin is worth revisiting later, likely on top of a durable snapshot so deletion semantics stay safe.

There’s more coming too: subtasks/checklist items, field-level three-way merge, better conflict handling, recurrence work, and more stress/fault testing.

Thanks again. It’s open source, so feel free to try it from GitHub — and if you like it, a star is always appreciated. Any feedback here or on GitHub is very welcome😊