mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(plugins): improve Todoist import feedback
This commit is contained in:
parent
59f011ee34
commit
08bac9cb27
15 changed files with 596 additions and 164 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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/');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,5 +22,8 @@
|
|||
"allowedHosts": ["api.todoist.com"],
|
||||
"iFrame": true,
|
||||
"isSkipMenuEntry": true,
|
||||
"i18n": {
|
||||
"languages": ["en"]
|
||||
},
|
||||
"icon": "icon.svg"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, ' 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<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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue