/** * Export every TickTick task reachable through the official CLI (Open API v1): * open, won't-do and completed tasks of the inbox and every list, plus completed * history month by month (windows are split further when they hit the API's * 200-result cap). Needs `ticktick auth login` done once; never reads the token. * * bun scripts/ticktick-export.ts ~/.cache/ticktick-export/all.json * * Then: bun scripts/ticktick-import.ts ~/.cache/ticktick-export/all.json */ import { $ } from 'bun'; const out = process.argv[2]; if (!out) { console.error('usage: bun scripts/ticktick-export.ts [--since YYYY]'); process.exit(2); } const sinceArg = process.argv.indexOf('--since'); const since = sinceArg > 0 ? Number(process.argv[sinceArg + 1]) : 2015; const CAP = 200; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Json = any; async function tt(...args: string[]): Promise { for (let attempt = 0; ; attempt++) { const r = await $`ticktick ${args} --json`.quiet().nothrow(); if (r.exitCode === 0) return JSON.parse(r.stdout.toString() || '[]'); if (attempt >= 3) throw new Error( `ticktick ${args[0]} ${args[1]} failed: ${r.stderr.toString().slice(0, 300)}` ); await Bun.sleep(2000 * (attempt + 1)); } } const fmt = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, '+0000'); const byId = new Map(); const add = (list: Json[]) => list.filter((t) => !byId.has(t.id) && byId.set(t.id, t)).length; const projects: Json[] = await tt('project', 'list'); // The inbox is not a project; its id shows up on tasks (inbox). const probe: Json[] = await tt('task', 'filter', '--status=0,2,-1'); const inboxId = probe.map((t) => t.projectId).find((p: string) => p?.startsWith('inbox')); add(probe); const ids = [...(inboxId ? [inboxId] : []), ...projects.map((p) => p.id)]; let capped = 0; for (const status of ['0', '-1', '2']) for (const p of ids) { const r = await tt('task', 'filter', '--projects', p, `--status=${status}`); if (r.length >= CAP) capped++; add(r); } for (const p of projects) add((await tt('project', 'data', p.id)).tasks ?? []); async function completed(start: Date, end: Date): Promise { const r = await tt('task', 'completed', '--start-date', fmt(start), '--end-date', fmt(end)); if (r.length >= CAP && end.getTime() - start.getTime() > 3_600_000) { const mid = new Date(Math.floor((start.getTime() + end.getTime()) / 2000) * 1000); await completed(start, mid); await completed(new Date(mid.getTime() + 1000), end); return; } add(r); } const now = new Date(); for (let y = since; y <= now.getUTCFullYear(); y++) for (let m = 0; m < 12; m++) { const start = new Date(Date.UTC(y, m, 1)); if (start > now) break; await completed(start, new Date(Date.UTC(y, m + 1, 1) - 1000)); } const tasks = [...byId.values()]; await Bun.write(out, JSON.stringify({ exportedAt: now.toISOString(), inboxId, projects, tasks })); const count = (s: number) => tasks.filter((t) => t.status === s).length; console.log( `${tasks.length} tasks (${count(0)} open, ${count(2)} completed, ${count(-1)} won't do) from ` + `${projects.length} lists + inbox` + (capped ? `; ${capped} status/list queries hit the ${CAP} cap (older ones may be missing)` : '') );