mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
d2015beb44
commit
762b3c5d8d
39 changed files with 9060 additions and 5 deletions
2
.github/workflows/plugin-tests.yml
vendored
2
.github/workflows/plugin-tests.yml
vendored
|
|
@ -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[@]}")
|
||||
|
|
|
|||
229
docs/plans/2026-07-09-todoist-import.md
Normal file
229
docs/plans/2026-07-09-todoist-import.md
Normal 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.
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
55
docs/wiki/2.20-Import-from-Todoist.md
Normal file
55
docs/wiki/2.20-Import-from-Todoist.md
Normal 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:
|
||||
|
||||
- **p1–p3 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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
76
packages/plugin-dev/todoist-import/i18n/en.json
Normal file
76
packages/plugin-dev/todoist-import/i18n/en.json
Normal 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": "p1–p3 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"
|
||||
}
|
||||
}
|
||||
25
packages/plugin-dev/todoist-import/jest.config.cjs
Normal file
25
packages/plugin-dev/todoist-import/jest.config.cjs
Normal 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',
|
||||
},
|
||||
};
|
||||
5689
packages/plugin-dev/todoist-import/package-lock.json
generated
Normal file
5689
packages/plugin-dev/todoist-import/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
packages/plugin-dev/todoist-import/package.json
Normal file
23
packages/plugin-dev/todoist-import/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
54
packages/plugin-dev/todoist-import/scripts/build.js
Normal file
54
packages/plugin-dev/todoist-import/scripts/build.js
Normal 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);
|
||||
});
|
||||
5
packages/plugin-dev/todoist-import/src/icon.svg
Normal file
5
packages/plugin-dev/todoist-import/src/icon.svg
Normal 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 |
29
packages/plugin-dev/todoist-import/src/manifest.json
Normal file
29
packages/plugin-dev/todoist-import/src/manifest.json
Normal 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"
|
||||
}
|
||||
308
packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts
Normal file
308
packages/plugin-dev/todoist-import/src/map/plan-import.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
232
packages/plugin-dev/todoist-import/src/map/plan-import.ts
Normal file
232
packages/plugin-dev/todoist-import/src/map/plan-import.ts
Normal 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 p1–p3 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 p1–p3 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] };
|
||||
};
|
||||
234
packages/plugin-dev/todoist-import/src/map/run-import.spec.ts
Normal file
234
packages/plugin-dev/todoist-import/src/map/run-import.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
225
packages/plugin-dev/todoist-import/src/map/run-import.ts
Normal file
225
packages/plugin-dev/todoist-import/src/map/run-import.ts
Normal 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;
|
||||
};
|
||||
463
packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts
Normal file
463
packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
482
packages/plugin-dev/todoist-import/src/parse/from-api.ts
Normal file
482
packages/plugin-dev/todoist-import/src/parse/from-api.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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[];
|
||||
}
|
||||
4
packages/plugin-dev/todoist-import/src/plugin.js
Normal file
4
packages/plugin-dev/todoist-import/src/plugin.js
Normal 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.
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
22
packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts
Normal file
22
packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
packages/plugin-dev/todoist-import/src/ui/i18n.ts
Normal file
47
packages/plugin-dev/todoist-import/src/ui/i18n.ts
Normal 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;
|
||||
};
|
||||
53
packages/plugin-dev/todoist-import/src/ui/index.html
Normal file
53
packages/plugin-dev/todoist-import/src/ui/index.html
Normal 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>
|
||||
355
packages/plugin-dev/todoist-import/src/ui/main.ts
Normal file
355
packages/plugin-dev/todoist-import/src/ui/main.ts
Normal 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
|
||||
// p1–p3 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();
|
||||
}
|
||||
18
packages/plugin-dev/todoist-import/tsconfig.json
Normal file
18
packages/plugin-dev/todoist-import/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue