feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)

* feat(plugins): add Todoist import plugin with Import/Export launcher

* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening

* fix(plugins): clamp non-finite Todoist durations + review polish

* feat(plugins): add Eisenhower priority mapping to Todoist import

Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).

The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.

Requested in the review of #8882.

* fix(plugins): harden Todoist import data integrity

* fix(plugins): improve Todoist import feedback
This commit is contained in:
Johannes Millan 2026-07-10 13:30:03 +02:00 committed by GitHub
parent d2015beb44
commit 762b3c5d8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 9060 additions and 5 deletions

View file

@ -34,7 +34,7 @@ jobs:
run: |
set -euo pipefail
# Plugins that have an `npm test` script configured.
PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md)
PLUGINS=(automations azure-devops-issue-provider caldav-calendar-provider clickup-issue-provider google-calendar-provider sync-md todoist-import)
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
CHANGED=$(printf '%s\n' "${PLUGINS[@]}")

View file

@ -0,0 +1,229 @@
# Todoist → Super Productivity migration
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.
## Product framing
- Migration runs **once per user** and is then dead weight. Per the manifesto (avoid
feature creep; new UI/settings are permanent costs), it lives at the edge, not in
core hot paths.
- It must be **additive**, never destructive — most people evaluating SP already have
some data. The existing `importCompleteBackup` path (wipes all state) is the wrong tool.
- It is a **one-time import**, not a live integration. The issue-provider framework
(`src/app/features/issue/`) is built for ongoing polling + remote-linked tasks and is
deliberately **not** used here.
## Chosen approach
**A bundled plugin does all the work; core gets only a launcher row.**
- Front-end + parsing + mapping + preview UI = a **bundled plugin**
(`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.
- Landing the data uses only existing plugin-API methods — **zero new core API**:
- `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`).
- The plugin ships `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.
### Key correction #1: use `batchUpdateForProject`, not per-task `addTask`
Verified in `plugin-bridge.service.ts:1209` + `task-shared-meta-reducers/task-batch-update.reducer.ts`:
- The batch API is **backed by a meta-reducer**: one dispatched chunk (≤ 50 ops,
`MAX_BATCH_OPERATIONS_SIZE`) = **one action = one op-log entry** (sync rule #3).
A 1000-task import ≈ 20 ops for structure instead of 1000+.
- It handles **parent references via temp IDs** (bridge pre-generates real IDs and
returns the mapping) and preserves creation order — root tasks land in
`project.taskIds` in array order (verified in
`validate-and-fix-data-consistency-after-batch-update.ts`). No reorder op needed for
freshly created projects.
- Per-task `addTask` would additionally **reverse ordering** (bridge hardcodes
`isAddToBottom: false`, i.e. prepend) — another reason the first draft's approach
was wrong.
- Batch create data carries `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.
- **Hard constraints found in review** (the reducer enforces these silently):
- temp IDs **must** be `temp-`/`temp_`-prefixed — anything else leaves children with
dangling `parentId`s that the consistency pass **deletes** as orphans;
- a parent's create op must sit at a **lower index than its children's** — the bridge
chunks at 50 ops/action and the roots-first sort is per-chunk only;
- the plugin **chunks its own calls at ≤ 50 ops and awaits each** — every iframe call
is its own postMessage round-trip, so this keeps one dispatch per tick (sync rule
#6) even for 5k-task projects, where the bridge's internal `forEach` chunking would
dispatch 100 actions in one tick;
- **ordering alone is NOT enough across self-chunked calls** (caught by the
post-implementation multi-review): the bridge builds `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`);
- the result is fire-and-forget (`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.
### Key correction #2: what the plugin API actually can't do (verified)
1. **No `TaskRepeatCfg` creation**`PluginTaskRepeatCfg` is read-only. v1 degrades:
keep next due date + append `Repeats: <string>` to notes.
2. **SP _does_ have a Section entity now** (`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.
3. **No ProjectFolder creation API** (`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.
4. **Subtasks can't hold tags** (bridge forces `tagIds: []` for subtasks — SP model)
→ labels on Todoist sub-tasks are dropped, flagged in the summary.
### v2 (only if v1 validates demand)
`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.
## Input source
**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).
## Mapping
| 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 |
## Architecture / file layout (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.
## Milestones (each with its check)
- **M0 · Spike — ✅ DONE, verdict GREEN.** Token path viable on web, Electron, mobile
via gated `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.
- **M1 · Parse + normalize.** Sync-v1 JSON → normalized model. → _verify:_ jest
fixtures: parent chains incl. depth 3+, the three `due.date` shapes, deadline,
recurring strings, durations (minute/day), priority inversion, section ordering,
comments incl. attachments, Inbox, assignees.
- **M2 · Plan + create.** Pure op-builder (normalized model → project/tag creates,
≤50-op batch chunks parent-before-child with `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.
- **M3 · UI + preview + summary.** Token input, per-project preview, import progress,
honest summary. → _verify:_ manual run web + Electron incl. full + incremental sync.
- **M4 · Discoverability + docs.** Launcher row in Import/Export
(`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.
## Risks & open decisions
- **Partial import on failure** — additive, not transactional; per-project execution
bounds the blast radius to whole projects and the summary names what landed.
Accepted for v1 (KISS) — no rollback machinery.
- **Archived-project re-runs**`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.
- **Follow-up `updateTask` volume** — one per dated/labelled task; bounded by the
batch op for structure; acceptable one-time burst. Watch 5k+ item accounts.
- **Todoist API drift** — unified v1 is current (v9 deprecated); parser is defensive
(unknown fields ignored, missing fields defaulted) and covered by fixtures.
- **Decided during review:** CSV cut from v1 · priority→tag default off (and API
priority 1 never tagged) · duration `day` skipped, not 8h · Inbox → "Inbox
(Todoist)" · collision projects default-unchecked in preview.

View file

@ -20,3 +20,5 @@ Every How-To note in the wiki. For structure and how to write How-Tos, see [[0.0
- [[2.16-Set-Up-Development-Environment]]
- [[2.17-Add-a-New-Issue-Integration]]
- [[2.18-Contribute-Translations]]
- [[2.19-Sync-Proton-Drive-via-rclone]]
- [[2.20-Import-from-Todoist]]

View file

@ -0,0 +1,55 @@
# Import from Todoist
Bring your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity in one pass. The import is **additive**: it only creates new projects, tags and tasks and never changes or removes existing Super Productivity data.
## What you need
- A Todoist account and its **API token**: in Todoist, open **Settings → Integrations → Developer** and copy the token. The token is sent only to `api.todoist.com` and is never stored.
## Steps
1. Open **Settings** (gear icon in the sidebar).
2. Open the **Sync & Backup** tab and find the **Import/Export** section.
3. Click **Import from Todoist**.
4. Paste your API token and click **Load preview**.
5. Review the preview: pick the projects to import (projects whose name already exists — for example from a previous run — are flagged and unchecked by default) and optionally choose how to carry over Todoist priorities (see [Priorities](#priorities) below).
6. Click **Import**. The summary lists what was created and everything that could not be carried over.
## What is imported
- Active projects (nested project hierarchy is flattened; the Todoist Inbox becomes a project named “Inbox (Todoist)”)
- Active tasks and sub-tasks (Super Productivity nests two levels; deeper sub-tasks become direct sub-tasks of their top-level task)
- Task descriptions and comments (into task notes; HTTP(S) comment file attachments keep their link)
- Labels used by imported tasks (as tags, on top-level tasks)
- Due dates, including times, and minute-based durations (as time estimates)
- Recurring tasks keep their next due date; the recurrence rule is added to the task notes (e.g. `Repeats: every 3 days`)
## Priorities
Todoist priorities are **off by default**. In the preview you can pick one of:
- **p1p3 tags** — adds a `p1`, `p2` or `p3` tag to each top-level task (Todoist's default p4 stays untagged).
- **Eisenhower matrix** — reuses Super Productivity's built-in `urgent` / `important` tags, so imported tasks appear in the Eisenhower Matrix board: p1 → urgent + important, p2 → important, p3 → urgent, p4 → neither. Because a single Todoist priority is being split across the two axes, this mapping is a sensible default rather than an exact translation.
Priority tags are only added to top-level tasks — sub-tasks in Super Productivity can't hold tags.
The preview counts prioritized sub-tasks that will not receive the selected priority tags.
## What is not imported
- Completed tasks and task history
- Nested project hierarchy and sections (project and task order is preserved)
- Labels and mapped priorities on sub-tasks
- Reminders, full-day durations, collaborator assignees and attachment files
- Recurrence rules as real repeating tasks — recreate important ones via [[2.06-Manage-Repeating-Tasks]]
Unusually long project names, task titles, labels and task notes are shortened to
safe import limits. The preview reports how many selected values are affected.
If the import stops midway (e.g. connection loss), delete the project named in the
error because it may be incomplete. Then re-run the import and select that project
and any remaining projects. Projects already listed as imported are complete. To
undo an import, delete the created projects.
The duplicate-title warning can check active Super Productivity projects only. If a
previously imported project was archived, restore or delete it before re-running the
import so that it can be detected.

View file

@ -393,6 +393,35 @@ const plugins = [
return 'Built and copied to assets';
},
},
{
name: 'todoist-import',
path: 'todoist-import',
needsInstall: true,
copyToAssets: true,
buildCommand: async (pluginPath) => {
await execAsync(`cd ${pluginPath} && npm run build`);
const targetDir = path.join(
__dirname,
'../../../src/assets/bundled-plugins/todoist-import',
);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const distPath = path.join(pluginPath, 'dist');
if (fs.existsSync(distPath)) {
const files = fs.readdirSync(distPath);
for (const file of files) {
copyRecursive(path.join(distPath, file), path.join(targetDir, file));
}
}
assertFilesExist(
targetDir,
['manifest.json', 'plugin.js', 'index.html', 'icon.svg', 'i18n/en.json'],
'todoist-import',
);
return 'Built and copied to assets';
},
},
];
async function buildPlugin(plugin) {

View file

@ -0,0 +1,76 @@
{
"TITLE": {
"IMPORT": "Import from Todoist",
"PREVIEW": "Preview",
"IMPORTING": "Importing…",
"FINISHED": "Import finished",
"INCOMPLETE": "Import incomplete"
},
"TOKEN": {
"LABEL": "Todoist API token",
"PLACEHOLDER": "Paste your Todoist API token",
"INTRO": "Brings your active Todoist projects, tasks, sub-tasks, labels and due dates into Super Productivity. The import only adds data — nothing in Super Productivity is changed or removed.",
"HELP": "Find the token in Todoist under Settings → Integrations → Developer. It is sent only to api.todoist.com and never stored.",
"REQUIRED": "Please paste your Todoist API token first.",
"LOADING": "Loading your Todoist data…",
"NO_PROJECTS": "No active projects found for this Todoist account."
},
"ERROR": {
"LOAD_FAILED": "Could not load data from Todoist: {{error}} — check the token and your connection.",
"IMPORT_FAILED": "Import failed: {{error}}",
"IMPORT_STOPPED": "Import stopped at “{{project}}”: {{error}}. That project was created only partially — delete “{{project}}” before re-running, then select it and the remaining projects again."
},
"BUTTON": {
"LOAD_PREVIEW": "Load preview",
"IMPORT": "Import",
"BACK": "Back"
},
"PREVIEW": {
"CHOOSE_PROJECTS": "Choose the projects to import:",
"PROJECT_COUNTS": "{{title}} — tasks: {{taskCount}}, sub-tasks: {{subTaskCount}}",
"ALREADY_EXISTS": "already exists — possibly imported before",
"PRIORITY_LEGEND": "Map Todoist priorities to:",
"PRIORITY_NONE": "Nothing",
"PRIORITY_TAGS": "p1p3 tags (p4 stays untagged)",
"PRIORITY_EISENHOWER": "Eisenhower matrix — urgent / important tags",
"LOSS_HEADING": "What will not survive the move",
"SELECT_PROJECT": "Select at least one project to import."
},
"LOSS": {
"NESTED_PROJECTS": "Nested projects flattened into the project list: {{count}}.",
"SECTIONS": "Sections dropped (tasks keep their order in the project): {{count}}.",
"DEMOTED_SUBTASKS": "Deeply nested sub-tasks made direct sub-tasks (2 levels max): {{count}}.",
"RECURRING": "Recurring tasks keeping only their next date (the rule is noted in task notes): {{count}}.",
"DAY_DURATIONS": "Full-day durations not imported: {{count}}.",
"SUBTASK_LABELS": "Sub-task labels not imported (sub-tasks have no tags): {{count}}.",
"SUBTASK_PRIORITIES": "Prioritized sub-tasks not receiving priority tags: {{count}}.",
"ASSIGNEES": "Task collaborator assignments dropped: {{count}}.",
"ATTACHMENTS": "Comment attachment links kept without their files: {{count}}.",
"TRUNCATED_FIELDS": "Unusually long values shortened to safe import limits: {{count}}.",
"COMPLETED_AND_REMINDERS": "Completed tasks and reminders are not imported."
},
"IMPORT": {
"STARTING": "Starting…",
"PHASE_PROJECT": "creating project",
"PHASE_TASKS": "creating tasks",
"PHASE_DETAILS": "applying dates and tags {{current}}/{{total}}",
"PROGRESS": "Project {{current}} of {{total}}: {{title}} — {{detail}}"
},
"SUMMARY": {
"PROJECT_RESULT": "{{title}}: {{landedTasks}} of {{plannedTasks}} tasks, {{landedSubTasks}} of {{plannedSubTasks}} sub-tasks",
"SHORTFALL": "some items did not land; please review",
"UNVERIFIED": "Could not verify the imported counts — the numbers above may show 0 even for tasks that landed.",
"CREATED_TAGS": "Created tags: {{tags}}",
"NOT_CARRIED_OVER": "Not carried over",
"SNACK_FINISHED": "Todoist import finished",
"UNDO": "The import is additive — to undo it, delete the created projects."
},
"PARSE": {
"UNTITLED_PROJECT": "Untitled project",
"UNTITLED_TASK": "Untitled task",
"REPEATS": "Repeats: {{rule}}",
"DEADLINE": "Deadline: {{date}}",
"COMMENTS": "Comments:",
"FILE": "file"
}
}

View file

@ -0,0 +1,25 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
transform: {
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: {
module: 'commonjs',
target: 'es2020',
esModuleInterop: true,
},
},
],
},
testMatch: ['**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
roots: ['<rootDir>/src'],
moduleNameMapper: {
'^@super-productivity/plugin-api$':
'<rootDir>/node_modules/@super-productivity/plugin-api/src/index.ts',
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "todoist-import",
"version": "1.0.0",
"description": "One-time import of active Todoist projects, tasks, labels and due dates into Super Productivity",
"private": true,
"scripts": {
"build": "node scripts/build.js",
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^22.15.33",
"esbuild": "^0.28.1",
"jest": "^30.0.4",
"jest-environment-jsdom": "^30.0.4",
"ts-jest": "^29.4.0",
"typescript": "^5.8.3"
},
"dependencies": {
"@super-productivity/plugin-api": "file:../../plugin-api"
}
}

View file

@ -0,0 +1,54 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { build } = require('esbuild');
const ROOT_DIR = path.join(__dirname, '..');
const SRC_DIR = path.join(ROOT_DIR, 'src');
const DIST_DIR = path.join(ROOT_DIR, 'dist');
const buildPlugin = async () => {
if (fs.existsSync(DIST_DIR)) {
fs.rmSync(DIST_DIR, { recursive: true });
}
fs.mkdirSync(DIST_DIR);
// Bundle the UI and inline it: the host loads index.html via iframe srcdoc,
// so the document must be fully self-contained (no relative script URLs).
const result = await build({
entryPoints: [path.join(SRC_DIR, 'ui', 'main.ts')],
bundle: true,
write: false,
platform: 'browser',
target: 'es2020',
format: 'iife',
minify: true,
sourcemap: false,
logLevel: 'info',
});
const bundle = result.outputFiles[0].text;
const htmlTemplate = fs.readFileSync(path.join(SRC_DIR, 'ui', 'index.html'), 'utf8');
const marker = '<!-- BUILD:SCRIPT -->';
if (!htmlTemplate.includes(marker)) {
throw new Error(`index.html is missing the ${marker} marker`);
}
// function replacer: a literal replacement string would corrupt the bundle
// if the minified JS ever contains `$&`/`$'`-style replacement patterns
const html = htmlTemplate.replace(marker, () => `<script>\n${bundle}\n</script>`);
fs.writeFileSync(path.join(DIST_DIR, 'index.html'), html);
for (const file of ['manifest.json', 'plugin.js', 'icon.svg']) {
fs.copyFileSync(path.join(SRC_DIR, file), path.join(DIST_DIR, file));
}
fs.cpSync(path.join(ROOT_DIR, 'i18n'), path.join(DIST_DIR, 'i18n'), {
recursive: true,
});
console.log('todoist-import build complete → dist/');
};
buildPlugin().catch((err) => {
console.error('todoist-import build failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v10"/>
<path d="m8 9 4 4 4-4"/>
<path d="M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,29 @@
{
"id": "todoist-import",
"manifestVersion": 1,
"name": "Todoist Import",
"version": "1.0.0",
"minSupVersion": "18.0.0",
"description": "One-time import of your active Todoist projects, tasks, sub-tasks, labels and due dates. Additive — your existing Super Productivity data is never touched.",
"author": "SuperProductivity",
"homepage": "https://github.com/super-productivity/super-productivity",
"hooks": [],
"permissions": [
"http",
"getTasks",
"getAllProjects",
"addProject",
"getAllTags",
"addTag",
"batchUpdateForProject",
"updateTask",
"showSnack"
],
"allowedHosts": ["api.todoist.com"],
"iFrame": true,
"isSkipMenuEntry": true,
"i18n": {
"languages": ["en"]
},
"icon": "icon.svg"
}

View file

@ -0,0 +1,308 @@
import { BatchTaskCreate } from '@super-productivity/plugin-api';
import { TodoistImportModel, TodoistTask } from '../parse/normalized-model';
import { BATCH_CHUNK_SIZE, planImport } from './plan-import';
const task = (overrides: Partial<TodoistTask>): TodoistTask => ({
extId: 't1',
projectExtId: 'p1',
parentExtId: null,
title: 'task',
notes: '',
labels: [],
apiPriority: 1,
dueDay: null,
dueWithTime: null,
timeEstimate: null,
isRecurring: false,
wasDemoted: false,
isDayDurationSkipped: false,
hasAssignee: false,
attachmentCount: 0,
...overrides,
});
const model = (overrides: Partial<TodoistImportModel> = {}): TodoistImportModel => ({
projects: [
{ extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 },
],
sections: [],
tasks: [],
...overrides,
});
describe('planImport', () => {
it('creates temp- prefixed IDs and parent references', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'a' }),
task({ extId: 'b', parentExtId: 'a', title: 'sub' }),
],
}),
{ priorityMapping: 'none' },
);
const ops = plan.projects[0].batchChunks[0] as BatchTaskCreate[];
expect(ops.map((o) => [o.tempId, o.data.parentId])).toEqual([
['temp-a', undefined],
['temp-b', 'temp-a'],
]);
});
it('chunks operations at the batch limit, keeping parents before children', () => {
const tasks: TodoistTask[] = [];
for (let i = 0; i < 60; i++) {
tasks.push(task({ extId: `root-${i}`, title: `t${i}` }));
tasks.push(task({ extId: `sub-${i}`, parentExtId: `root-${i}`, title: `s${i}` }));
}
const plan = planImport(model({ tasks }), { priorityMapping: 'none' });
const chunks = plan.projects[0].batchChunks;
expect(chunks.length).toBe(Math.ceil(120 / BATCH_CHUNK_SIZE));
chunks.forEach((chunk) => expect(chunk.length).toBeLessThanOrEqual(BATCH_CHUNK_SIZE));
// a parent is always at a lower global index than its child
const flat = chunks.flat() as BatchTaskCreate[];
const indexByTempId = new Map(flat.map((op, i) => [op.tempId, i]));
for (const op of flat) {
if (op.data.parentId) {
expect(indexByTempId.get(op.data.parentId)).toBeLessThan(
indexByTempId.get(op.tempId) as number,
);
}
}
});
it('emits follow-ups only for tasks that need them, with due exclusivity', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'plain' }),
task({ extId: 'dated', dueDay: '2026-07-15' }),
task({ extId: 'timed', dueWithTime: 123456, dueDay: null }),
],
}),
{ priorityMapping: 'none' },
);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-dated', dueDay: '2026-07-15' },
{ tempId: 'temp-timed', dueWithTime: 123456 },
]);
});
it('collects label tags for root tasks but never for sub-tasks', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'a', labels: ['errand'] }),
task({ extId: 'b', parentExtId: 'a', labels: ['dropped'] }),
],
}),
{ priorityMapping: 'none' },
);
expect(plan.tagTitles).toEqual(['errand']);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-a', tagTitles: ['errand'] },
]);
});
describe('priority tags (opt-in)', () => {
it('maps API 4→p1, 3→p2, 2→p3 and never tags the default priority 1', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'highest', apiPriority: 4 }),
task({ extId: 'mid', apiPriority: 3 }),
task({ extId: 'low', apiPriority: 2 }),
task({ extId: 'default', apiPriority: 1 }),
],
}),
{ priorityMapping: 'priorityTags' },
);
expect(plan.tagTitles.sort()).toEqual(['p1', 'p2', 'p3']);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-highest', tagTitles: ['p1'] },
{ tempId: 'temp-mid', tagTitles: ['p2'] },
{ tempId: 'temp-low', tagTitles: ['p3'] },
]);
});
it('is off by default', () => {
const plan = planImport(model({ tasks: [task({ extId: 'a', apiPriority: 4 })] }), {
priorityMapping: 'none',
});
expect(plan.tagTitles).toEqual([]);
});
});
describe('eisenhower priority mapping (opt-in)', () => {
it('maps 4→urgent+important, 3→important, 2→urgent, leaving default 1 untagged', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'p1', apiPriority: 4 }),
task({ extId: 'p2', apiPriority: 3 }),
task({ extId: 'p3', apiPriority: 2 }),
task({ extId: 'p4', apiPriority: 1 }),
],
}),
{ priorityMapping: 'eisenhower' },
);
// reused by title → lands in SP's existing EM_URGENT / EM_IMPORTANT tags
expect(plan.tagTitles.sort()).toEqual(['important', 'urgent']);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-p1', tagTitles: ['urgent', 'important'] },
{ tempId: 'temp-p2', tagTitles: ['important'] },
{ tempId: 'temp-p3', tagTitles: ['urgent'] },
]);
});
it('merges the matrix tags after existing labels and never tags sub-tasks', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'root', apiPriority: 4, labels: ['errand'] }),
task({ extId: 'sub', parentExtId: 'root', apiPriority: 4, labels: ['x'] }),
],
}),
{ priorityMapping: 'eisenhower' },
);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-root', tagTitles: ['errand', 'urgent', 'important'] },
]);
});
it('does not duplicate matrix tags already present as Todoist labels', () => {
const plan = planImport(
model({
tasks: [
task({
extId: 'root',
apiPriority: 4,
labels: ['Urgent', 'important'],
}),
],
}),
{ priorityMapping: 'eisenhower' },
);
expect(plan.projects[0].followUps).toEqual([
{ tempId: 'temp-root', tagTitles: ['Urgent', 'important'] },
]);
});
});
describe('project selection and titles', () => {
it('only plans selected projects', () => {
const plan = planImport(
model({
projects: [
{ extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 },
{ extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 },
],
tasks: [task({ extId: 'a', projectExtId: 'p2' })],
}),
{ priorityMapping: 'none', selectedProjectExtIds: new Set(['p2']) },
);
expect(plan.projects.map((p) => p.extId)).toEqual(['p2']);
});
it('renames the inbox and disambiguates colliding nested titles', () => {
const plan = planImport(
model({
projects: [
{
extId: 'inbox',
title: 'Inbox',
parentExtId: null,
isInbox: true,
childOrder: 0,
},
{
extId: 'work',
title: 'Work',
parentExtId: null,
isInbox: false,
childOrder: 1,
},
{
extId: 'misc1',
title: 'Misc',
parentExtId: 'work',
isInbox: false,
childOrder: 2,
},
{
extId: 'home',
title: 'Home',
parentExtId: null,
isInbox: false,
childOrder: 3,
},
{
extId: 'misc2',
title: 'Misc',
parentExtId: 'home',
isInbox: false,
childOrder: 4,
},
],
}),
{ priorityMapping: 'none' },
);
expect(plan.projects.map((p) => p.title)).toEqual([
'Inbox (Todoist)',
'Work',
'Work / Misc',
'Home',
'Home / Misc',
]);
});
it('suffixes titles that still collide after prefixing', () => {
const plan = planImport(
model({
projects: [
{ extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 },
{ extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 },
],
}),
{ priorityMapping: 'none' },
);
expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (2)']);
});
it('never generates a suffix that collides with another project title', () => {
const plan = planImport(
model({
projects: [
{ extId: 'a', title: 'X', parentExtId: null, isInbox: false, childOrder: 1 },
{ extId: 'b', title: 'X', parentExtId: null, isInbox: false, childOrder: 2 },
{
extId: 'c',
title: 'X (2)',
parentExtId: null,
isInbox: false,
childOrder: 3,
},
],
}),
{ priorityMapping: 'none' },
);
expect(plan.projects.map((p) => p.title)).toEqual(['X', 'X (3)', 'X (2)']);
});
});
it('reports task and sub-task counts per project', () => {
const plan = planImport(
model({
tasks: [
task({ extId: 'a' }),
task({ extId: 'b', parentExtId: 'a' }),
task({ extId: 'c' }),
],
}),
{ priorityMapping: 'none' },
);
expect(plan.projects[0].taskCount).toBe(2);
expect(plan.projects[0].subTaskCount).toBe(1);
});
});

