Tasks: TickTick history sync (export + idempotent import, start_at/time_zone); trackers ordered tasks, money, books with single-noun names
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
This commit is contained in:
79
scripts/ticktick-export.ts
Normal file
79
scripts/ticktick-export.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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)` : '')
|
||||
);
|
||||
49
scripts/ticktick-import.ts
Normal file
49
scripts/ticktick-import.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Sync a TickTick export (scripts/ticktick-export.ts) into the private `tasks`
|
||||
* schema. Idempotent; rerun after a new export to pull in changes.
|
||||
*
|
||||
* bun scripts/ticktick-import.ts [--dry-run] ~/.cache/ticktick-export/all.json
|
||||
*
|
||||
* Links listed in data/tasks/ticktick-merges.json (plus exact title matches, see
|
||||
* syncTickTick) attach a TickTick task to an existing task instead of duplicating it.
|
||||
* Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { syncTickTick, type TtExport, type TtMerge } from '../src/lib/server/ticktick';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const files = args.filter((a) => !a.startsWith('--'));
|
||||
if (files.length !== 1) {
|
||||
console.error('usage: bun scripts/ticktick-import.ts [--dry-run] <export.json>');
|
||||
process.exit(2);
|
||||
}
|
||||
const data = JSON.parse(await readFile(files[0], 'utf8')) as TtExport;
|
||||
const merges = JSON.parse(
|
||||
await readFile(new URL('../data/tasks/ticktick-merges.json', import.meta.url), 'utf8')
|
||||
) as TtMerge[];
|
||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||
class DryRun extends Error {}
|
||||
try {
|
||||
const db = drizzle({ client });
|
||||
let report;
|
||||
try {
|
||||
report = await db.transaction(async (tx) => {
|
||||
const r = await syncTickTick(tx, data, { merges });
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
throw new DryRun();
|
||||
}
|
||||
return r;
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} catch (e) {
|
||||
if (!(e instanceof DryRun)) throw e;
|
||||
console.log('(dry run, rolled back)');
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
Reference in New Issue
Block a user