docs/plans/2026-07-09-todoist-import.md
Status: revised & verified against code · 2026-07-09
Goal: let a Todoist user bring their active projects, tasks, sub-tasks, labels, due dates and time estimates into Super Productivity in one pass, non-destructively, without adding permanent weight to the core app.
importCompleteBackup path (wipes all state) is the wrong tool.src/app/features/issue/) is built for ongoing polling + remote-linked tasks and is
deliberately not used here.A bundled plugin does all the work; core gets only a launcher row.
packages/plugin-dev/todoist-import/, built into src/assets/bundled-plugins/).
Matches the maintainers' direction (Trello / Linear / ClickUp / Azure moved out of
core into plugins); fully replaceable / community-extensible.addProject / addTag for containers,batchUpdateForProject for all task creation (see below — this was missed in
the first draft and changes the op-log story),updateTask follow-ups only for fields the batch op doesn't carry
(dueDay, dueWithTime, tagIds).isSkipMenuEntry: true — no permanent menu noise for a one-time
tool. Discoverability comes from a single launcher row in the Import/Export
settings screen (src/app/imex/file-imex/): PluginService.activatePlugin(id, true)
then navigate to the existing plugins/:pluginId/index route. Deliberately the
in-memory enable (plugin-management additionally persists via
setPluginEnabled): after a restart the importer is dormant again — zero standing
weight; relaunching from the same row re-activates it.batchUpdateForProject, not per-task addTaskVerified in plugin-bridge.service.ts:1209 + task-shared-meta-reducers/task-batch-update.reducer.ts:
MAX_BATCH_OPERATIONS_SIZE) = one action = one op-log entry (sync rule #3).
A 1000-task import ≈ 20 ops for structure instead of 1000+.project.taskIds in array order (verified in
validate-and-fix-data-consistency-after-batch-update.ts). No reorder op needed for
freshly created projects.addTask would additionally reverse ordering (bridge hardcodes
isAddToBottom: false, i.e. prepend) — another reason the first draft's approach
was wrong.title / notes / isDone / parentId / timeEstimate only;
dueDay, dueWithTime and tagIds are applied with one updateTask per task that
has them. Ops ≈ ceil(tasks/50) per project + dated/labelled tasks — a tolerable
one-time burst, and it removes the main driver for a v2 importData core primitive.temp-/temp_-prefixed — anything else leaves children with
dangling parentIds that the consistency pass deletes as orphans;forEach chunking would
dispatch 100 actions in one tick;createdTaskIds per call,
so a child sent in a later call cannot resolve a temp- parent from an earlier
call — the executor must rewrite already-created parents to their real IDs
before sending each chunk (resolveKnownParents in run-import.ts; real IDs are
explicitly supported in BatchTaskCreate.parentId);success: true always, errors never populated) —
the post-import summary re-reads state (getTasks) and compares landed vs
planned counts instead of trusting the return value.TaskRepeatCfg creation — PluginTaskRepeatCfg is read-only. v1 degrades:
keep next due date + append Repeats: <string> to notes.src/app/features/section/) — the first
draft claimed it didn't. But there is no plugin API / allowed action to create
sections, so v1 still drops Todoist sections (task order within the project is
preserved; flagged as lossy). v2 candidate: expose section creation to plugins.Project.folderId exists, but nothing creates
folders) — the first draft's parent_id → folderId mapping is unworkable. v1
flattens nested projects; when two projects collide on title, the child is
disambiguated as Parent / Child. Flagged as lossy.tagIds: [] for subtasks — SP model)
→ labels on Todoist sub-tasks are dropped, flagged in the summary.addTaskRepeatCfg and/or plugin-visible section/folder creation. Deferring is
deliberate (YAGNI): public plugin-API surface is hard to reverse. The bulk-import
primitive from the first draft is no longer needed — batchUpdateForProject
already folds structure into few ops. One more v2 candidate (from the perf review):
extend BatchTaskUpdate with dueDay/dueWithTime/tagIds — the per-task
updateTask follow-ups are the dominant import cost (one op each; O(k²) planner-day
scans at 5k dated tasks) and today there is no cheaper path.
API token only in v1. The user pastes a Todoist personal token (Settings →
Integrations → Developer); the plugin makes an initial
POST https://api.todoist.com/api/v1/sync with sync_token=*, followed immediately
by an incremental request with the returned sync token. This applies changes that
arrived while Todoist prepared a potentially delayed full snapshot. Both requests use
resource_types=["projects","items","sections","notes"] (notes = task comments,
folded into SP task notes; the labels resource is deliberately NOT requested —
item labels arrive as names on the items themselves) via the gated PluginAPI.request
(permissions:["http"] + allowedHosts:["api.todoist.com"]). The old Sync v9
endpoint is deprecated — use unified v1.
Token privacy (hard rule): the token lives in iframe memory for the session only —
never persistDataSynced (that syncs!), not even setSecret. Password-type input; UI
states "sent only to api.todoist.com, never stored".
CSV fallback: cut from v1 (YAGNI, folded review verdict). It is a second parser +
fixture suite + multi-file UI for strictly worse fidelity (no labels, no comments, no
tz fidelity, one tedious export per project) — and its DATE column holds localized
natural-language strings ("every day", "5 août") that cannot be parsed faithfully.
Named contingency for users who already closed their account; fast-follow, not v1.
Completed-task history is out of scope for v1 (the sync endpoint returns only active items by default — nothing extra to do).
| Todoist | → Super Productivity | Notes |
|---|---|---|
| project | Project | hierarchy flattened (no folder API, see correction #2); Parent / Child title only on collision |
Inbox project (inbox_project) | Project Inbox (Todoist) | never merged into SP's own Inbox — additive & reviewable |
| section | — | v1: ignore (no plugin API to create SP sections); flagged lossy. Sibling order key = (section.section_order, item.child_order) — sync items arrive unordered! |
| label | Tag | item labels are names in unified v1; match existing SP tags by title (case-insensitive), else addTag; only labels actually used by imported top-level tasks (SP subtasks can't hold tags — dropped + counted; the plugin must enforce this itself, the host won't) |
| item content | task title | |
| item description | task notes | markdown passes through |
comments (sync notes) | appended to task notes | same sync call; file attachments → keep the URL line, flag files as not imported |
priority (API 4=p1 … 1=p4) | — | inverted vs UI! Opt-in, default off, top-level only; never tag API priority 1 (p4 is Todoist's default on every task). Single control with two mappings: p1…p3 Tags, or Eisenhower (reuses SP's urgent/important tags: p1→both, p2→important, p3→urgent — added post-review per #8882 feedback) |
due (all-day, YYYY-MM-DD) | dueDay | via updateTask after batch create |
due (floating, YYYY-MM-DDTHH:MM:SS) | dueWithTime (unix ms) | parse as local time |
due (fixed-tz, trailing Z) | dueWithTime (unix ms) | parse as UTC instant — parsing as local shifts every fixed-time task |
| deadline | dueDay if no due; else Deadline: <date> appended to notes | nothing silently dropped |
duration minute | timeEstimate (ms) | |
duration day | — | skip + count in summary — fabricating 8h would corrupt time-tracking stats |
sub-task (parent_id) | sub-task (2 levels) | SP nests 2 levels only — depth ≥ 2 re-parents to the depth-0 ancestor in reading (DFS) order, demotion counted |
assignee (responsible_uid) | — | imported like any task; "N tasks had collaborator assignees" in summary |
recurring (due.is_recurring + due.string) | keep next due + append verbatim Repeats: <string> to notes | verbatim preserves every! (recur-from-completion) semantics; real TaskRepeatCfg only with v2 core work. Imported timed tasks get no reminder (updateTask bypasses scheduleTaskWithTime) — noted, acceptable: Todoist reminders aren't imported anyway |
| completed items | — | skip v1 |
packages/plugin-dev/todoist-import/
package.json # esbuild + jest, modeled on sync-md (no framework)
scripts/build.js # bundle ui/main.ts, INLINE bundle into index.html, copy manifest/icon/i18n
src/
manifest.json # iFrame:true, isSkipMenuEntry:true, permissions incl. "http",
# allowedHosts:["api.todoist.com"], hooks:[]
plugin.js # stub (all logic lives in the iframe UI)
ui/index.html # minimal shell; built JS inlined (iframe uses srcdoc →
# the document must be fully self-contained, verified in
# plugin-iframe.util.ts)
ui/main.ts # wizard: token → preview (per-project checkboxes) → import → summary
parse/from-api.ts # unified-v1 sync JSON → normalized model (pure)
parse/normalized-model.ts
map/plan-import.ts # normalized model → batch ops + follow-up updates (pure)
map/run-import.ts # executes the plan via PluginAPI, per-project failure boundary
*.spec.ts # jest over fixtures: due shapes, depth-3 nesting, section order,
# labels, priority inversion, duration units, deadline, comments
UI wizard specifics (trust items from review):
Preview = per-project checkboxes with task/subtask counts; projects whose title already exists in SP are flagged "already exists — possibly from a previous import" and default unchecked (re-run safety without rollback machinery). Lossy items listed up front (sections, demotions, day-durations, subtask labels, assignees).
Import runs project-by-project (batch + follow-ups per project before the next), so an abort leaves whole projects, and the summary can say "4/6 imported, failed at 'Errands'".
Post-import summary counts from re-read state, names everything dropped.
Register in packages/plugin-dev/scripts/build-all.js and
src/app/plugins/plugin.service.ts bundled list.
Core touch (discoverability only): one launcher button in
src/app/imex/file-imex/ + one en.json key.
PluginAPI.request (plugin-bridge.service.ts:508); app CSP is
connect-src *; Electron injects ACAO:*. Todoist's unified API documentation says
all endpoints except the initial OAuth authorization endpoint support CORS for any
origin; retain one live web-build sanity check during M3.due.date shapes, deadline,
recurring strings, durations (minute/day), priority inversion, section ordering,
comments incl. attachments, Inbox, assignees.temp- IDs, follow-up updates) +
executor. → verify: unit tests on the op-builder; manual import of a fixture into
a scratch profile: counts, nesting, order, due dates.activatePlugin + route to plugins/todoist-import/index); "Switch from Todoist"
docs page (search is how the day-they-quit-Todoist persona finds this — no
onboarding banners, per the manifesto). → verify: new user completes an import
from a cold start.getAllProjects() exposes active projects only, so
an archived prior import cannot be collision-flagged. Document the restore/delete
workaround; do not widen a permanent public plugin API solely for this importer.updateTask volume — one per dated/labelled task; bounded by the
batch op for structure; acceptable one-time burst. Watch 5k+ item accounts.day skipped, not 8h · Inbox → "Inbox
(Todoist)" · collision projects default-unchecked in preview.