fix(plugins): improve Todoist import feedback

This commit is contained in:
Johannes Millan 2026-07-10 12:55:30 +02:00
parent 59f011ee34
commit 08bac9cb27
15 changed files with 596 additions and 164 deletions

View file

@ -105,8 +105,10 @@ 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 one
`POST https://api.todoist.com/api/v1/sync` with `sync_token=*`,
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`
@ -121,8 +123,7 @@ states "sent only to api.todoist.com, never stored".
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 if the web CORS check fails or users already closed their account;
fast-follow, not v1.
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).
@ -155,7 +156,7 @@ items by default — nothing extra to do).
```
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
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:[]
@ -192,9 +193,9 @@ UI wizard specifics (trust items from review):
- **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:*. → *residual (web only):\* Todoist must
answer the CORS preflight for the `Authorization` header; confirm with one live
web-build call during M3. Electron/mobile need no confirm.
`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,
@ -204,7 +205,7 @@ UI wizard specifics (trust items from review):
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. the live CORS check.
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
@ -216,8 +217,9 @@ UI wizard specifics (trust items from review):
- **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.
- **Web CORS preflight** (M0 residual) — if Todoist blocks browser calls, web users
get a clear error pointing at the desktop app (or the CSV fast-follow).
- **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

View file

@ -19,7 +19,7 @@ Bring your active Todoist projects, tasks, sub-tasks, labels and due dates into
- 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; comment file attachments keep their link)
- 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`)
@ -32,12 +32,24 @@ Todoist priorities are **off by default**. In the preview you can pick one of:
- **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
- Sections (task order within the project is preserved)
- 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]]
If the import stops midway (e.g. connection loss), the projects already listed as imported are complete; re-run the import and select only the remaining projects. To undo an import, delete the created projects.
Unusually long project names, task titles, labels and task notes are shortened to
safe import limits. The preview reports how many selected values are affected.
If the import stops midway (e.g. connection loss), delete the project named in the
error because it may be incomplete. Then re-run the import and select that project
and any remaining projects. Projects already listed as imported are complete. To
undo an import, delete the created projects.
The duplicate-title warning can check active Super Productivity projects only. If a
previously imported project was archived, restore or delete it before re-running the
import so that it can be detected.

View file

@ -416,7 +416,7 @@ const plugins = [
}
assertFilesExist(
targetDir,
['manifest.json', 'plugin.js', 'index.html', 'icon.svg'],
['manifest.json', 'plugin.js', 'index.html', 'icon.svg', 'i18n/en.json'],
'todoist-import',
);
return 'Built and copied to assets';

View file

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

View file

@ -42,6 +42,9 @@ const buildPlugin = async () => {
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/');
};

View file

@ -22,5 +22,8 @@
"allowedHosts": ["api.todoist.com"],
"iFrame": true,
"isSkipMenuEntry": true,
"i18n": {
"languages": ["en"]
},
"icon": "icon.svg"
}

View file

