All checks were successful
Deploy production / deploy (push) Successful in 2m33s
80 lines
3.2 KiB
TypeScript
80 lines
3.2 KiB
TypeScript
/**
|
|
* 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 <out.json> [--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<Json> {
|
|
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<string, Json>();
|
|
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<userId>).
|
|
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<void> {
|
|
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)` : '')
|
|
);
|