View file

@ -0,0 +1,232 @@
import { BatchOperation } from '@super-productivity/plugin-api';
import { TodoistImportModel, TodoistTask } from '../parse/normalized-model';
/**
* Batch chunk size. Must stay the host's MAX_BATCH_OPERATIONS_SIZE (50):
* the plugin chunks its own `batchUpdateForProject` calls and awaits each one,
* so every call is a single dispatched action in its own tick (sync rule #6);
* the bridge's internal chunking would fire all chunks in one tick.
*/
export const BATCH_CHUNK_SIZE = 50;
/** Temp IDs MUST be `temp-`-prefixed the batch reducer only resolves parent
* references with this prefix; anything else orphans (= deletes) sub-tasks. */
const tempId = (extId: string): string => `temp-${extId}`;
/** Todoist API priority (4 = highest) SP tag title. API 1 = the default on
* every task and is deliberately never tagged. */
const PRIORITY_TAG_BY_API_VALUE: Record<number, string> = {
4: 'p1',
3: 'p2',
2: 'p3',
};
/**
* Opt-in alternative to the p1p3 tags: map Todoist's single priority axis onto
* Super Productivity's built-in Eisenhower-matrix tags. The two tags are reused
* by title (`ensureTags` matches case-insensitively), so imported tasks land in
* the existing `EM_URGENT`/`EM_IMPORTANT` quadrants instead of spawning new
* tags. Collapsing one axis onto the 2-D matrix is inherently opinionated; this
* is the conventional split and only ever applies when the user explicitly
* chooses it. API 1 (default p4) stays untagged.
*/
const EISENHOWER_TAGS_BY_API_VALUE: Record<number, readonly string[]> = {
4: ['urgent', 'important'],
3: ['important'],
2: ['urgent'],
};
export interface TaskFollowUp {
tempId: string;
dueDay?: string;
dueWithTime?: number;
/** resolved to tag IDs at run time (existing tags are reused by title) */
tagTitles?: string[];
}
export interface ProjectImportPlan {
extId: string;
title: string;
taskCount: number;
subTaskCount: number;
batchChunks: BatchOperation[][];
followUps: TaskFollowUp[];
}
export interface ImportPlan {
projects: ProjectImportPlan[];
/** all tag titles the import needs (used labels + opt-in priority tags) */
tagTitles: string[];
}
/**
* How Todoist task priority is carried over mutually exclusive (one UI
* control): `none` leaves priority off, `priorityTags` adds the p1p3 tags,
* `eisenhower` adds SP's built-in urgent/important tags.
*/
export type PriorityMapping = 'none' | 'priorityTags' | 'eisenhower';
export interface PlanImportOptions {
priorityMapping: PriorityMapping;
/** omit to import everything */
selectedProjectExtIds?: ReadonlySet<string>;
}
export const groupTasksByProject = (
model: TodoistImportModel,
): Map<string, TodoistTask[]> => {
const byProject = new Map<string, TodoistTask[]>();
for (const t of model.tasks) {
const list = byProject.get(t.projectExtId) || [];
list.push(t);
byProject.set(t.projectExtId, list);
}
return byProject;
};
const taskTagTitles = (task: TodoistTask, priorityMapping: PriorityMapping): string[] => {
// SP sub-tasks cannot hold tags (host model) — the plugin must enforce this
if (task.parentExtId) {
return [];
}
const titles = [...task.labels];
if (priorityMapping === 'priorityTags') {
const priorityTag = PRIORITY_TAG_BY_API_VALUE[task.apiPriority];
if (priorityTag) {
titles.push(priorityTag);
}
} else if (priorityMapping === 'eisenhower') {
const emTags = EISENHOWER_TAGS_BY_API_VALUE[task.apiPriority];
if (emTags) {
titles.push(...emTags);
}
}
const seen = new Set<string>();
return titles.filter((title) => {
const key = title.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
/**
* Flattened project titles must stay unique enough to review: nested projects
* keep their plain name unless it collides, then `Parent / Child`; remaining
* duplicates get a numeric suffix. The Todoist Inbox becomes `Inbox (Todoist)`
* so it never shadows SP's own Inbox.
*
* Exported so the preview shows (and collision-checks) exactly the titles the
* import will create.
*/
export const buildProjectTitles = (model: TodoistImportModel): Map<string, string> => {
const byExtId = new Map(model.projects.map((p) => [p.extId, p]));
const preferredTitles = new Map<string, string>();
const counts = new Map<string, number>();
for (const p of model.projects) {
const base = p.isInbox ? 'Inbox (Todoist)' : p.title;
const key = base.toLowerCase();
counts.set(key, (counts.get(key) || 0) + 1);
preferredTitles.set(p.extId, base);
}
for (const p of model.projects) {
let title = preferredTitles.get(p.extId) as string;
if ((counts.get(title.toLowerCase()) || 0) > 1 && p.parentExtId) {
const parent = byExtId.get(p.parentExtId);
if (parent) {
title = `${parent.title} / ${title}`;
}
}
preferredTitles.set(p.extId, title);
}
const reserved = new Set(
[...preferredTitles.values()].map((title) => title.toLowerCase()),
);
const used = new Set<string>();
const titles = new Map<string, string>();
for (const p of model.projects) {
const base = preferredTitles.get(p.extId) as string;
let title = base;
let suffix = 2;
while (used.has(title.toLowerCase())) {
do {
title = `${base} (${suffix++})`;
} while (reserved.has(title.toLowerCase()) || used.has(title.toLowerCase()));
}
used.add(title.toLowerCase());
titles.set(p.extId, title);
}
return titles;
};
/**
* Normalized model executable plan. Pure; unit-tested. Operations are
* ordered parent-before-child (guaranteed by the model's task order), which
* keeps chunk boundaries safe.
*/
export const planImport = (
model: TodoistImportModel,
options: PlanImportOptions,
): ImportPlan => {
const titles = buildProjectTitles(model);
const tagTitles = new Set<string>();
const projects: ProjectImportPlan[] = [];
const tasksByProject = groupTasksByProject(model);
for (const project of model.projects) {
if (
options.selectedProjectExtIds &&
!options.selectedProjectExtIds.has(project.extId)
) {
continue;
}
const tasks = tasksByProject.get(project.extId) || [];
const operations: BatchOperation[] = tasks.map((t) => ({
type: 'create',
tempId: tempId(t.extId),
data: {
title: t.title,
notes: t.notes || undefined,
parentId: t.parentExtId ? tempId(t.parentExtId) : undefined,
timeEstimate: t.timeEstimate ?? undefined,
},
}));
const batchChunks: BatchOperation[][] = [];
for (let i = 0; i < operations.length; i += BATCH_CHUNK_SIZE) {
batchChunks.push(operations.slice(i, i + BATCH_CHUNK_SIZE));
}
const followUps: TaskFollowUp[] = [];
for (const t of tasks) {
const followUp: TaskFollowUp = { tempId: tempId(t.extId) };
if (t.dueDay) {
followUp.dueDay = t.dueDay;
} else if (t.dueWithTime) {
followUp.dueWithTime = t.dueWithTime;
}
const titlesForTask = taskTagTitles(t, options.priorityMapping);
if (titlesForTask.length) {
followUp.tagTitles = titlesForTask;
titlesForTask.forEach((title) => tagTitles.add(title));
}
if (followUp.dueDay || followUp.dueWithTime || followUp.tagTitles) {
followUps.push(followUp);
}
}
projects.push({
extId: project.extId,
title: titles.get(project.extId) as string,
taskCount: tasks.filter((t) => !t.parentExtId).length,
subTaskCount: tasks.filter((t) => !!t.parentExtId).length,
batchChunks,
followUps,
});
}
return { projects, tagTitles: [...tagTitles] };
};

View file

@ -0,0 +1,234 @@
import {
BatchTaskCreate,
BatchUpdateRequest,
Tag,
Task,
} from '@super-productivity/plugin-api';
import { TodoistImportModel, TodoistTask } from '../parse/normalized-model';
import { planImport } from './plan-import';
import { runImport } from './run-import';
const task = (overrides: Partial<TodoistTask>): TodoistTask => ({
extId: 't1',
projectExtId: 'p1',
parentExtId: null,
title: 'task',
notes: '',
labels: [],
apiPriority: 1,
dueDay: null,
dueWithTime: null,
timeEstimate: null,
isRecurring: false,
wasDemoted: false,
isDayDurationSkipped: false,
hasAssignee: false,
attachmentCount: 0,
...overrides,
});
const model = (tasks: TodoistTask[]): TodoistImportModel => ({
projects: [
{ extId: 'p1', title: 'Work', parentExtId: null, isInbox: false, childOrder: 1 },
],
sections: [],
tasks,
});
/** In-memory fake of the PluginAPI subset the executor uses. */
const createFakeApi = (
opts: {
existingTags?: Partial<Tag>[];
failNthBatch?: number;
failNthTag?: number;
failGetTasks?: boolean;
} = {},
): {
api: Parameters<typeof runImport>[0];
sentBatches: BatchUpdateRequest[];
updates: { taskId: string; changes: Partial<Task> }[];
createdTags: string[];
} => {
const sentBatches: BatchUpdateRequest[] = [];
const updates: { taskId: string; changes: Partial<Task> }[] = [];
const createdTags: string[] = [];
const createdTasks: Task[] = [];
let idCounter = 0;
let batchCounter = 0;
let tagCounter = 0;
const api: Parameters<typeof runImport>[0] = {
getAllTags: async () => (opts.existingTags || []) as Tag[],
addTag: async (tagData) => {
tagCounter++;
if (opts.failNthTag === tagCounter) {
throw new Error('tag failed');
}
createdTags.push(tagData.title as string);
return `tag-${createdTags.length}`;
},
addProject: async () => `project-${++idCounter}`,
batchUpdateForProject: async (request) => {
batchCounter++;
if (opts.failNthBatch === batchCounter) {
throw new Error('batch failed');
}
sentBatches.push(request);
const createdTaskIds: Record<string, string> = {};
for (const op of request.operations) {
if (op.type === 'create') {
const realId = `real-${op.tempId}`;
createdTaskIds[op.tempId] = realId;
const parentId = op.data.parentId || null;
createdTasks.push({
id: realId,
title: op.data.title,
projectId: request.projectId,
// mirror the host: an unresolved temp- parent would orphan-delete
parentId: parentId && parentId.startsWith('temp-') ? undefined : parentId,
tagIds: [],
subTaskIds: [],
timeEstimate: 0,
timeSpent: 0,
isDone: false,
created: 1,
} as Task);
}
}
return { success: true, createdTaskIds };
},
updateTask: async (taskId, changes) => {
updates.push({ taskId, changes });
},
getTasks: async () => {
if (opts.failGetTasks) {
throw new Error('getTasks failed');
}
return createdTasks;
},
};
return { api, sentBatches, updates, createdTags };
};
describe('runImport', () => {
it('rewrites temp- parent refs to real IDs when a family straddles a chunk boundary', async () => {
// 49 filler roots + 1 root at index 49 whose 3 subtasks land in chunk 2
const tasks: TodoistTask[] = [];
for (let i = 0; i < 49; i++) {
tasks.push(task({ extId: `filler-${i}` }));
}
tasks.push(task({ extId: 'family-root' }));
for (let i = 0; i < 3; i++) {
tasks.push(task({ extId: `family-sub-${i}`, parentExtId: 'family-root' }));
}
const plan = planImport(model(tasks), { priorityMapping: 'none' });
expect(plan.projects[0].batchChunks.length).toBe(2);
const { api, sentBatches } = createFakeApi();
const result = await runImport(api, plan, () => {});
// the second SENT chunk must reference the parent's REAL id, not temp-
const secondChunkCreates = sentBatches[1].operations as BatchTaskCreate[];
for (const op of secondChunkCreates) {
expect(op.data.parentId).toBe('real-temp-family-root');
}
// and nothing was lost: 53 planned, 53 landed
expect(result.imported[0].landedTaskCount).toBe(50);
expect(result.imported[0].landedSubTaskCount).toBe(3);
expect(result.errorMessage).toBeNull();
});
it('keeps temp- parent refs within the same chunk (bridge resolves those)', async () => {
const plan = planImport(
model([task({ extId: 'a' }), task({ extId: 'b', parentExtId: 'a' })]),
{ priorityMapping: 'none' },
);
const { api, sentBatches } = createFakeApi();
await runImport(api, plan, () => {});
const ops = sentBatches[0].operations as BatchTaskCreate[];
expect(ops[1].data.parentId).toBe('temp-a');
});
it('never maps a label onto the virtual TODAY tag', async () => {
const plan = planImport(model([task({ extId: 'a', labels: ['Today'] })]), {
priorityMapping: 'none',
});
const { api, updates, createdTags } = createFakeApi({
existingTags: [{ id: 'TODAY', title: 'Today' }],
});
await runImport(api, plan, () => {});
expect(createdTags).toEqual(['Today']);
expect(updates[0].changes.tagIds).toEqual(['tag-1']);
});
it('reuses existing tags by title, case-insensitively', async () => {
const plan = planImport(model([task({ extId: 'a', labels: ['Errand'] })]), {
priorityMapping: 'none',
});
const { api, updates, createdTags } = createFakeApi({
existingTags: [{ id: 'tag-existing', title: 'errand' }],
});
await runImport(api, plan, () => {});
expect(createdTags).toEqual([]);
expect(updates[0].changes.tagIds).toEqual(['tag-existing']);
});
it('reports tags created before a later tag creation fails', async () => {
const plan = planImport(model([task({ extId: 'a', labels: ['first', 'second'] })]), {
priorityMapping: 'none',
});
const { api } = createFakeApi({ failNthTag: 2 });
const result = await runImport(api, plan, () => {});
expect(result.createdTagTitles).toEqual(['first']);
expect(result.errorMessage).toBe('tag failed');
});
it('records the failed project as partial and keeps earlier projects', async () => {
const m: TodoistImportModel = {
projects: [
{ extId: 'p1', title: 'A', parentExtId: null, isInbox: false, childOrder: 1 },
{ extId: 'p2', title: 'B', parentExtId: null, isInbox: false, childOrder: 2 },
],
sections: [],
tasks: [
task({ extId: 'a', projectExtId: 'p1' }),
task({ extId: 'b', projectExtId: 'p2' }),
],
};
const plan = planImport(m, { priorityMapping: 'none' });
const { api } = createFakeApi({ failNthBatch: 2 });
const result = await runImport(api, plan, () => {});
expect(result.imported.map((p) => p.title)).toEqual(['A']);
expect(result.failedProjectTitle).toBe('B');
expect(result.errorMessage).toBe('batch failed');
});
it('does not let a failed recount mask a successful import', async () => {
const plan = planImport(model([task({ extId: 'a' })]), {
priorityMapping: 'none',
});
const { api } = createFakeApi({ failGetTasks: true });
const result = await runImport(api, plan, () => {});
expect(result.errorMessage).toBeNull();
expect(result.isCountUnverified).toBe(true);
expect(result.imported.length).toBe(1);
});
it('reports detail progress during follow-ups', async () => {
const tasks: TodoistTask[] = [];
for (let i = 0; i < 30; i++) {
tasks.push(task({ extId: `t-${i}`, dueDay: '2026-07-15' }));
}
const plan = planImport(model(tasks), { priorityMapping: 'none' });
const { api } = createFakeApi();
const detailReports: (number | undefined)[] = [];
await runImport(api, plan, (p) => {
if (p.phase === 'details') {
detailReports.push(p.detailIndex);
}
});
expect(detailReports).toEqual([0, 25]);
});
});

View file

@ -0,0 +1,225 @@
import { BatchOperation, PluginAPI, Task } from '@super-productivity/plugin-api';
import { ImportPlan, ProjectImportPlan } from './plan-import';
/** SP's virtual Today tag — must never land in task.tagIds (sync rule #5). */
const TODAY_TAG_ID = 'TODAY';
/** Report follow-up progress in steps of this many tasks. */
const DETAIL_PROGRESS_STEP = 25;
export interface ImportProgress {
projectTitle: string;
projectIndex: number;
totalProjects: number;
phase: 'project' | 'tasks' | 'details';
/** follow-up progress within the 'details' phase */
detailIndex?: number;
detailTotal?: number;
}
export interface ImportedProjectResult {
title: string;
projectId: string;
/** planned counts, for an honest landed-vs-planned comparison */
plannedTaskCount: number;
plannedSubTaskCount: number;
/** counted from re-read state — the batch API is fire-and-forget */
landedTaskCount: number;
landedSubTaskCount: number;
}
export interface ImportResult {
imported: ImportedProjectResult[];
createdTagTitles: string[];
/** set when the import aborted mid-way; that project exists PARTIALLY
* the user should delete it before re-running */
failedProjectTitle: string | null;
errorMessage: string | null;
/** the post-import recount failed; landed counts are unknown, not zero */
isCountUnverified: boolean;
}
type ImportApi = Pick<
PluginAPI,
| 'getAllTags'
| 'addTag'
| 'addProject'
| 'batchUpdateForProject'
| 'updateTask'
| 'getTasks'
>;
const ensureTags = async (
api: ImportApi,
tagTitles: string[],
createdTagTitles: string[],
): Promise<Map<string, string>> => {
const tagIdByTitle = new Map<string, string>();
const existing = await api.getAllTags();
for (const tag of existing) {
// never map a label onto the virtual Today tag — a Todoist label named
// "Today" gets a real tag of its own instead
if (tag.id === TODAY_TAG_ID) {
continue;
}
tagIdByTitle.set(tag.title.toLowerCase(), tag.id);
}
for (const title of tagTitles) {
const key = title.toLowerCase();
if (!tagIdByTitle.has(key)) {
tagIdByTitle.set(key, await api.addTag({ title }));
createdTagTitles.push(title);
}
}
return tagIdByTitle;
};
/**
* The bridge builds its temp-ID map PER CALL a chunk sent in a later call
* cannot resolve a `temp-` parent created by an earlier call (the reducer
* would store the dangling ref and the consistency pass would orphan-DELETE
* the child). Real task IDs are supported in `parentId`, so rewrite every
* already-known temp parent to its real ID before sending.
*/
const resolveKnownParents = (
chunk: BatchOperation[],
idByTempId: Record<string, string>,
): BatchOperation[] =>
chunk.map((op) =>
op.type === 'create' && op.data.parentId && idByTempId[op.data.parentId]
? { ...op, data: { ...op.data, parentId: idByTempId[op.data.parentId] } }
: op,
);
const importProject = async (
api: ImportApi,
projectPlan: ProjectImportPlan,
tagIdByTitle: Map<string, string>,
onProgress: (
detail: Partial<ImportProgress> & { phase: ImportProgress['phase'] },
) => void,
): Promise<string> => {
onProgress({ phase: 'project' });
const projectId = await api.addProject({ title: projectPlan.title });
onProgress({ phase: 'tasks' });
const idByTempId: Record<string, string> = {};
for (const chunk of projectPlan.batchChunks) {
// sequential awaits: one dispatched action per tick (sync rule #6)
const result = await api.batchUpdateForProject({
projectId,
operations: resolveKnownParents(chunk, idByTempId),
});
Object.assign(idByTempId, result.createdTaskIds);
}
const followUps = projectPlan.followUps;
for (let i = 0; i < followUps.length; i++) {
if (i % DETAIL_PROGRESS_STEP === 0) {
onProgress({ phase: 'details', detailIndex: i, detailTotal: followUps.length });
}
const followUp = followUps[i];
const taskId = idByTempId[followUp.tempId];
if (!taskId) {
continue;
}
const updates: Partial<Task> = {};
if (followUp.dueDay) {
updates.dueDay = followUp.dueDay;
} else if (followUp.dueWithTime) {
updates.dueWithTime = followUp.dueWithTime;
}
if (followUp.tagTitles?.length) {
const tagIds = followUp.tagTitles
.map((t) => tagIdByTitle.get(t.toLowerCase()))
.filter((id): id is string => !!id);
if (tagIds.length) {
updates.tagIds = tagIds;
}
}
if (Object.keys(updates).length) {
await api.updateTask(taskId, updates);
}
}
return projectId;
};
const countLanded = async (api: ImportApi, result: ImportResult): Promise<void> => {
const allTasks = await api.getTasks();
const rootsByProject = new Map<string, number>();
const subsByProject = new Map<string, number>();
for (const task of allTasks) {
if (!task.projectId) {
continue;
}
const target = task.parentId ? subsByProject : rootsByProject;
target.set(task.projectId, (target.get(task.projectId) || 0) + 1);
}
for (const imported of result.imported) {
imported.landedTaskCount = rootsByProject.get(imported.projectId) || 0;
imported.landedSubTaskCount = subsByProject.get(imported.projectId) || 0;
}
};
/**
* Executes the plan project-by-project so an abort leaves at most one partial
* project (named in the result), and counts what actually landed by re-reading
* state (`batchUpdateForProject` always reports success and silently skips
* invalid operations).
*/
export const runImport = async (
api: ImportApi,
plan: ImportPlan,
onProgress: (progress: ImportProgress) => void,
): Promise<ImportResult> => {
const result: ImportResult = {
imported: [],
createdTagTitles: [],
failedProjectTitle: null,
errorMessage: null,
isCountUnverified: false,
};
try {
const tagIdByTitle = await ensureTags(api, plan.tagTitles, result.createdTagTitles);
for (let i = 0; i < plan.projects.length; i++) {
const projectPlan = plan.projects[i];
const report: Parameters<typeof importProject>[3] = (detail) =>
onProgress({
projectTitle: projectPlan.title,
projectIndex: i,
totalProjects: plan.projects.length,
...detail,
});
try {
const projectId = await importProject(api, projectPlan, tagIdByTitle, report);
result.imported.push({
title: projectPlan.title,
projectId,
plannedTaskCount: projectPlan.taskCount,
plannedSubTaskCount: projectPlan.subTaskCount,
landedTaskCount: 0,
landedSubTaskCount: 0,
});
} catch (e) {
result.failedProjectTitle = projectPlan.title;
result.errorMessage = e instanceof Error ? e.message : String(e);
break;
}
}
} catch (e) {
result.errorMessage = e instanceof Error ? e.message : String(e);
}
if (result.imported.length) {
// a failed recount must not mask a successful import (or the real error)
try {
await countLanded(api, result);
} catch {
result.isCountUnverified = true;
}
}
return result;
};

View file

@ -0,0 +1,463 @@
import { mergeSyncResponses, parseSyncResponse, RawSyncResponse } from './from-api';
const baseFixture = (): RawSyncResponse => ({
projects: [
{ id: 'p1', name: 'Work', child_order: 1 },
{ id: 'p2', name: 'Home', child_order: 2 },
],
items: [],
sections: [],
notes: [],
});
describe('mergeSyncResponses', () => {
it('applies incremental additions, replacements, and tombstones to a full sync', () => {
const full: RawSyncResponse = {
sync_token: 'full-token',
projects: [
{ id: 'p1', name: 'Old name' },
{ id: 'p2', name: 'Deleted later' },
{ id: 'p4', name: 'Unchanged project' },
],
items: [{ id: 't1', project_id: 'p1', content: 'Old task title' }],
sections: [],
notes: [],
};
const incremental: RawSyncResponse = {
sync_token: 'incremental-token',
projects: [
{ id: 'p1', name: 'New name' },
{ id: 'p2', is_deleted: true },
{ id: 'p3', name: 'New project' },
],
items: [{ id: 't1', project_id: 'p1', content: 'New task title' }],
};
const merged = mergeSyncResponses(full, incremental);
expect(merged.sync_token).toBe('incremental-token');
expect(merged.projects).toEqual([
...incremental.projects!,
{ id: 'p4', name: 'Unchanged project' },
]);
expect(merged.items).toEqual(incremental.items);
expect(parseSyncResponse(merged).projects.map((project) => project.extId)).toEqual([
'p1',
'p3',
'p4',
]);
expect(parseSyncResponse(merged).tasks[0].title).toBe('New task title');
});
});
describe('parseSyncResponse', () => {
it('skips archived and deleted projects and their items', () => {
const raw = baseFixture();
raw.projects!.push(
{ id: 'p3', name: 'Old', is_archived: true },
{ id: 'p4', name: 'Gone', is_deleted: 1 },
);
raw.items = [
{ id: 't1', project_id: 'p3', content: 'in archived' },
{ id: 't2', project_id: 'p4', content: 'in deleted' },
{ id: 't3', project_id: 'p1', content: 'kept' },
];
const model = parseSyncResponse(raw);
expect(model.projects.map((p) => p.extId)).toEqual(['p1', 'p2']);
expect(model.tasks.map((t) => t.extId)).toEqual(['t3']);
});
it('skips completed and deleted items', () => {
const raw = baseFixture();
raw.items = [
{ id: 't1', project_id: 'p1', content: 'done', checked: true },
{ id: 't2', project_id: 'p1', content: 'deleted', is_deleted: true },
{ id: 't3', project_id: 'p1', content: 'open' },
];
const model = parseSyncResponse(raw);
expect(model.tasks.map((t) => t.extId)).toEqual(['t3']);
});
it('marks the inbox project', () => {
const raw = baseFixture();
raw.projects!.unshift({
id: 'p0',
name: 'Inbox',
inbox_project: true,
child_order: 0,
});
const model = parseSyncResponse(raw);
expect(model.projects[0]).toEqual(
expect.objectContaining({ extId: 'p0', isInbox: true }),
);
});
describe('due dates', () => {
it('parses all-day dates as dueDay', () => {
const raw = baseFixture();
raw.items = [
{ id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-07-15' } },
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.dueDay).toBe('2026-07-15');
expect(task.dueWithTime).toBeNull();
});
it('parses floating datetimes as local time', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
due: { date: '2026-07-15T09:30:00' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.dueDay).toBeNull();
expect(task.dueWithTime).toBe(new Date(2026, 6, 15, 9, 30, 0).getTime());
});
it('parses fixed-timezone datetimes (trailing Z) as UTC instants', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
due: { date: '2026-07-15T09:30:00Z', timezone: 'Europe/Berlin' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.dueWithTime).toBe(Date.UTC(2026, 6, 15, 9, 30, 0));
});
it('uses the deadline as dueDay when there is no due date', () => {
const raw = baseFixture();
raw.items = [
{ id: 't1', project_id: 'p1', content: 'a', deadline: { date: '2026-08-01' } },
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.dueDay).toBe('2026-08-01');
expect(task.notes).not.toContain('Deadline:');
});
it('keeps the deadline as a notes line when a due date exists', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
due: { date: '2026-07-15' },
deadline: { date: '2026-08-01' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.dueDay).toBe('2026-07-15');
expect(task.notes).toContain('Deadline: 2026-08-01');
});
});
describe('recurring', () => {
it('flags recurring tasks and appends the verbatim rule to notes', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'water plants',
description: 'living room only',
due: { date: '2026-07-15', is_recurring: true, string: 'every! 3 days' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.isRecurring).toBe(true);
expect(task.notes).toBe('living room only\n\nRepeats: every! 3 days');
});
});
describe('durations', () => {
it('converts minute durations to a ms time estimate', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
duration: { amount: 45, unit: 'minute' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.timeEstimate).toBe(45 * 60_000);
expect(task.isDayDurationSkipped).toBe(false);
});
it('skips day durations and flags them', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
duration: { amount: 1, unit: 'day' },
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.timeEstimate).toBeNull();
expect(task.isDayDurationSkipped).toBe(true);
});
});
describe('nesting', () => {
it('keeps level-1 sub-tasks under their parent', () => {
const raw = baseFixture();
raw.items = [
{ id: 'root', project_id: 'p1', content: 'root', child_order: 1 },
{ id: 'sub', project_id: 'p1', content: 'sub', parent_id: 'root' },
];
const model = parseSyncResponse(raw);
expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([
['root', null, false],
['sub', 'root', false],
]);
});
it('re-parents depth ≥ 2 to the root ancestor in DFS order and flags demotion', () => {
const raw = baseFixture();
raw.items = [
{ id: 'a', project_id: 'p1', content: 'a', child_order: 1 },
{ id: 'b', project_id: 'p1', content: 'b', parent_id: 'a', child_order: 1 },
{ id: 'c', project_id: 'p1', content: 'c', parent_id: 'b', child_order: 1 },
{ id: 'd', project_id: 'p1', content: 'd', parent_id: 'c', child_order: 1 },
{ id: 'b2', project_id: 'p1', content: 'b2', parent_id: 'a', child_order: 2 },
];
const model = parseSyncResponse(raw);
expect(model.tasks.map((t) => [t.extId, t.parentExtId, t.wasDemoted])).toEqual([
['a', null, false],
['b', 'a', false],
['c', 'a', true],
['d', 'a', true],
['b2', 'a', false],
]);
});
it('treats items with a missing parent as roots', () => {
const raw = baseFixture();
raw.items = [
{ id: 'orphan', project_id: 'p1', content: 'x', parent_id: 'completed-parent' },
];
const model = parseSyncResponse(raw);
expect(model.tasks[0].parentExtId).toBeNull();
});
});
describe('ordering', () => {
it('orders roots by section order then child order; section-less first', () => {
const raw = baseFixture();
raw.sections = [
{ id: 's2', project_id: 'p1', name: 'Later', section_order: 2 },
{ id: 's1', project_id: 'p1', name: 'Now', section_order: 1 },
];
raw.items = [
{ id: 'in-s2', project_id: 'p1', section_id: 's2', content: 'x', child_order: 1 },
{ id: 'no-sec-2', project_id: 'p1', content: 'x', child_order: 2 },
{ id: 'in-s1', project_id: 'p1', section_id: 's1', content: 'x', child_order: 1 },
{ id: 'no-sec-1', project_id: 'p1', content: 'x', child_order: 1 },
];
const model = parseSyncResponse(raw);
expect(model.tasks.map((t) => t.extId)).toEqual([
'no-sec-1',
'no-sec-2',
'in-s1',
'in-s2',
]);
});
it('groups tasks by project in project order', () => {
const raw = baseFixture();
raw.items = [
{ id: 'h1', project_id: 'p2', content: 'x', child_order: 1 },
{ id: 'w1', project_id: 'p1', content: 'x', child_order: 1 },
];
const model = parseSyncResponse(raw);
expect(model.tasks.map((t) => t.extId)).toEqual(['w1', 'h1']);
});
});
describe('comments', () => {
it('appends comments to notes and counts attachments', () => {
const raw = baseFixture();
raw.items = [{ id: 't1', project_id: 'p1', content: 'a', description: 'desc' }];
raw.notes = [
{ id: 'n1', item_id: 't1', content: 'first comment' },
{
id: 'n2',
item_id: 't1',
content: 'see file',
file_attachment: { file_name: 'report.pdf', file_url: 'https://x/report.pdf' },
},
{ id: 'n3', item_id: 't1', content: 'deleted', is_deleted: true },
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.notes).toBe(
'desc\n\nComments:\n- first comment\n- see file report.pdf: https://x/report.pdf',
);
expect(task.attachmentCount).toBe(1);
});
it('uses caller-provided strings for text added to imported notes', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: '',
due: { date: '2026-07-15', is_recurring: true, string: 'every day' },
deadline: { date: '2026-08-01' },
},
];
raw.notes = [
{
id: 'n1',
item_id: 't1',
content: 'attachment',
file_attachment: { file_url: 'https://x/file' },
},
];
const [taskParsed] = parseSyncResponse(raw, {
untitledProject: 'Projekt ohne Titel',
untitledTask: 'Aufgabe ohne Titel',
repeats: (rule) => `Wiederholt: ${rule}`,
deadline: (date) => `Frist: ${date}`,
comments: 'Kommentare:',
file: 'Datei',
}).tasks;
expect(taskParsed.title).toBe('Aufgabe ohne Titel');
expect(taskParsed.notes).toBe(
'Wiederholt: every day\nFrist: 2026-08-01\n\nKommentare:\n- attachment Datei: https://x/file',
);
});
});
describe('hostile / malformed payloads', () => {
it('rejects shape-valid but impossible calendar dates', () => {
const raw = baseFixture();
raw.items = [
{ id: 't1', project_id: 'p1', content: 'a', due: { date: '2026-99-99' } },
{ id: 't2', project_id: 'p1', content: 'b', deadline: { date: '0000-00-00' } },
];
const [t1, t2] = parseSyncResponse(raw).tasks;
expect(t1.dueDay).toBeNull();
expect(t2.dueDay).toBeNull();
});
it('treats a parent in another project as missing (child becomes root)', () => {
const raw = baseFixture();
raw.items = [
{ id: 'other', project_id: 'p2', content: 'x' },
{ id: 'child', project_id: 'p1', content: 'y', parent_id: 'other' },
];
const child = parseSyncResponse(raw).tasks.find((t) => t.extId === 'child');
expect(child?.parentExtId).toBeNull();
});
it('drops unsafe or malformed attachment URLs from notes', () => {
const raw = baseFixture();
raw.items = [{ id: 't1', project_id: 'p1', content: 'a' }];
raw.notes = [
{
id: 'n1',
item_id: 't1',
content: 'evil',
file_attachment: { file_name: 'x', file_url: 'javascript:alert(1)' },
},
{
id: 'n2',
item_id: 't1',
content: 'malformed',
file_attachment: {
file_name: 'x',
file_url: 'https://files.example/x\n[bad](javascript:alert(1))',
},
},
];
const [taskParsed] = parseSyncResponse(raw).tasks;
expect(taskParsed.notes).toBe('Comments:\n- evil\n- malformed');
expect(taskParsed.attachmentCount).toBe(0);
});
it('rejects non-finite or absurd durations', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
duration: { amount: Infinity, unit: 'minute' },
},
{
id: 't2',
project_id: 'p1',
content: 'b',
duration: { amount: 9_999_999, unit: 'minute' },
},
];
const [t1, t2] = parseSyncResponse(raw).tasks;
expect(t1.timeEstimate).toBeNull();
expect(t2.timeEstimate).toBeNull();
});
it('clamps oversized titles and notes', () => {
const raw = baseFixture();
raw.projects![0].name = 'p'.repeat(5000);
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'x'.repeat(5000),
description: 'y'.repeat(100_000),
},
];
const parsed = parseSyncResponse(raw);
const [taskParsed] = parsed.tasks;
expect(parsed.projects[0].truncatedFieldCount).toBe(1);
expect(taskParsed.title.length).toBeLessThanOrEqual(1001);
expect(taskParsed.notes.length).toBeLessThanOrEqual(50_001);
expect(taskParsed.truncatedFieldCount).toBe(2);
});
});
it('orders nested projects parent-first (children directly after their parent)', () => {
const raw = baseFixture();
// child_order is per-parent in Todoist: a flat sort would interleave
raw.projects = [
{ id: 'a', name: 'A', child_order: 1 },
{ id: 'b', name: 'B', child_order: 2 },
{ id: 'a1', name: 'A1', parent_id: 'a', child_order: 1 },
{ id: 'b1', name: 'B1', parent_id: 'b', child_order: 1 },
];
const model = parseSyncResponse(raw);
expect(model.projects.map((p) => p.extId)).toEqual(['a', 'a1', 'b', 'b1']);
});
it('captures labels and assignee flag', () => {
const raw = baseFixture();
raw.items = [
{
id: 't1',
project_id: 'p1',
content: 'a',
labels: ['errand', 'urgent'],
responsible_uid: 'user-2',
priority: 4,
},
];
const [task] = parseSyncResponse(raw).tasks;
expect(task.labels).toEqual(['errand', 'urgent']);
expect(task.hasAssignee).toBe(true);
expect(task.apiPriority).toBe(4);
});
});