@ -305,6 +305,41 @@ describe('parseSyncResponse', () => {
);
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', () => {
@ -329,7 +364,7 @@ describe('parseSyncResponse', () => {
expect(child?.parentExtId).toBeNull();
});
it('drops non-http(s) attachment URLs from notes', () => {
it('drops unsafe or malformed attachment URLs from notes', () => {
const raw = baseFixture();
raw.items = [{ id: 't1', project_id: 'p1', content: 'a' }];
raw.notes = [
@ -339,9 +374,19 @@ describe('parseSyncResponse', () => {
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');
expect(taskParsed.notes).toBe('Comments:\n- evil\n- malformed');
expect(taskParsed.attachmentCount).toBe(0);
});
it('rejects non-finite or absurd durations', () => {
@ -367,6 +412,7 @@ describe('parseSyncResponse', () => {
it('clamps oversized titles and notes', () => {
const raw = baseFixture();
raw.projects![0].name = 'p'.repeat(5000);
raw.items = [
{
id: 't1',
@ -375,9 +421,12 @@ describe('parseSyncResponse', () => {
description: 'y'.repeat(100_000),
},
];
const [taskParsed] = parseSyncResponse(raw).tasks;
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);
});
});

View file

@ -85,6 +85,24 @@ 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);
@ -126,6 +144,18 @@ export const mergeSyncResponses = (
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 =>
@ -166,7 +196,8 @@ const buildNotes = (
item: RawItem,
comments: RawNote[],
deadlineNoted: string | null,
): string => {
strings: ParseStrings,
): { notes: string; truncatedFieldCount: number } => {
const parts: string[] = [];
const description = asStr(item.description).trim();
if (description) {
@ -174,10 +205,10 @@ const buildNotes = (
}
const extras: string[] = [];
if (item.due?.is_recurring && asStr(item.due.string)) {
extras.push(`Repeats: ${asStr(item.due.string)}`);
extras.push(strings.repeats(asStr(item.due.string)));
}
if (deadlineNoted) {
extras.push(`Deadline: ${deadlineNoted}`);
extras.push(strings.deadline(deadlineNoted));
}
if (extras.length) {
parts.push(extras.join('\n'));
@ -188,14 +219,18 @@ const buildNotes = (
const att = c.file_attachment;
// scheme-filter remote URLs before they land in markdown-rendered notes
const url = asStr(att?.file_url);
const attLine = /^https?:\/\//i.test(url)
? ` ${asStr(att?.file_name) || 'file'}: ${url}`
const attLine = isSupportedAttachmentUrl(url)
? ` ${asStr(att?.file_name) || strings.file}: ${url}`
: '';
return `- ${content}${attLine}`.trimEnd();
});
parts.push(`Comments:\n${lines.join('\n')}`);
parts.push(`${strings.comments}\n${lines.join('\n')}`);
}
return clamp(parts.join('\n\n'), MAX_NOTES_LEN);
const notes = parts.join('\n\n');
return {
notes: clamp(notes, MAX_NOTES_LEN),
truncatedFieldCount: notes.length > MAX_NOTES_LEN ? 1 : 0,
};
};
/**
@ -207,7 +242,10 @@ const buildNotes = (
* 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): TodoistImportModel => {
export const parseSyncResponse = (
raw: RawSyncResponse,
strings: ParseStrings = DEFAULT_PARSE_STRINGS,
): TodoistImportModel => {
const projects: TodoistProject[] = [];
const keptProjectIds = new Set<string>();
@ -216,13 +254,15 @@ export const parseSyncResponse = (raw: RawSyncResponse): TodoistImportModel => {
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(asStr(p.name).trim(), MAX_TITLE_LEN) || 'Untitled project',
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
@ -369,15 +409,24 @@ export const parseSyncResponse = (raw: RawSyncResponse): TodoistImportModel => {
(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(asStr(item.content).trim(), MAX_TITLE_LEN) || 'Untitled task',
notes: buildNotes(item, commentsByItemId.get(extId) || [], deadlineNoted),
labels: (item.labels || [])
.filter((l) => typeof l === 'string' && l.trim() !== '')
.map((l) => clamp(l, MAX_LABEL_LEN)),
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,
@ -386,9 +435,13 @@ export const parseSyncResponse = (raw: RawSyncResponse): TodoistImportModel => {
wasDemoted: depth >= 2,
isDayDurationSkipped,
hasAssignee: !!asId(item.responsible_uid),
attachmentCount: (commentsByItemId.get(extId) || []).filter(
(c) => !!c.file_attachment?.file_url,
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,
};
};

View file

@ -10,6 +10,8 @@ export interface TodoistProject {
parentExtId: string | null;
isInbox: boolean;
childOrder: number;
/** number of imported values shortened to the parser's safe limits */
truncatedFieldCount?: number;
}
export interface TodoistSection {
@ -42,6 +44,8 @@ export interface TodoistTask {
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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { PluginAPI as PluginApiType, Project } from '@super-productivity/plugin-api';
import { parseSyncResponse } from '../parse/from-api';
import { parseSyncResponse, ParseStrings } from '../parse/from-api';
import { loadTodoistData } from '../parse/load-todoist-data';
import { TodoistImportModel } from '../parse/normalized-model';
import {
@ -10,6 +10,8 @@ import {
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 {
@ -43,46 +45,59 @@ const render = (...children: HTMLElement[]): void => {
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: 'Todoist API token',
placeholder: t('TOKEN.PLACEHOLDER'),
autocomplete: 'off',
value: tokenValue || '',
});
const fetchBtn = el('button', { text: 'Load preview' });
const errorLine = errorMsg ? [el('p', { className: 'error', text: errorMsg })] : [];
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('Please paste your Todoist API token first.');
renderTokenStep(t('TOKEN.REQUIRED'));
return;
}
render(
el('h2', { text: 'Import from Todoist' }),
el('p', { text: 'Loading your Todoist data…' }),
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 || {});
const model = parseSyncResponse(raw || {}, parseStrings());
if (!model.projects.length) {
renderTokenStep('No active projects found for this Todoist account.', token);
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(
`Could not load data from Todoist: ${msg} — check the token and your ` +
'connection. If this keeps failing in the browser, try the desktop app.',
tokenInput.value,
);
renderTokenStep(t('ERROR.LOAD_FAILED', { error: msg }), tokenInput.value);
}
};
fetchBtn.addEventListener('click', () => void submit());
@ -93,21 +108,12 @@ const renderTokenStep = (errorMsg?: string, tokenValue?: string): void => {
});
render(
el('h2', { text: 'Import from Todoist' }),
el('p', {
text:
'Brings your active Todoist projects, tasks, sub-tasks, labels and due ' +
'dates into Super Productivity. The import only adds data — nothing of ' +
'your existing Super Productivity data is changed or removed.',
}),
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:
'Find the token in Todoist under Settings → Integrations → Developer. ' +
'It is sent only to api.todoist.com and never stored.',
}),
el('p', { className: 'muted', text: t('TOKEN.HELP') }),
el('div', { className: 'actions' }, [fetchBtn]),
);
};
@ -116,53 +122,6 @@ const renderTokenStep = (errorMsg?: string, tokenValue?: string): void => {
// Step 2 — preview with per-project selection
// ---------------------------------------------------------------------------
const buildLossyNotes = (
model: TodoistImportModel,
selected: ReadonlySet<string>,
): string[] => {
const tasks = model.tasks.filter((t) => selected.has(t.projectExtId));
const notes: string[] = [];
const sectionCount = model.sections.filter((s) => selected.has(s.projectExtId)).length;
const demoted = tasks.filter((t) => t.wasDemoted).length;
const dayDurations = tasks.filter((t) => t.isDayDurationSkipped).length;
const subtaskLabels = tasks.filter((t) => t.parentExtId && t.labels.length).length;
const assignees = tasks.filter((t) => t.hasAssignee).length;
const recurring = tasks.filter((t) => t.isRecurring).length;
const attachments = tasks.reduce((sum, t) => sum + t.attachmentCount, 0);
if (sectionCount) {
notes.push(
`${sectionCount} sections are dropped (tasks keep their order in the project).`,
);
}
if (demoted) {
notes.push(
`${demoted} deeply nested sub-tasks become direct sub-tasks (2 levels max).`,
);
}
if (recurring) {
notes.push(
`${recurring} recurring tasks keep their next date; the recurrence rule is noted in the task notes.`,
);
}
if (dayDurations) {
notes.push(`${dayDurations} full-day durations are not imported.`);
}
if (subtaskLabels) {
notes.push(`${subtaskLabels} sub-tasks lose their labels (sub-tasks have no tags).`);
}
if (assignees) {
notes.push(
`${assignees} tasks are assigned to collaborators; assignees are dropped.`,
);
}
if (attachments) {
notes.push(`${attachments} comment attachments keep their link but no file.`);
}
notes.push('Completed tasks and reminders are not imported.');
return notes;
};
const renderPreviewStep = (
model: TodoistImportModel,
existingProjects: Project[],
@ -200,7 +159,9 @@ const renderPreviewStep = (
const refreshLossyList = (): void => {
lossyList.replaceChildren(
...buildLossyNotes(model, selectedIds()).map((text) => el('li', { text })),
...buildLossyNotes(model, selectedIds(), selectedPriorityMapping()).map((note) =>
el('li', { text: t(note.key, note.params) }),
),
);
};
@ -213,52 +174,57 @@ const renderPreviewStep = (
const checkbox = el('input', { type: 'checkbox', checked: !collides });
checkbox.addEventListener('change', refreshLossyList);
checkboxByExtId.set(project.extId, checkbox);
const countText = `${rootCount} tasks${subCount ? ` (${subCount} sub-tasks)` : ''}`;
return el('label', {}, [
checkbox,
` ${title}${countText}`,
` ${t('PREVIEW.PROJECT_COUNTS', {
title,
taskCount: rootCount,
subTaskCount: subCount,
})}`,
...(collides
? [
el('span', {
className: 'warn',
text: ' already exists — possibly imported before',
text: `${t('PREVIEW.ALREADY_EXISTS')}`,
}),
]
: []),
]);
});
const importBtn = el('button', { text: 'Import' });
const backBtn = el('button', { text: 'Back' });
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: 'Select at least one project to import.', type: 'WARNING' });
api().showSnack({ msg: t('PREVIEW.SELECT_PROJECT'), type: 'WARNING' });
return;
}
const priorityMapping = selectedPriorityMapping();
const plan = planImport(model, {
priorityMapping: selectedPriorityMapping(),
priorityMapping,
selectedProjectExtIds: selected,
});
void executeImport(plan, buildLossyNotes(model, selected));
void executeImport(plan, buildLossyNotes(model, selected, priorityMapping));
});
refreshLossyList();
render(
el('h2', { text: 'Preview' }),
el('p', { text: 'Choose the projects to import:' }),
el('h1', { text: t('TITLE.PREVIEW') }),
el('p', { text: t('PREVIEW.CHOOSE_PROJECTS') }),
el('div', {}, projectRows),
el('div', {}, [
el('p', { text: 'Map Todoist priorities to:' }),
el('label', {}, [priorityNoneRadio, ' Nothing']),
el('label', {}, [priorityTagsRadio, ' p1p3 tags (p4 stays untagged)']),
el('label', {}, [
priorityEisenhowerRadio,
' Eisenhower matrix — urgent / important tags',
]),
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('h3', { text: 'What will not survive the move' }),
el('h2', { text: t('PREVIEW.LOSS_HEADING') }),
lossyList,
el('div', { className: 'actions' }, [importBtn, backBtn]),
);
@ -268,46 +234,62 @@ const renderPreviewStep = (
// Step 3 + 4 — import progress and summary
// ---------------------------------------------------------------------------
const executeImport = async (plan: ImportPlan, lossyNotes: string[]): Promise<void> => {
const progressLine = el('p', { text: 'Starting…' });
render(el('h2', { text: 'Importing…' }), progressLine);
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
? ` — applying dates & tags ${Math.min((progress.detailIndex ?? 0) + 1, progress.detailTotal)}/${progress.detailTotal}`
: ` (${progress.phase})`;
progressLine.textContent = `Project ${progress.projectIndex + 1} of ${progress.totalProjects}: ${progress.projectTitle}${detail}`;
? 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 subText = p.plannedSubTaskCount
? `, ${p.landedSubTaskCount} of ${p.plannedSubTaskCount} sub-tasks`
: '';
const isShortfall =
p.landedTaskCount < p.plannedTaskCount ||
p.landedSubTaskCount < p.plannedSubTaskCount;
return el('li', {
className: isShortfall ? 'warn' : '',
text:
`${p.title}: ${p.landedTaskCount} of ${p.plannedTaskCount} tasks${subText}` +
(isShortfall ? ' — some items did not land, please review' : ''),
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: string[]): void => {
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
? `Import stopped at “${result.failedProjectTitle}”: ${result.errorMessage}. ` +
`That project was created only partially — delete “${result.failedProjectTitle}` +
'before re-running, then select it and the remaining projects again.'
: `Import failed: ${result.errorMessage}`,
? t('ERROR.IMPORT_STOPPED', {
project: result.failedProjectTitle,
error: result.errorMessage,
})
: t('ERROR.IMPORT_FAILED', { error: result.errorMessage }),
}),
]
: [];
@ -315,29 +297,38 @@ const renderSummaryStep = (result: ImportResult, lossyNotes: string[]): void =>
? [
el('p', {
className: 'warn',
text: 'Could not verify the imported counts — the numbers above may show 0 even for tasks that landed.',
role: 'status',
text: t('SUMMARY.UNVERIFIED'),
}),
]
: [];
const tagLine = result.createdTagTitles.length
? [el('p', { text: `Created tags: ${result.createdTagTitles.join(', ')}` })]
? [
el('p', {
text: t('SUMMARY.CREATED_TAGS', {
tags: result.createdTagTitles.join(', '),
}),
}),
]
: [];
const lossy = lossyNotes.length
? [
el('h3', { text: 'Not carried over' }),
el('h2', { text: t('SUMMARY.NOT_CARRIED_OVER') }),
el(
'ul',
{},
lossyNotes.map((text) => el('li', { text })),
lossyNotes.map((note) => el('li', { text: t(note.key, note.params) })),
),
]
: [];
if (!result.errorMessage) {
api().showSnack({ msg: 'Todoist import finished', type: 'SUCCESS' });
api().showSnack({ msg: t('SUMMARY.SNACK_FINISHED'), type: 'SUCCESS' });
}
render(
el('h2', { text: result.errorMessage ? 'Import incomplete' : 'Import finished' }),
el('h1', {
text: t(result.errorMessage ? 'TITLE.INCOMPLETE' : 'TITLE.FINISHED'),
}),
el('ul', {}, items),
...unverified,
...tagLine,
@ -345,15 +336,20 @@ const renderSummaryStep = (result: ImportResult, lossyNotes: string[]): void =>
...lossy,
el('p', {
className: 'muted',
text: 'The import is additive — to undo it, delete the created projects.',
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.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => renderTokenStep());
} else {
const start = async (): Promise<void> => {
await loadTranslations(api());
renderTokenStep();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void start());
} else {
void start();
}