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

This commit is contained in:
2026-10-24 03:05:05 +00:00
parent 9d08e6af23
commit 6a127de950
11 changed files with 15974 additions and 52 deletions

View 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)` : '')
);

View 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();
}