View file

@ -0,0 +1,482 @@
import {
TodoistImportModel,
TodoistProject,
TodoistSection,
TodoistTask,
} from './normalized-model';
/**
* Raw shapes from `POST https://api.todoist.com/api/v1/sync` (unified v1).
* Only the fields we read; everything is optional so unknown/missing fields
* from API drift degrade gracefully instead of crashing the import.
*/
export interface RawSyncResponse {
sync_token?: string;
full_sync?: boolean;
full_sync_date_utc?: string;
projects?: RawProject[];
items?: RawItem[];
sections?: RawSection[];
notes?: RawNote[];
}
interface RawProject {
id?: string | number;
name?: string;
parent_id?: string | number | null;
child_order?: number;
inbox_project?: boolean;
is_archived?: boolean | number;
is_deleted?: boolean | number;
}
interface RawSection {
id?: string | number;
project_id?: string | number;
name?: string;
section_order?: number;
is_deleted?: boolean | number;
is_archived?: boolean | number;
}
interface RawDue {
/** YYYY-MM-DD | YYYY-MM-DDTHH:MM:SS (floating/local) | …Z (fixed, UTC instant) */
date?: string;
timezone?: string | null;
is_recurring?: boolean;
string?: string;
}
interface RawItem {
id?: string | number;
project_id?: string | number;
section_id?: string | number | null;
parent_id?: string | number | null;
content?: string;
description?: string;
/** 4 = UI p1 (highest) … 1 = default */
priority?: number;
labels?: string[];
due?: RawDue | null;
deadline?: { date?: string } | null;
duration?: { amount?: number; unit?: string } | null;
checked?: boolean | number;
is_deleted?: boolean | number;
child_order?: number;
responsible_uid?: string | number | null;
}
interface RawNote {
id?: string | number;
item_id?: string | number;
content?: string;
is_deleted?: boolean | number;
file_attachment?: { file_name?: string; file_url?: string } | null;
}
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
// Everything below lands in synced state that every client must replay —
// clamp remote-controlled sizes and reject nonsense dates instead of
// trusting the payload (shared-project collaborators control much of it).
const MAX_TITLE_LEN = 1000;
const MAX_LABEL_LEN = 200;
const MAX_NOTES_LEN = 50_000;
const MIN_DUE_MS = 0; // 1970
const MAX_DUE_MS = 32_503_680_000_000; // year 3000
export interface ParseStrings {
untitledProject: string;
untitledTask: string;
repeats: (rule: string) => string;
deadline: (date: string) => string;
comments: string;
file: string;
}
const DEFAULT_PARSE_STRINGS: ParseStrings = {
untitledProject: 'Untitled project',
untitledTask: 'Untitled task',
repeats: (rule) => `Repeats: ${rule}`,
deadline: (date) => `Deadline: ${date}`,
comments: 'Comments:',
file: 'file',
};
const asId = (v: string | number | null | undefined): string | null =>
v === null || v === undefined || v === '' ? null : String(v);
const mergeResources = <T extends { id?: string | number }>(
full: T[] | undefined,
incremental: T[] | undefined,
): T[] | undefined => {
if (!incremental?.length) {
return full;
}
const changedIds = new Set(
incremental.map((resource) => asId(resource.id)).filter((id): id is string => !!id),
);
return [
...incremental,
...(full || []).filter((resource) => {
const id = asId(resource.id);
return !id || !changedIds.has(id);
}),
];
};
/**
* Apply the delta returned by a Todoist incremental sync to its initial full
* snapshot. Incremental tombstones intentionally replace the old resource so
* the normal parser filters deletions instead of resurrecting stale data.
*/
export const mergeSyncResponses = (
full: RawSyncResponse,
incremental: RawSyncResponse,
): RawSyncResponse => ({
...full,
...incremental,
projects: mergeResources(full.projects, incremental.projects),
items: mergeResources(full.items, incremental.items),
sections: mergeResources(full.sections, incremental.sections),
notes: mergeResources(full.notes, incremental.notes),
});
const asStr = (v: unknown): string => (typeof v === 'string' ? v : '');
const isSupportedAttachmentUrl = (url: unknown): url is string => {
if (typeof url !== 'string' || /[\s\u0000-\u001f\u007f]/u.test(url)) {
return false;
}
try {
const protocol = new URL(url).protocol;
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
};
const isTruthyFlag = (v: boolean | number | undefined): boolean => !!v;
const clamp = (s: string, maxLen: number): string =>
s.length > maxLen ? `${s.slice(0, maxLen)}` : s;
/** Shape AND calendar validity (rejects e.g. 2026-99-99). */
const isValidDayStr = (s: unknown): s is string => {
if (typeof s !== 'string' || !DATE_ONLY_RE.test(s)) {
return false;
}
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
};
/**
* `new Date('YYYY-MM-DDTHH:MM:SS')` is local time per spec; a trailing `Z`
* makes it a UTC instant exactly Todoist's floating vs fixed semantics.
*/
const parseDue = (
due: RawDue | null | undefined,
): { dueDay: string | null; dueWithTime: number | null } => {
const date = due?.date;
if (!date || typeof date !== 'string') {
return { dueDay: null, dueWithTime: null };
}
if (DATE_ONLY_RE.test(date)) {
return isValidDayStr(date)
? { dueDay: date, dueWithTime: null }
: { dueDay: null, dueWithTime: null };
}
const ts = new Date(date).getTime();
return Number.isNaN(ts) || ts < MIN_DUE_MS || ts > MAX_DUE_MS
? { dueDay: null, dueWithTime: null }
: { dueDay: null, dueWithTime: ts };
};
const buildNotes = (
item: RawItem,
comments: RawNote[],
deadlineNoted: string | null,
strings: ParseStrings,
): { notes: string; truncatedFieldCount: number } => {
const parts: string[] = [];
const description = asStr(item.description).trim();
if (description) {
parts.push(description);
}
const extras: string[] = [];
if (item.due?.is_recurring && asStr(item.due.string)) {
extras.push(strings.repeats(asStr(item.due.string)));
}
if (deadlineNoted) {
extras.push(strings.deadline(deadlineNoted));
}
if (extras.length) {
parts.push(extras.join('\n'));
}
if (comments.length) {
const lines = comments.map((c) => {
const content = asStr(c.content).trim();
const att = c.file_attachment;
// scheme-filter remote URLs before they land in markdown-rendered notes
const url = asStr(att?.file_url);
const attLine = isSupportedAttachmentUrl(url)
? ` ${asStr(att?.file_name) || strings.file}: ${url}`
: '';
return `- ${content}${attLine}`.trimEnd();
});
parts.push(`${strings.comments}\n${lines.join('\n')}`);
}
const notes = parts.join('\n\n');
return {
notes: clamp(notes, MAX_NOTES_LEN),
truncatedFieldCount: notes.length > MAX_NOTES_LEN ? 1 : 0,
};
};
/**
* Sync payload normalized model. Pure; safe to unit-test with fixtures.
*
* - completed (`checked`) and deleted items are skipped, as are archived /
* deleted projects (and their items),
* - the item tree is flattened to SP's 2 levels: any task deeper than one
* level is re-parented to its root ancestor in DFS (reading) order,
* - items whose parent is missing (completed/deleted) are treated as roots.
*/
export const parseSyncResponse = (
raw: RawSyncResponse,
strings: ParseStrings = DEFAULT_PARSE_STRINGS,
): TodoistImportModel => {
const projects: TodoistProject[] = [];
const keptProjectIds = new Set<string>();
for (const p of raw.projects || []) {
const extId = asId(p.id);
if (!extId || isTruthyFlag(p.is_archived) || isTruthyFlag(p.is_deleted)) {
continue;
}
const title = asStr(p.name).trim();
keptProjectIds.add(extId);
projects.push({
extId,
title: clamp(title, MAX_TITLE_LEN) || strings.untitledProject,
parentExtId: asId(p.parent_id),
isInbox: !!p.inbox_project,
childOrder: p.child_order ?? 0,
truncatedFieldCount: title.length > MAX_TITLE_LEN ? 1 : 0,
});
}
// parent-first DFS: `child_order` is per-parent in Todoist, so a flat sort
// would interleave unrelated siblings; this keeps children next to their
// (flattened-away) parent in the SP sidebar
const orderedProjects: TodoistProject[] = [];
const projectChildren = new Map<string | null, TodoistProject[]>();
for (const p of projects) {
const parentKey =
p.parentExtId && keptProjectIds.has(p.parentExtId) ? p.parentExtId : null;
const list = projectChildren.get(parentKey) || [];
list.push(p);
projectChildren.set(parentKey, list);
}
projectChildren.forEach((list) => list.sort((a, b) => a.childOrder - b.childOrder));
const visitedProjects = new Set<string>();
const visitProject = (p: TodoistProject): void => {
if (visitedProjects.has(p.extId)) {
return;
}
visitedProjects.add(p.extId);
orderedProjects.push(p);
(projectChildren.get(p.extId) || []).forEach(visitProject);
};
(projectChildren.get(null) || []).forEach(visitProject);
// a parent-cycle in a hostile payload would skip its members entirely —
// append anything unvisited so no project is silently lost
for (const p of projects) {
visitProject(p);
}
const sections: TodoistSection[] = [];
const sectionOrderById = new Map<string, number>();
for (const s of raw.sections || []) {
const extId = asId(s.id);
const projectExtId = asId(s.project_id);
if (
!extId ||
!projectExtId ||
!keptProjectIds.has(projectExtId) ||
isTruthyFlag(s.is_deleted) ||
isTruthyFlag(s.is_archived)
) {
continue;
}
sectionOrderById.set(extId, s.section_order ?? 0);
sections.push({ extId, projectExtId, title: asStr(s.name).trim() });
}
const commentsByItemId = new Map<string, RawNote[]>();
for (const n of raw.notes || []) {
const itemId = asId(n.item_id);
if (!itemId || isTruthyFlag(n.is_deleted)) {
continue;
}
const list = commentsByItemId.get(itemId) || [];
list.push(n);
commentsByItemId.set(itemId, list);
}
const keptItems: RawItem[] = [];
const keptItemIds = new Set<string>();
for (const item of raw.items || []) {
const extId = asId(item.id);
const projectExtId = asId(item.project_id);
if (
!extId ||
!projectExtId ||
!keptProjectIds.has(projectExtId) ||
isTruthyFlag(item.checked) ||
isTruthyFlag(item.is_deleted)
) {
continue;
}
keptItems.push(item);
keptItemIds.add(extId);
}
// parent missing (completed/deleted/filtered) OR in another project (a
// shape Todoist shouldn't produce — its temp ID could never resolve in this
// project's batch) → treat as root
const projectByItemId = new Map<string, string>(
keptItems.map((i) => [asId(i.id) as string, asId(i.project_id) as string]),
);
const childrenByParent = new Map<string, RawItem[]>();
const roots: RawItem[] = [];
for (const item of keptItems) {
const parentId = asId(item.parent_id);
if (
parentId &&
keptItemIds.has(parentId) &&
projectByItemId.get(parentId) === asId(item.project_id)
) {
const list = childrenByParent.get(parentId) || [];
list.push(item);
childrenByParent.set(parentId, list);
} else {
roots.push(item);
}
}
childrenByParent.forEach((list) =>
list.sort((a, b) => (a.child_order ?? 0) - (b.child_order ?? 0)),
);
const projectOrder = new Map(orderedProjects.map((p, i) => [p.extId, i]));
const rootSortKey = (item: RawItem): [number, number, number] => {
const sectionId = asId(item.section_id);
// section-less tasks come first in Todoist, like sections with order -1
const sectionOrder = sectionId ? (sectionOrderById.get(sectionId) ?? 0) : -1;
return [
projectOrder.get(asId(item.project_id) as string) ?? 0,
sectionOrder,
item.child_order ?? 0,
];
};
roots.sort((a, b) => {
const [pa, sa, ca] = rootSortKey(a);
const [pb, sb, cb] = rootSortKey(b);
return pa - pb || sa - sb || ca - cb;
});
const toTask = (
item: RawItem,
rootExtId: string | null,
depth: number,
): TodoistTask => {
const extId = asId(item.id) as string;
const { dueDay: parsedDueDay, dueWithTime } = parseDue(item.due);
const deadlineDay = isValidDayStr(item.deadline?.date) ? item.deadline!.date! : null;
const hasDue = !!parsedDueDay || !!dueWithTime;
// deadline fills in as dueDay when there is no due date; otherwise it is
// preserved as a notes line so nothing is silently dropped
const dueDay = parsedDueDay ?? (!hasDue ? deadlineDay : null);
const deadlineNoted = hasDue && deadlineDay ? deadlineDay : null;
const duration = item.duration;
// Number.isFinite guards a hostile `1e999` → Infinity, which would diverge
// across clients (Infinity JSON-serializes to null in the op-log)
const isMinuteDuration =
!!duration &&
duration.unit === 'minute' &&
Number.isFinite(duration.amount) &&
(duration.amount as number) > 0 &&
(duration.amount as number) <= 525_600; // 1 year in minutes
const isDayDurationSkipped = !!duration && duration.unit === 'day';
const title = asStr(item.content).trim();
const labels = (item.labels || []).filter(
(label): label is string => typeof label === 'string' && label.trim() !== '',
);
const builtNotes = buildNotes(
item,
commentsByItemId.get(extId) || [],
deadlineNoted,
strings,
);
return {
extId,
projectExtId: asId(item.project_id) as string,
parentExtId: rootExtId,
title: clamp(title, MAX_TITLE_LEN) || strings.untitledTask,
notes: builtNotes.notes,
labels: labels.map((label) => clamp(label, MAX_LABEL_LEN)),
apiPriority: item.priority ?? 1,
dueDay,
dueWithTime,
timeEstimate: isMinuteDuration ? (duration.amount as number) * 60_000 : null,
isRecurring: !!item.due?.is_recurring,
wasDemoted: depth >= 2,
isDayDurationSkipped,
hasAssignee: !!asId(item.responsible_uid),
attachmentCount: (commentsByItemId.get(extId) || []).filter((c) =>
isSupportedAttachmentUrl(c.file_attachment?.file_url),
).length,
truncatedFieldCount:
(title.length > MAX_TITLE_LEN ? 1 : 0) +
labels.filter((label) => label.length > MAX_LABEL_LEN).length +
builtNotes.truncatedFieldCount,
};
};
const tasks: TodoistTask[] = [];
const emittedIds = new Set<string>();
const emitFamily = (root: RawItem): void => {
const rootExtId = asId(root.id) as string;
emittedIds.add(rootExtId);
tasks.push(toTask(root, null, 0));
// all descendants become direct sub-tasks of the root, DFS keeps reading order
const stack = (childrenByParent.get(rootExtId) || [])
.map((item) => ({ item, depth: 1 }))
.reverse();
while (stack.length) {
const { item, depth } = stack.pop() as { item: RawItem; depth: number };
const itemId = asId(item.id) as string;
if (emittedIds.has(itemId)) {
continue; // parent-cycle guard in a hostile payload
}
emittedIds.add(itemId);
tasks.push(toTask(item, rootExtId, depth));
const children = childrenByParent.get(itemId) || [];
for (let i = children.length - 1; i >= 0; i--) {
stack.push({ item: children[i], depth: depth + 1 });
}
}
};
roots.forEach(emitFamily);
// members of a parent-cycle have no root — emit them as roots so nothing
// in the payload is silently dropped
for (const item of keptItems) {
if (!emittedIds.has(asId(item.id) as string)) {
emitFamily(item);
}
}
return { projects: orderedProjects, sections, tasks };
};

View file

@ -0,0 +1,53 @@
import { PluginAPI } from '@super-productivity/plugin-api';
import { loadTodoistData } from './load-todoist-data';
describe('loadTodoistData', () => {
it('follows the initial full sync with an incremental sync and merges the delta', async () => {
const request = jest
.fn()
.mockResolvedValueOnce({
sync_token: 'full-token',
projects: [{ id: 'p1', name: 'Old name' }],
items: [],
sections: [],
notes: [],
})
.mockResolvedValueOnce({
sync_token: 'incremental-token',
projects: [
{ id: 'p1', name: 'New name' },
{ id: 'p2', name: 'New project' },
],
});
const result = await loadTodoistData(
{ request } as unknown as Pick<PluginAPI, 'request'>,
'secret-token',
);
expect(request).toHaveBeenCalledTimes(2);
expect(
new URLSearchParams(request.mock.calls[0][1].body as string).get('sync_token'),
).toBe('*');
expect(
new URLSearchParams(request.mock.calls[1][1].body as string).get('sync_token'),
).toBe('full-token');
expect(result.sync_token).toBe('incremental-token');
expect(result.projects?.map((project) => project.name)).toEqual([
'New name',
'New project',
]);
});
it('fails closed when Todoist omits the incremental sync token', async () => {
const request = jest.fn().mockResolvedValue({ projects: [] });
await expect(
loadTodoistData(
{ request } as unknown as Pick<PluginAPI, 'request'>,
'secret-token',
),
).rejects.toThrow('sync token');
expect(request).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,43 @@
import { PluginAPI } from '@super-productivity/plugin-api';
import { mergeSyncResponses, RawSyncResponse } from './from-api';
const SYNC_URL = 'https://api.todoist.com/api/v1/sync';
const RESOURCE_TYPES = ['projects', 'items', 'sections', 'notes'];
type TodoistRequestApi = Pick<PluginAPI, 'request'>;
const requestSync = (
api: TodoistRequestApi,
token: string,
syncToken: string,
): Promise<RawSyncResponse> =>
api.request<RawSyncResponse>(SYNC_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
sync_token: syncToken,
resource_types: JSON.stringify(RESOURCE_TYPES),
}).toString(),
});
/**
* Todoist can serve delayed full snapshots for large accounts. Always apply the
* immediately-following incremental delta before previewing data so recent
* creates, updates, and tombstones are represented in the import.
*
* @see https://developer.todoist.com/api/v1/#tag/Sync/Incremental-sync
*/
export const loadTodoistData = async (
api: TodoistRequestApi,
token: string,
): Promise<RawSyncResponse> => {
const full = await requestSync(api, token, '*');
if (!full.sync_token) {
throw new Error('Todoist full sync did not return an incremental sync token.');
}
const incremental = await requestSync(api, token, full.sync_token);
return mergeSyncResponses(full, incremental);
};

View file

@ -0,0 +1,60 @@
/**
* Normalized intermediate shape between the Todoist sync payload and the
* Super Productivity import plan. Everything lossy is flagged per task so the
* preview/summary UI can honestly report what will be / was dropped.
*/
export interface TodoistProject {
extId: string;
title: string;
parentExtId: string | null;
isInbox: boolean;
childOrder: number;
/** number of imported values shortened to the parser's safe limits */
truncatedFieldCount?: number;
}
export interface TodoistSection {
extId: string;
projectExtId: string;
title: string;
}
export interface TodoistTask {
extId: string;
projectExtId: string;
/** null = root task; set = direct sub-task after depth-flattening (SP nests 2 levels) */
parentExtId: string | null;
title: string;
notes: string;
labels: string[];
/** Raw Todoist API value: 4 = UI p1 (highest) … 1 = default/none */
apiPriority: number;
/** YYYY-MM-DD — mutually exclusive with dueWithTime */
dueDay: string | null;
/** unix ms — mutually exclusive with dueDay */
dueWithTime: number | null;
/** ms, from minute-based durations only */
timeEstimate: number | null;
isRecurring: boolean;
/** original depth was ≥ 2 and the task was re-parented to its root ancestor */
wasDemoted: boolean;
/** had a day-based duration that is deliberately not imported */
isDayDurationSkipped: boolean;
hasAssignee: boolean;
/** comment file attachments (URLs kept in notes, files not imported) */
attachmentCount: number;
/** number of imported values shortened to the parser's safe limits */
truncatedFieldCount?: number;
}
export interface TodoistImportModel {
projects: TodoistProject[];
sections: TodoistSection[];
/**
* In final creation order: per project, root tasks (sorted by section order,
* then child order) each immediately followed by their sub-tasks in DFS
* order a parent always precedes its children.
*/
tasks: TodoistTask[];
}

View file

@ -0,0 +1,4 @@
// Todoist Import plugin — all logic lives in the iframe UI (index.html).
// The plugin is opened via the Import/Export settings screen; it registers no
// hooks, shortcuts or menu entries (isSkipMenuEntry) so it adds zero standing
// weight when idle.

View file

@ -0,0 +1,100 @@
import { PriorityMapping } from '../map/plan-import';
import { TodoistImportModel, TodoistTask } from '../parse/normalized-model';
import { buildLossyNotes } from './build-lossy-notes';
const task = (overrides: Partial<TodoistTask>): TodoistTask => ({
extId: 'task',
projectExtId: 'selected',
parentExtId: null,
title: 'Task',
notes: '',
labels: [],
apiPriority: 1,
dueDay: null,
dueWithTime: null,
timeEstimate: null,
isRecurring: false,
wasDemoted: false,
isDayDurationSkipped: false,
hasAssignee: false,
attachmentCount: 0,
...overrides,
});
const model = (): TodoistImportModel => ({
projects: [
{
extId: 'selected',
title: 'Child',
parentExtId: 'parent',
isInbox: false,
childOrder: 0,
truncatedFieldCount: 1,
},
{
extId: 'other',
title: 'Other',
parentExtId: null,
isInbox: false,
childOrder: 1,
},
],
sections: [
{ extId: 'section-selected', projectExtId: 'selected', title: 'Section' },
{ extId: 'section-other', projectExtId: 'other', title: 'Other section' },
],
tasks: [
task({
extId: 'selected-root',
isRecurring: true,
isDayDurationSkipped: true,
hasAssignee: true,
attachmentCount: 2,
truncatedFieldCount: 2,
}),
task({
extId: 'selected-sub',
parentExtId: 'selected-root',
labels: ['label'],
apiPriority: 4,
wasDemoted: true,
}),
task({
extId: 'other-root',
projectExtId: 'other',
isRecurring: true,
attachmentCount: 4,
}),
],
});
const keysFor = (priorityMapping: PriorityMapping): string[] =>
buildLossyNotes(model(), new Set(['selected']), priorityMapping).map(
(note) => note.key,
);
describe('buildLossyNotes', () => {
it('only reports losses for selected projects', () => {
const notes = buildLossyNotes(model(), new Set(['selected']), 'none');
expect(notes).toEqual(
expect.arrayContaining([
{ key: 'LOSS.NESTED_PROJECTS', params: { count: 1 } },
{ key: 'LOSS.SECTIONS', params: { count: 1 } },
{ key: 'LOSS.DEMOTED_SUBTASKS', params: { count: 1 } },
{ key: 'LOSS.RECURRING', params: { count: 1 } },
{ key: 'LOSS.DAY_DURATIONS', params: { count: 1 } },
{ key: 'LOSS.SUBTASK_LABELS', params: { count: 1 } },
{ key: 'LOSS.ASSIGNEES', params: { count: 1 } },
{ key: 'LOSS.ATTACHMENTS', params: { count: 2 } },
{ key: 'LOSS.TRUNCATED_FIELDS', params: { count: 3 } },
]),
);
});
it('reports prioritized sub-tasks only when priority mapping is enabled', () => {
expect(keysFor('none')).not.toContain('LOSS.SUBTASK_PRIORITIES');
expect(keysFor('priorityTags')).toContain('LOSS.SUBTASK_PRIORITIES');
expect(keysFor('eisenhower')).toContain('LOSS.SUBTASK_PRIORITIES');
});
});

View file

@ -0,0 +1,61 @@
import { PriorityMapping } from '../map/plan-import';
import { TodoistImportModel } from '../parse/normalized-model';
export interface LossNote {
key: string;
params?: Record<string, string | number>;
}
export const buildLossyNotes = (
model: TodoistImportModel,
selected: ReadonlySet<string>,
priorityMapping: PriorityMapping,
): LossNote[] => {
const projects = model.projects.filter((project) => selected.has(project.extId));
const tasks = model.tasks.filter((task) => selected.has(task.projectExtId));
const notes: LossNote[] = [];
const addCount = (key: string, count: number): void => {
if (count) {
notes.push({ key, params: { count } });
}
};
addCount(
'LOSS.NESTED_PROJECTS',
projects.filter((project) => !!project.parentExtId).length,
);
addCount(
'LOSS.SECTIONS',
model.sections.filter((section) => selected.has(section.projectExtId)).length,
);
addCount('LOSS.DEMOTED_SUBTASKS', tasks.filter((task) => task.wasDemoted).length);
addCount('LOSS.RECURRING', tasks.filter((task) => task.isRecurring).length);
addCount(
'LOSS.DAY_DURATIONS',
tasks.filter((task) => task.isDayDurationSkipped).length,
);
addCount(
'LOSS.SUBTASK_LABELS',
tasks.filter((task) => task.parentExtId && task.labels.length).length,
);
if (priorityMapping !== 'none') {
addCount(
'LOSS.SUBTASK_PRIORITIES',
tasks.filter((task) => task.parentExtId && task.apiPriority > 1).length,
);
}
addCount('LOSS.ASSIGNEES', tasks.filter((task) => task.hasAssignee).length);
addCount(
'LOSS.ATTACHMENTS',
tasks.reduce((count, task) => count + task.attachmentCount, 0),
);
addCount(
'LOSS.TRUNCATED_FIELDS',
[...projects, ...tasks].reduce(
(count, item) => count + (item.truncatedFieldCount || 0),
0,
),
);
notes.push({ key: 'LOSS.COMPLETED_AND_REMINDERS' });
return notes;
};

View file

@ -0,0 +1,22 @@
import { loadTranslations, t } from './i18n';
describe('iframe translations', () => {
it('awaits the iframe translation bridge and interpolates dynamic values locally', async () => {
const translate = jest.fn((key: string) =>
Promise.resolve(key === 'BUTTON.LOAD_PREVIEW' ? 'Vorschau laden' : key),
);
await loadTranslations({ translate });
expect(t('BUTTON.LOAD_PREVIEW')).toBe('Vorschau laden');
expect(t('ERROR.LOAD_FAILED', { error: '401' })).toContain('401');
});
it('falls back to bundled English if the host translation call fails', async () => {
await loadTranslations({
translate: () => Promise.reject(new Error('bridge unavailable')),
});
expect(t('BUTTON.IMPORT')).toBe('Import');
});
});

View file

@ -0,0 +1,47 @@
import english from '../../i18n/en.json';
type TranslationParams = Record<string, string | number>;
interface TranslationApi {
translate: (key: string, params?: TranslationParams) => string | Promise<string>;
}
const flatten = (value: Record<string, unknown>, prefix = ''): Record<string, string> => {
const result: Record<string, string> = {};
for (const [key, nestedValue] of Object.entries(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof nestedValue === 'string') {
result[fullKey] = nestedValue;
} else if (nestedValue && typeof nestedValue === 'object') {
Object.assign(result, flatten(nestedValue as Record<string, unknown>, fullKey));
}
}
return result;
};
const englishByKey = flatten(english);
let translatedByKey: Record<string, string> = { ...englishByKey };
export const loadTranslations = async (api: TranslationApi): Promise<void> => {
translatedByKey = { ...englishByKey };
await Promise.all(
Object.keys(englishByKey).map(async (key) => {
try {
const translated = await Promise.resolve(api.translate(key));
if (translated && translated !== key) {
translatedByKey[key] = translated;
}
} catch {
// Bundled English remains available if the iframe bridge is unavailable.
}
}),
);
};
export const t = (key: string, params: TranslationParams = {}): string => {
let value = translatedByKey[key] || englishByKey[key] || key;
for (const [name, replacement] of Object.entries(params)) {
value = value.split(`{{${name}}}`).join(String(replacement));
}
return value;
};

View file

@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Todoist Import</title>
<link
rel="icon"
href="data:,"
/>
<style>
body {
max-width: 640px;
margin: 0 auto;
padding: var(--s2);
}
.muted {
opacity: 0.7;
font-size: 0.9em;
}
.warn {
color: var(--color-warning);
font-size: 0.9em;
}
.error {
color: var(--color-danger);
}
ul {
padding-left: var(--s3);
}
li {
margin: var(--s-half) 0;
}
label {
display: block;
margin: var(--s-half) 0;
}
input[type='password'] {
width: 100%;
box-sizing: border-box;
margin: var(--s) 0;
}
.actions {
margin-top: var(--s2);
display: flex;
gap: var(--s);
}
</style>
</head>
<body>
<main id="app"></main>
<!-- BUILD:SCRIPT -->
</body>
</html>

View file

@ -0,0 +1,355 @@
import { PluginAPI as PluginApiType, Project } from '@super-productivity/plugin-api';
import { parseSyncResponse, ParseStrings } from '../parse/from-api';
import { loadTodoistData } from '../parse/load-todoist-data';
import { TodoistImportModel } from '../parse/normalized-model';
import {
buildProjectTitles,
groupTasksByProject,
ImportPlan,
planImport,
PriorityMapping,
} from '../map/plan-import';
import { runImport, ImportResult } from '../map/run-import';
import { buildLossyNotes, LossNote } from './build-lossy-notes';
import { loadTranslations, t } from './i18n';
declare global {
interface Window {
PluginAPI: PluginApiType;
}
}
const api = (): PluginApiType => window.PluginAPI;
const el = <K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Partial<HTMLElementTagNameMap[K]> & { text?: string } = {},
children: (HTMLElement | string)[] = [],
): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag);
const { text, ...rest } = props;
Object.assign(node, rest);
if (text !== undefined) {
node.textContent = text;
}
for (const child of children) {
node.append(child);
}
return node;
};
const app = (): HTMLElement => document.getElementById('app') as HTMLElement;
const render = (...children: HTMLElement[]): void => {
const root = app();
root.replaceChildren(...children);
};
const parseStrings = (): ParseStrings => ({
untitledProject: t('PARSE.UNTITLED_PROJECT'),
untitledTask: t('PARSE.UNTITLED_TASK'),
repeats: (rule) => t('PARSE.REPEATS', { rule }),
deadline: (date) => t('PARSE.DEADLINE', { date }),
comments: t('PARSE.COMMENTS'),
file: t('PARSE.FILE'),
});
// ---------------------------------------------------------------------------
// Step 1 — token input
// ---------------------------------------------------------------------------
const renderTokenStep = (errorMsg?: string, tokenValue?: string): void => {
const tokenInputId = 'todoist-api-token';
const tokenInput = el('input', {
id: tokenInputId,
type: 'password',
placeholder: t('TOKEN.PLACEHOLDER'),
autocomplete: 'off',
value: tokenValue || '',
});
const fetchBtn = el('button', { text: t('BUTTON.LOAD_PREVIEW') });
const errorLine = errorMsg
? [el('p', { className: 'error', role: 'alert', text: errorMsg })]
: [];
const submit = async (): Promise<void> => {
const token = tokenInput.value.trim();
if (!token) {
renderTokenStep(t('TOKEN.REQUIRED'));
return;
}
render(
el('h1', { text: t('TITLE.IMPORT') }),
el('p', {
text: t('TOKEN.LOADING'),
role: 'status',
ariaLive: 'polite',
}),
);
try {
const raw = await loadTodoistData(api(), token);
const model = parseSyncResponse(raw || {}, parseStrings());
if (!model.projects.length) {
renderTokenStep(t('TOKEN.NO_PROJECTS'), token);
return;
}
const existingProjects = await api().getAllProjects();
renderPreviewStep(model, existingProjects);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
renderTokenStep(t('ERROR.LOAD_FAILED', { error: msg }), tokenInput.value);
}
};
fetchBtn.addEventListener('click', () => void submit());
tokenInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
void submit();
}
});
render(
el('h1', { text: t('TITLE.IMPORT') }),
el('p', { text: t('TOKEN.INTRO') }),
...errorLine,
el('label', { htmlFor: tokenInputId, text: t('TOKEN.LABEL') }),
tokenInput,
el('p', { className: 'muted', text: t('TOKEN.HELP') }),
el('div', { className: 'actions' }, [fetchBtn]),
);
};
// ---------------------------------------------------------------------------
// Step 2 — preview with per-project selection
// ---------------------------------------------------------------------------
const renderPreviewStep = (
model: TodoistImportModel,
existingProjects: Project[],
): void => {
const existingTitles = new Set(existingProjects.map((p) => p.title.toLowerCase()));
// the exact titles the import will create (Inbox rename, `Parent / Child`
// disambiguation, `(2)` suffixes) — preview and collision check must match
const titleByExtId = buildProjectTitles(model);
const tasksByProject = groupTasksByProject(model);
const checkboxByExtId = new Map<string, HTMLInputElement>();
const lossyList = el('ul');
// Priority → tags is a single mutually-exclusive choice (radios): off, the
// p1p3 tags, or SP's built-in Eisenhower urgent/important tags.
const priorityRadio = (value: PriorityMapping, checked = false): HTMLInputElement =>
el('input', { type: 'radio', name: 'todoist-priority-mapping', value, checked });
const priorityNoneRadio = priorityRadio('none', true);
const priorityTagsRadio = priorityRadio('priorityTags');
const priorityEisenhowerRadio = priorityRadio('eisenhower');
const selectedPriorityMapping = (): PriorityMapping =>
priorityTagsRadio.checked
? 'priorityTags'
: priorityEisenhowerRadio.checked
? 'eisenhower'
: 'none';
const selectedIds = (): Set<string> => {
const ids = new Set<string>();
checkboxByExtId.forEach((box, extId) => {
if (box.checked) {
ids.add(extId);
}
});
return ids;
};
const refreshLossyList = (): void => {
lossyList.replaceChildren(
...buildLossyNotes(model, selectedIds(), selectedPriorityMapping()).map((note) =>
el('li', { text: t(note.key, note.params) }),
),
);
};
const projectRows = model.projects.map((project) => {
const tasks = tasksByProject.get(project.extId) || [];
const rootCount = tasks.filter((t) => !t.parentExtId).length;
const subCount = tasks.length - rootCount;
const title = titleByExtId.get(project.extId) as string;
const collides = existingTitles.has(title.toLowerCase());
const checkbox = el('input', { type: 'checkbox', checked: !collides });
checkbox.addEventListener('change', refreshLossyList);
checkboxByExtId.set(project.extId, checkbox);
return el('label', {}, [
checkbox,
` ${t('PREVIEW.PROJECT_COUNTS', {
title,
taskCount: rootCount,
subTaskCount: subCount,
})}`,
...(collides
? [
el('span', {
className: 'warn',
text: `${t('PREVIEW.ALREADY_EXISTS')}`,
}),
]
: []),
]);
});
for (const radio of [priorityNoneRadio, priorityTagsRadio, priorityEisenhowerRadio]) {
radio.addEventListener('change', refreshLossyList);
}
const importBtn = el('button', { text: t('BUTTON.IMPORT') });
const backBtn = el('button', { text: t('BUTTON.BACK') });
backBtn.addEventListener('click', () => renderTokenStep());
importBtn.addEventListener('click', () => {
const selected = selectedIds();
if (!selected.size) {
api().showSnack({ msg: t('PREVIEW.SELECT_PROJECT'), type: 'WARNING' });
return;
}
const priorityMapping = selectedPriorityMapping();
const plan = planImport(model, {
priorityMapping,
selectedProjectExtIds: selected,
});
void executeImport(plan, buildLossyNotes(model, selected, priorityMapping));
});
refreshLossyList();
render(
el('h1', { text: t('TITLE.PREVIEW') }),
el('p', { text: t('PREVIEW.CHOOSE_PROJECTS') }),
el('div', {}, projectRows),
el('fieldset', {}, [
el('legend', { text: t('PREVIEW.PRIORITY_LEGEND') }),
el('label', {}, [priorityNoneRadio, ` ${t('PREVIEW.PRIORITY_NONE')}`]),
el('label', {}, [priorityTagsRadio, ` ${t('PREVIEW.PRIORITY_TAGS')}`]),
el('label', {}, [priorityEisenhowerRadio, ` ${t('PREVIEW.PRIORITY_EISENHOWER')}`]),
]),
el('h2', { text: t('PREVIEW.LOSS_HEADING') }),
lossyList,
el('div', { className: 'actions' }, [importBtn, backBtn]),
);
};
// ---------------------------------------------------------------------------
// Step 3 + 4 — import progress and summary
// ---------------------------------------------------------------------------
const executeImport = async (plan: ImportPlan, lossyNotes: LossNote[]): Promise<void> => {
const progressLine = el('p', {
text: t('IMPORT.STARTING'),
role: 'status',
ariaLive: 'polite',
});
render(el('h1', { text: t('TITLE.IMPORTING') }), progressLine);
const result = await runImport(api(), plan, (progress) => {
const detail =
progress.phase === 'details' && progress.detailTotal
? t('IMPORT.PHASE_DETAILS', {
current: Math.min((progress.detailIndex ?? 0) + 1, progress.detailTotal),
total: progress.detailTotal,
})
: t(progress.phase === 'project' ? 'IMPORT.PHASE_PROJECT' : 'IMPORT.PHASE_TASKS');
progressLine.textContent = t('IMPORT.PROGRESS', {
current: progress.projectIndex + 1,
total: progress.totalProjects,
title: progress.projectTitle,
detail,
});
});
renderSummaryStep(result, lossyNotes);
};
const projectSummaryLine = (p: ImportResult['imported'][number]): HTMLElement => {
const isShortfall =
p.landedTaskCount < p.plannedTaskCount ||
p.landedSubTaskCount < p.plannedSubTaskCount;
return el('li', {
className: isShortfall ? 'warn' : '',
text:
t('SUMMARY.PROJECT_RESULT', {
title: p.title,
landedTasks: p.landedTaskCount,
plannedTasks: p.plannedTaskCount,
landedSubTasks: p.landedSubTaskCount,
plannedSubTasks: p.plannedSubTaskCount,
}) + (isShortfall ? `${t('SUMMARY.SHORTFALL')}` : ''),
});
};
const renderSummaryStep = (result: ImportResult, lossyNotes: LossNote[]): void => {
const items = result.imported.map(projectSummaryLine);
const failure = result.errorMessage
? [
el('p', {
className: 'error',
role: 'alert',
text: result.failedProjectTitle
? t('ERROR.IMPORT_STOPPED', {
project: result.failedProjectTitle,
error: result.errorMessage,
})
: t('ERROR.IMPORT_FAILED', { error: result.errorMessage }),
}),
]
: [];
const unverified = result.isCountUnverified
? [
el('p', {
className: 'warn',
role: 'status',
text: t('SUMMARY.UNVERIFIED'),
}),
]
: [];
const tagLine = result.createdTagTitles.length
? [
el('p', {
text: t('SUMMARY.CREATED_TAGS', {
tags: result.createdTagTitles.join(', '),
}),
}),
]
: [];
const lossy = lossyNotes.length
? [
el('h2', { text: t('SUMMARY.NOT_CARRIED_OVER') }),
el(
'ul',
{},
lossyNotes.map((note) => el('li', { text: t(note.key, note.params) })),
),
]
: [];
if (!result.errorMessage) {
api().showSnack({ msg: t('SUMMARY.SNACK_FINISHED'), type: 'SUCCESS' });
}
render(
el('h1', {
text: t(result.errorMessage ? 'TITLE.INCOMPLETE' : 'TITLE.FINISHED'),
}),
el('ul', {}, items),
...unverified,
...tagLine,
...failure,
...lossy,
el('p', {
className: 'muted',
text: t('SUMMARY.UNDO'),
}),
);
};
// The host injects the PluginAPI bridge script at the end of <body>; wait for
// DOM readiness so it is guaranteed to be defined before first use.
const start = async (): Promise<void> => {
await loadTranslations(api());
renderTokenStep();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void start());
} else {
void start();
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2020", "DOM"],
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -56,3 +56,13 @@
<mat-icon>compress</mat-icon>
{{ T.FILE_IMEX.BTN_COMPRESS_ARCHIVE | translate }}
</button>
<button
(click)="openTodoistImport()"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon>move_to_inbox</mat-icon>
{{ T.FILE_IMEX.IMPORT_FROM_TODOIST | translate }}
</button>

View file

@ -18,6 +18,7 @@ import { ConfirmUrlImportDialogComponent } from '../dialog-confirm-url-import/di
import { DialogImportFromUrlComponent } from '../dialog-import-from-url/dialog-import-from-url.component';
import { createAppDataCompleteMock } from '../../util/app-data-mock';
import { ImportEncryptionHandlerService } from '../sync/import-encryption-handler.service';
import { PluginService } from '../../plugins/plugin.service';
describe('FileImexComponent', () => {
let component: FileImexComponent;
@ -52,6 +53,13 @@ describe('FileImexComponent', () => {
importEncryptionHandlerSpy.checkEncryptionStateChange.and.returnValue(
Promise.resolve({ willChange: false }),
);
const pluginServiceSpy = jasmine.createSpyObj('PluginService', [
'isInitialized',
'initializePlugins',
'activatePlugin',
]);
pluginServiceSpy.isInitialized.and.returnValue(true);
pluginServiceSpy.activatePlugin.and.returnValue(Promise.resolve(null));
importEncryptionHandlerSpy.handleImportEncryptionIfNeeded.and.returnValue(
Promise.resolve(null),
);
@ -77,6 +85,7 @@ describe('FileImexComponent', () => {
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: MatDialog, useValue: matDialogSpy },
{ provide: ImportEncryptionHandlerService, useValue: importEncryptionHandlerSpy },
{ provide: PluginService, useValue: pluginServiceSpy },
],
}).compileComponents();

View file

@ -43,6 +43,9 @@ import { Log } from '../../core/log';
import { DialogArchiveCompressionComponent } from '../../features/archive/dialog-archive-compression/dialog-archive-compression.component';
import { DataValidationFailedError } from '../../op-log/core/errors/sync-errors';
import { alertDialog } from '../../util/native-dialogs';
import { PluginService } from '../../plugins/plugin.service';
const TODOIST_IMPORT_PLUGIN_ID = 'todoist-import';
@Component({
selector: 'file-imex',
@ -59,6 +62,7 @@ export class FileImexComponent implements OnInit {
private _matDialog = inject(MatDialog);
private _http = inject(HttpClient);
private _importEncryptionHandler = inject(ImportEncryptionHandlerService);
private _pluginService = inject(PluginService);
readonly fileInputRef = viewChild<ElementRef>('fileInput');
T: typeof T = T;
@ -297,4 +301,28 @@ export class FileImexComponent implements OnInit {
maxWidth: '90vw',
});
}
async openTodoistImport(): Promise<void> {
try {
if (!this._pluginService.isInitialized()) {
await this._pluginService.initializePlugins();
}
// In-memory activation only (not persisted): the importer is a one-time
// tool and should be dormant again after a restart.
const instance = await this._pluginService.activatePlugin(
TODOIST_IMPORT_PLUGIN_ID,
true,
);
if (!instance) {
throw new Error('Plugin activation returned no instance');
}
await this._router.navigate(['/plugins', TODOIST_IMPORT_PLUGIN_ID, 'index']);
} catch (e) {
Log.err('Failed to open Todoist importer', e);
this._snackService.open({
type: 'ERROR',
msg: T.FILE_IMEX.S_ERR_TODOIST_IMPORT_OPEN,
});
}
}
}

View file

@ -1221,6 +1221,7 @@ export class PluginBridgeService implements OnDestroy {
// Chunk large operations to prevent oversized payloads
const chunks = this._chunkOperations(request.operations);
const createdTaskTimestamp = Date.now();
if (chunks.length > 1) {
PluginLog.log('PluginBridge: Chunking large batch operation', {
@ -1237,6 +1238,7 @@ export class PluginBridgeService implements OnDestroy {
projectId: request.projectId,
operations: chunk,
createdTaskIds, // Same IDs mapping for all chunks
createdTaskTimestamp,
}),
);
});

View file

@ -61,6 +61,7 @@ const BUNDLED_PLUGIN_PATHS = [
'assets/bundled-plugins/google-calendar-provider',
'assets/bundled-plugins/caldav-calendar-provider',
'assets/bundled-plugins/doc-mode',
'assets/bundled-plugins/todoist-import',
] as const;
// Reserved ids: an uploaded plugin may not reuse a bundled plugin's manifest id (it would
@ -85,6 +86,7 @@ const BUNDLED_PLUGIN_IDS = new Set<string>([
'linear-issue-provider',
'procrastination-buster',
'sync-md',
'todoist-import',
'trello-issue-provider',
'voice-reminder',
'yesterday-tasks',

View file

@ -86,6 +86,27 @@ describe('taskBatchUpdateMetaReducer', () => {
expect(task.isDone).toBe(false);
});
it('should use the timestamp captured in the persistent batch action', () => {
const action = TaskSharedActions.batchUpdateForProject({
projectId: 'project1',
operations: [
{
type: 'create',
tempId: 'temp-stable-time',
data: { title: 'Replay-safe task' },
} as BatchTaskCreate,
],
createdTaskIds: { 'temp-stable-time': 'stable-time-task' },
createdTaskTimestamp: 1_752_124_200_000,
});
const result = metaReducer(baseState, action);
expect(result[TASK_FEATURE_NAME].entities['stable-time-task']?.created).toBe(
1_752_124_200_000,
);
});
it('should skip task creation if title is empty', () => {
const operations: BatchOperation[] = [
{

View file

@ -42,9 +42,8 @@ export const taskBatchUpdateMetaReducer = <T extends Partial<RootState> = RootSt
): ActionReducer<T> => {
return (state: T | undefined, action: Action) => {
if (action.type === TaskSharedActions.batchUpdateForProject.type) {
const { projectId, operations, createdTaskIds } = action as ReturnType<
typeof TaskSharedActions.batchUpdateForProject
>;
const { projectId, operations, createdTaskIds, createdTaskTimestamp } =
action as ReturnType<typeof TaskSharedActions.batchUpdateForProject>;
// Ensure state has required properties
const rootState = state as unknown as RootState;
@ -144,7 +143,9 @@ export const taskBatchUpdateMetaReducer = <T extends Partial<RootState> = RootSt
notes: createOp.data.notes || '',
parentId,
timeEstimate: createOp.data.timeEstimate || 0,
created: Date.now(),
// New actions capture this before dispatch so replay is deterministic.
// The fallback keeps legacy operations and direct test callers valid.
created: createdTaskTimestamp ?? Date.now(),
};
tasksToAdd.push(newTask);

View file

@ -381,6 +381,8 @@ export const TaskSharedActions = createActionGroup({
projectId: string;
operations: BatchOperation[];
createdTaskIds: { [tempId: string]: string };
/** Captured before dispatch so replay creates identical task state. */
createdTaskTimestamp?: number;
}) => ({
...taskProps,
meta: {

View file

@ -2288,6 +2288,7 @@ const T = {
},
EXPORT_DATA: 'FILE_IMEX.EXPORT_DATA',
IMPORT_FROM_FILE: 'FILE_IMEX.IMPORT_FROM_FILE',
IMPORT_FROM_TODOIST: 'FILE_IMEX.IMPORT_FROM_TODOIST',
IMPORT_FROM_URL: 'FILE_IMEX.IMPORT_FROM_URL',
IMPORT_FROM_URL_DIALOG_DESCRIPTION: 'FILE_IMEX.IMPORT_FROM_URL_DIALOG_DESCRIPTION',
IMPORT_FROM_URL_DIALOG_TITLE: 'FILE_IMEX.IMPORT_FROM_URL_DIALOG_TITLE',
@ -2299,6 +2300,7 @@ const T = {
S_ERR_INVALID_DATA: 'FILE_IMEX.S_ERR_INVALID_DATA',
S_ERR_INVALID_URL: 'FILE_IMEX.S_ERR_INVALID_URL',
S_ERR_NETWORK: 'FILE_IMEX.S_ERR_NETWORK',
S_ERR_TODOIST_IMPORT_OPEN: 'FILE_IMEX.S_ERR_TODOIST_IMPORT_OPEN',
S_IMPORT_FROM_URL_ERR_DECODE: 'FILE_IMEX.S_IMPORT_FROM_URL_ERR_DECODE',
URL_PLACEHOLDER: 'FILE_IMEX.URL_PLACEHOLDER',
},

View file

@ -2228,6 +2228,7 @@
},
"EXPORT_DATA": "Export Data",
"IMPORT_FROM_FILE": "Import from File",
"IMPORT_FROM_TODOIST": "Import from Todoist",
"IMPORT_FROM_URL": "Import from URL",
"IMPORT_FROM_URL_DIALOG_DESCRIPTION": "Please enter the full URL of the Super Productivity backup JSON file to import.",
"IMPORT_FROM_URL_DIALOG_TITLE": "Import from URL",
@ -2239,6 +2240,7 @@
"S_ERR_INVALID_DATA": "Import failed: Invalid JSON",
"S_ERR_INVALID_URL": "Import failed: Invalid URL provided",
"S_ERR_NETWORK": "Import failed: Network error while fetching data from URL",
"S_ERR_TODOIST_IMPORT_OPEN": "Could not open the Todoist importer",
"S_IMPORT_FROM_URL_ERR_DECODE": "Error: Could not decode the URL parameter for import. Please ensure it is correctly formatted.",
"URL_PLACEHOLDER": "Enter URL to import from"
},