Merge remote-tracking branch 'origin/master' into feat/sync-55222e

* origin/master: (25 commits)
  refactor(tasks): extract shared task ordering helpers (#8926)
  feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
  refactor(config): reduce duplicated form and selector boilerplate (#8928)
  feat(work-view): show break time today (#8909)
  feat(tasks): navigate from empty add-subtask input (#8916)
  docs(development): add instructions for setting up local tests with Chromium (#8887)
  chore(lint): remove unused eslint-disable directives (#8913)
  fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
  chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
  fix(app): hide donation page on macOS (#8915)
  chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
  fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
  feat: add Home Assistant Bridge to community plugins (#8891)
  docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
  18.14.0
  fix(tasks): remove postal-mime dep and harden eml import (#8901)
  style(tasks): elevate add-subtask input
  fix(config): restore day-start offset after operation replay (#8899)
  fix(task-repeat): allow selecting day-of-month recurrence (#8896)
  feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
  ...

# Conflicts:
#	docs/wiki/3.06-User-Data.md
#	src/app/op-log/backup/backup.service.spec.ts
#	src/app/op-log/backup/backup.service.ts
#	src/app/op-log/sync/conflict-resolution.service.ts
This commit is contained in:
Johannes Millan 2026-07-11 18:50:32 +02:00
commit 291995f343
240 changed files with 18316 additions and 3104 deletions

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 272 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -127,6 +127,7 @@ export type {
ConflictResolutionSuggestion,
EntityConflictLike,
LwwConflictResolutionPlan,
LwwConflictResolutionReason,
LwwResolvedConflict,
} from './conflict-resolution';