From 08bac9cb271d010661c7cb0025a98abd2f6d868a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 10 Jul 2026 12:55:30 +0200 Subject: [PATCH] fix(plugins): improve Todoist import feedback --- docs/plans/2026-07-09-todoist-import.md | 24 +- docs/wiki/2.20-Import-from-Todoist.md | 18 +- packages/plugin-dev/scripts/build-all.js | 2 +- .../plugin-dev/todoist-import/i18n/en.json | 76 ++++++ .../todoist-import/scripts/build.js | 3 + .../todoist-import/src/manifest.json | 3 + .../todoist-import/src/parse/from-api.spec.ts | 55 +++- .../todoist-import/src/parse/from-api.ts | 85 +++++-- .../src/parse/normalized-model.ts | 4 + .../src/ui/build-lossy-notes.spec.ts | 100 ++++++++ .../src/ui/build-lossy-notes.ts | 61 +++++ .../todoist-import/src/ui/i18n.spec.ts | 22 ++ .../plugin-dev/todoist-import/src/ui/i18n.ts | 47 ++++ .../todoist-import/src/ui/index.html | 24 +- .../plugin-dev/todoist-import/src/ui/main.ts | 236 +++++++++--------- 15 files changed, 596 insertions(+), 164 deletions(-) create mode 100644 packages/plugin-dev/todoist-import/i18n/en.json create mode 100644 packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts create mode 100644 packages/plugin-dev/todoist-import/src/ui/i18n.ts diff --git a/docs/plans/2026-07-09-todoist-import.md b/docs/plans/2026-07-09-todoist-import.md index bdf238c3d7..bf0a523a40 100644 --- a/docs/plans/2026-07-09-todoist-import.md +++ b/docs/plans/2026-07-09-todoist-import.md @@ -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 diff --git a/docs/wiki/2.20-Import-from-Todoist.md b/docs/wiki/2.20-Import-from-Todoist.md index 02883e902e..6eeb1b7961 100644 --- a/docs/wiki/2.20-Import-from-Todoist.md +++ b/docs/wiki/2.20-Import-from-Todoist.md @@ -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. diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index 9d42977438..08f9b73fff 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -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'; diff --git a/packages/plugin-dev/todoist-import/i18n/en.json b/packages/plugin-dev/todoist-import/i18n/en.json new file mode 100644 index 0000000000..406695ad9f --- /dev/null +++ b/packages/plugin-dev/todoist-import/i18n/en.json @@ -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" + } +} diff --git a/packages/plugin-dev/todoist-import/scripts/build.js b/packages/plugin-dev/todoist-import/scripts/build.js index a2107a00b3..23e3688a53 100644 --- a/packages/plugin-dev/todoist-import/scripts/build.js +++ b/packages/plugin-dev/todoist-import/scripts/build.js @@ -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/'); }; diff --git a/packages/plugin-dev/todoist-import/src/manifest.json b/packages/plugin-dev/todoist-import/src/manifest.json index 9f92962ac8..4cce2849ea 100644 --- a/packages/plugin-dev/todoist-import/src/manifest.json +++ b/packages/plugin-dev/todoist-import/src/manifest.json @@ -22,5 +22,8 @@ "allowedHosts": ["api.todoist.com"], "iFrame": true, "isSkipMenuEntry": true, + "i18n": { + "languages": ["en"] + }, "icon": "icon.svg" } diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts index 5a5fdea119..1c5ea36cfb 100644 --- a/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.spec.ts @@ -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); }); }); diff --git a/packages/plugin-dev/todoist-import/src/parse/from-api.ts b/packages/plugin-dev/todoist-import/src/parse/from-api.ts index eec8b37d91..7d70b4346a 100644 --- a/packages/plugin-dev/todoist-import/src/parse/from-api.ts +++ b/packages/plugin-dev/todoist-import/src/parse/from-api.ts @@ -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(); @@ -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, }; }; diff --git a/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts index 75d32520df..444b1d2966 100644 --- a/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts +++ b/packages/plugin-dev/todoist-import/src/parse/normalized-model.ts @@ -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 { diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts new file mode 100644 index 0000000000..7909ef2c35 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.spec.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts new file mode 100644 index 0000000000..eb0f37db2a --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/build-lossy-notes.ts @@ -0,0 +1,61 @@ +import { PriorityMapping } from '../map/plan-import'; +import { TodoistImportModel } from '../parse/normalized-model'; + +export interface LossNote { + key: string; + params?: Record; +} + +export const buildLossyNotes = ( + model: TodoistImportModel, + selected: ReadonlySet, + 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; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts new file mode 100644 index 0000000000..f08133e110 --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.spec.ts @@ -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'); + }); +}); diff --git a/packages/plugin-dev/todoist-import/src/ui/i18n.ts b/packages/plugin-dev/todoist-import/src/ui/i18n.ts new file mode 100644 index 0000000000..d39134830c --- /dev/null +++ b/packages/plugin-dev/todoist-import/src/ui/i18n.ts @@ -0,0 +1,47 @@ +import english from '../../i18n/en.json'; + +type TranslationParams = Record; + +interface TranslationApi { + translate: (key: string, params?: TranslationParams) => string | Promise; +} + +const flatten = (value: Record, prefix = ''): Record => { + const result: Record = {}; + 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, fullKey)); + } + } + return result; +}; + +const englishByKey = flatten(english); +let translatedByKey: Record = { ...englishByKey }; + +export const loadTranslations = async (api: TranslationApi): Promise => { + 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; +}; diff --git a/packages/plugin-dev/todoist-import/src/ui/index.html b/packages/plugin-dev/todoist-import/src/ui/index.html index 67cc2ff421..1202698d06 100644 --- a/packages/plugin-dev/todoist-import/src/ui/index.html +++ b/packages/plugin-dev/todoist-import/src/ui/index.html @@ -3,47 +3,51 @@ Todoist Import + -
+
diff --git a/packages/plugin-dev/todoist-import/src/ui/main.ts b/packages/plugin-dev/todoist-import/src/ui/main.ts index 0a4bd21d3c..a1786b2b49 100644 --- a/packages/plugin-dev/todoist-import/src/ui/main.ts +++ b/packages/plugin-dev/todoist-import/src/ui/main.ts @@ -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 => { 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[] => { - 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, ' p1–p3 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 => { - const progressLine = el('p', { text: 'Starting…' }); - render(el('h2', { text: 'Importing…' }), progressLine); +const executeImport = async (plan: ImportPlan, lossyNotes: LossNote[]): Promise => { + 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 ; 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 => { + await loadTranslations(api()); renderTokenStep(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => void start()); +} else { + void start(); }