// TickTick → tasks schema sync. Pure mapping + an idempotent sync, shared by // scripts/ticktick-import.ts and its tests; must not import anything from SvelteKit. // Input is the JSON written by scripts/ticktick-export.ts (Open API v1 task objects). import { createHash } from 'node:crypto'; import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'; import type { PgAsyncDatabase } from 'drizzle-orm/pg-core'; import { todoActivity, todoAreas, todoTasks, type TaskStatus } from './db/schema/tasks'; import { TIME_ZONE } from './tasks'; export const TICKTICK_SOURCE = 'ticktick'; export type TtItem = { id: string; title?: string; status?: number; completedTime?: string | number; sortOrder?: number; startDate?: string; isAllDay?: boolean; timeZone?: string; }; export type TtTask = { id: string; projectId: string; title?: string; content?: string; desc?: string; status?: number; priority?: number; tags?: string[]; dueDate?: string; startDate?: string; isAllDay?: boolean; timeZone?: string; repeatFlag?: string; reminders?: string[]; items?: TtItem[]; parentId?: string; sortOrder?: number; createdTime?: string; modifiedTime?: string; completedTime?: string; }; export type TtProject = { id: string; name: string; closed?: boolean }; export type TtExport = { projects: TtProject[]; tasks: TtTask[]; inboxId?: string }; /** Hand-picked links: a TickTick task that is clearly the same as an existing task. */ export type TtMerge = { ticktickId: string; title: string }; /** TickTick timestamps look like 2024-06-11T17:30:36.000+0000. */ export function ttDate(v: string | number | null | undefined): Date | null { if (v === null || v === undefined || v === '') return null; const d = typeof v === 'number' ? new Date(v) : new Date(v.replace(/([+-]\d{2})(\d{2})$/, '$1:$2')); return Number.isNaN(d.getTime()) ? null : d; } const parts = (d: Date, timeZone: string) => { const f = new Intl.DateTimeFormat('en-GB', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(d); const g = (t: string) => f.find((p) => p.type === t)?.value ?? ''; return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` }; }; /** * All-day dues are midnight in the task's own zone (keep that calendar date); * timed dues become an Amsterdam wall-clock date + time (due_time is Amsterdam). */ export function ttDue( dueDate: string | undefined, isAllDay: boolean | undefined, timeZone: string | undefined ): { dueDate: string | null; dueTime: string | null } { const d = ttDate(dueDate); if (!d) return { dueDate: null, dueTime: null }; if (isAllDay) return { dueDate: parts(d, validZone(timeZone)).date, dueTime: null }; const p = parts(d, TIME_ZONE); return { dueDate: p.date, dueTime: p.time }; } function validZone(z: string | undefined) { if (!z) return TIME_ZONE; try { new Intl.DateTimeFormat('en', { timeZone: z }); return z; } catch { return TIME_ZONE; } } /** First reminder: TRIGGER:PT0S / -PT60M / P0DT9H0M0S (relative to the due moment). */ export function ttReminder(reminders: string[] | undefined, due: Date | null): Date | null { if (!due || !reminders?.length) return null; let best: Date | null = null; for (const r of reminders) { const m = r.match( /^TRIGGER:(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/ ); if (!m) continue; const [, neg, w, d, h, min, s] = m; const ms = ((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86_400 + Number(h ?? 0) * 3600 + Number(min ?? 0) * 60 + Number(s ?? 0)) * 1000; const at = new Date(due.getTime() + (neg ? -ms : ms)); if (!best || at < best) best = at; } return best; } export const ttPriority = (p: number | undefined) => (p === 5 ? 3 : p === 3 ? 2 : p === 1 ? 1 : 0); export const ttStatus = (s: number | undefined): TaskStatus => s === 2 ? 'done' : s === -1 ? 'dropped' : 'todo'; /** TickTick stores "RRULE:FREQ=…"; the tracker keeps the bare RFC 5545 rule. */ export const ttRecurrence = (r: string | undefined) => r?.replace(/^RRULE:/, '').trim() || null; const RANK: Record = { todo: 0, in_progress: 1, dropped: 2, done: 3 }; /** The more complete of two statuses (done > dropped > in progress > to do). */ export const moreComplete = (a: TaskStatus, b: TaskStatus) => (RANK[b] > RANK[a] ? b : a); export const slugify = (s: string) => s .normalize('NFKD') .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') .toLowerCase() || 'list'; export const normTitle = (s: string) => s .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, ' ') .trim(); export type MappedRow = { externalId: string; parentExternalId: string | null; projectId: string; title: string; notes: string | null; status: TaskStatus; priority: number; tags: string[]; dueDate: string | null; dueTime: string | null; startAt: Date | null; timeZone: string | null; reminderAt: Date | null; recurrence: string | null; completedAt: Date | null; createdAt: Date; updatedAt: Date; ttSortOrder: number; }; /** One TickTick task → its row plus one row per checklist item (as subtasks). */ export function mapTask(t: TtTask): MappedRow[] { const status = ttStatus(t.status); const created = ttDate(t.createdTime) ?? ttDate(t.modifiedTime) ?? ttDate(t.completedTime) ?? new Date(0); const modified = ttDate(t.modifiedTime) ?? created; const due = ttDue(t.dueDate, t.isAllDay, t.timeZone); const notes = (t.content?.trim() || t.desc?.trim() || '').slice(0, 50_000) || null; const done = status === 'done' || status === 'dropped'; const row: MappedRow = { externalId: t.id, parentExternalId: t.parentId || null, projectId: t.projectId, title: t.title?.trim() || '(untitled)', notes, status, priority: ttPriority(t.priority), tags: [...new Set((t.tags ?? []).map((x) => x.toLowerCase()))], ...due, startAt: ttDate(t.startDate), timeZone: t.timeZone || null, reminderAt: ttReminder(t.reminders, ttDate(t.dueDate)), recurrence: ttRecurrence(t.repeatFlag), completedAt: done ? (ttDate(t.completedTime) ?? modified) : null, createdAt: created, updatedAt: modified, ttSortOrder: t.sortOrder ?? 0 }; // Some old checklist items carry a placeholder completedTime (2001-01-01): ignore any // completion before the task existed. const itemDone = (v: string | number | undefined) => { const d = ttDate(v); return d && d.getTime() >= created.getTime() - 86_400_000 ? d : null; }; const items = (t.items ?? []).map((i): MappedRow => { // Checklist status: 0 open, 1 (or 2) checked. Unchecked items of a finished task // follow the task (done → done, won't do → dropped) so they don't resurface as open. const checked = (i.status ?? 0) !== 0 || status === 'done'; return { externalId: `${t.id}:${i.id}`, parentExternalId: t.id, projectId: t.projectId, title: i.title?.trim() || '(untitled)', notes: null, status: checked ? 'done' : status === 'dropped' ? 'dropped' : 'todo', priority: 0, tags: [], ...ttDue(i.startDate, i.isAllDay ?? true, i.timeZone ?? t.timeZone), startAt: null, timeZone: i.timeZone || t.timeZone || null, reminderAt: null, recurrence: null, completedAt: checked ? (itemDone(i.completedTime) ?? row.completedAt ?? modified) : status === 'dropped' ? row.completedAt : null, createdAt: created, updatedAt: modified, ttSortOrder: i.sortOrder ?? 0 }; }); return [row, ...items]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any type Db = PgAsyncDatabase; export type SyncReport = { areas: { inserted: string[]; existing: string[] }; tasks: { inserted: number; updated: number; unchanged: number; subtasks: number }; merged: { ticktick: string; task: string; status: TaskStatus }[]; /** Rows inserted by this run. */ newByStatus: Partial>; newByArea: Record; byStatus: Record; byArea: Record; range: { created: [string, string] | null; completed: [string, string] | null }; }; /** * Upsert a TickTick export (idempotent). Rows are keyed on external_id = TickTick id * (checklist items: ":"). A TickTick row is only overwritten when * TickTick modified it after our last update, so edits made on the site win until * the task changes in TickTick again. Links to existing (e.g. daisy-import) tasks: * the explicit `merges` list, plus an exact title match when the TickTick task is * still open or was finished after that task was created. A linked task keeps its * own fields; only its status moves to the more complete of the two. */ export async function syncTickTick( db: Db, data: TtExport, opts: { merges?: TtMerge[] } = {} ): Promise { const report: SyncReport = { areas: { inserted: [], existing: [] }, tasks: { inserted: 0, updated: 0, unchanged: 0, subtasks: 0 }, merged: [], newByStatus: {}, newByArea: {}, byStatus: { todo: 0, in_progress: 0, done: 0, dropped: 0 }, byArea: {}, range: { created: null, completed: null } }; const rows = data.tasks.flatMap(mapTask); report.tasks.subtasks = rows.filter((r) => r.parentExternalId).length; return db.transaction(async (tx) => { // Areas: one per TickTick list. The inbox and any list no longer returned // (closed/deleted) get their own area. const projectName = new Map(data.projects.map((p) => [p.id, p.name])); const areaFor = new Map(); const [{ maxSort }] = await tx .select({ maxSort: sql`coalesce(max(${todoAreas.sortOrder}), 0)` }) .from(todoAreas); let sort = Number(maxSort); for (const pid of new Set(rows.map((r) => r.projectId))) { const isInbox = pid.startsWith('inbox'); const raw = isInbox ? 'Inbox' : (projectName.get(pid) ?? `TickTick list ${pid.slice(-6)}`); const name = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim() || raw; const slug = slugify(name); const [found] = await tx.select().from(todoAreas).where(eq(todoAreas.slug, slug)); if (found) { areaFor.set(pid, found.id); report.areas.existing.push(slug); continue; } const [ins] = await tx .insert(todoAreas) .values({ slug, name, description: isInbox ? 'TickTick inbox' : `TickTick list "${raw}"`, sortOrder: (sort += 10) }) .returning({ id: todoAreas.id }); areaFor.set(pid, ins.id); report.areas.inserted.push(slug); } // Site sort order: rank by TickTick sortOrder within each list (TickTick values overflow int4). const siteSort = new Map(); const perProject = new Map(); for (const r of rows) { const list = perProject.get(r.projectId) ?? []; list.push(r); perProject.set(r.projectId, list); } for (const list of perProject.values()) list .sort( (a, b) => a.ttSortOrder - b.ttSortOrder || a.createdAt.getTime() - b.createdAt.getTime() ) .forEach((r, i) => siteSort.set(r.externalId, (i + 1) * 10)); const existing = new Map( ( await tx .select({ id: todoTasks.id, source: todoTasks.source, externalId: todoTasks.externalId, status: todoTasks.status, updatedAt: todoTasks.updatedAt }) .from(todoTasks) .where(isNotNull(todoTasks.externalId)) ).map((r) => [r.externalId!, r]) ); // Unlinked non-TickTick tasks are merge candidates. const candidates = await tx .select({ id: todoTasks.id, title: todoTasks.title, status: todoTasks.status, createdAt: todoTasks.createdAt }) .from(todoTasks) .where( and( isNull(todoTasks.externalId), sql`coalesce(${todoTasks.source}, '') <> ${TICKTICK_SOURCE}` ) ); const byTitle = new Map(); for (const c of candidates) { const k = normTitle(c.title); byTitle.set(k, [...(byTitle.get(k) ?? []), c]); } const explicit = new Map((opts.merges ?? []).map((m) => [m.ticktickId, normTitle(m.title)])); const linked = new Set(); const idFor = new Map(); const activity: (typeof todoActivity.$inferInsert)[] = []; for (const r of rows) { let prev = existing.get(r.externalId); // A task first imported from the backup CSV (csv:… key) that the API now returns: // move it onto the API id instead of inserting a duplicate. if (!prev && !r.externalId.startsWith('csv:') && !r.parentExternalId) { const alias = existing.get( csvKey(listNameOf(data.projects, r.projectId), r.title, r.createdAt) ); if (alias && alias.source === TICKTICK_SOURCE) { await tx .update(todoTasks) .set({ externalId: r.externalId }) .where(eq(todoTasks.id, alias.id)); existing.delete(alias.externalId!); prev = { ...alias, externalId: r.externalId }; existing.set(r.externalId, prev); } } const fields = { title: r.title, notes: r.notes, status: r.status, priority: r.priority, tags: r.tags, dueDate: r.dueDate, dueTime: r.dueTime, startAt: r.startAt, timeZone: r.timeZone, reminderAt: r.reminderAt, recurrence: r.recurrence, completedAt: r.completedAt, areaId: areaFor.get(r.projectId) ?? null }; if (prev && prev.source === TICKTICK_SOURCE) { idFor.set(r.externalId, prev.id); if (r.updatedAt > prev.updatedAt) { await tx.update(todoTasks).set(fields).where(eq(todoTasks.id, prev.id)); if (prev.status !== r.status) activity.push({ taskId: prev.id, kind: 'status', fromStatus: prev.status, toStatus: r.status, note: 'TickTick sync', createdAt: r.updatedAt }); report.tasks.updated++; } else report.tasks.unchanged++; continue; } if (prev) { // Linked earlier to a task from another source: only upgrade its status. idFor.set(r.externalId, prev.id); const next = moreComplete(prev.status, r.status); if (next !== prev.status) { await tx .update(todoTasks) .set({ status: next, completedAt: r.completedAt }) .where(eq(todoTasks.id, prev.id)); report.tasks.updated++; } else report.tasks.unchanged++; continue; } if (!r.parentExternalId) { const want = explicit.get(r.externalId); const pool = (want ? byTitle.get(want) : byTitle.get(normTitle(r.title))) ?? []; const match = pool.filter( (c) => !linked.has(c.id) && (want || r.status === 'todo' || (r.completedAt && r.completedAt >= c.createdAt)) ); if (match.length === 1) { const c = match[0]; linked.add(c.id); const next = moreComplete(c.status, r.status); await tx .update(todoTasks) .set({ externalId: r.externalId, status: next, ...(next !== c.status ? { completedAt: r.completedAt } : {}) }) .where(eq(todoTasks.id, c.id)); activity.push({ taskId: c.id, kind: 'note', note: `Linked to TickTick task "${r.title}" (${r.status})` }); idFor.set(r.externalId, c.id); report.merged.push({ ticktick: r.title, task: c.title, status: next }); continue; } } const [ins] = await tx .insert(todoTasks) .values({ ...fields, sortOrder: siteSort.get(r.externalId) ?? 0, source: TICKTICK_SOURCE, externalId: r.externalId, createdAt: r.createdAt, updatedAt: r.updatedAt }) .returning({ id: todoTasks.id }); idFor.set(r.externalId, ins.id); activity.push({ taskId: ins.id, kind: 'created', toStatus: r.status, note: 'Imported (ticktick)', createdAt: r.createdAt }); report.tasks.inserted++; report.newByStatus[r.status] = (report.newByStatus[r.status] ?? 0) + 1; const an = listNameOf(data.projects, r.projectId); report.newByArea[an] = (report.newByArea[an] ?? 0) + 1; } // Parents (TickTick subtasks and checklist items), set after every row exists. for (const r of rows) { if (!r.parentExternalId) continue; const me = idFor.get(r.externalId); const parent = idFor.get(r.parentExternalId); const mine = existing.get(r.externalId); if (!me || !parent || me === parent) continue; if (mine && mine.source !== TICKTICK_SOURCE) continue; await tx .update(todoTasks) .set({ parentId: parent }) .where(and(eq(todoTasks.id, me), sql`${todoTasks.parentId} IS DISTINCT FROM ${parent}`)); } for (let i = 0; i < activity.length; i += 500) await tx.insert(todoActivity).values(activity.slice(i, i + 500)); // Report from the database: everything that came from (or is linked to) TickTick. const stats = await tx .select({ status: todoTasks.status, area: todoAreas.name, n: sql`count(*)::int` }) .from(todoTasks) .leftJoin(todoAreas, eq(todoAreas.id, todoTasks.areaId)) .where(isNotNull(todoTasks.externalId)) .groupBy(todoTasks.status, todoAreas.name); for (const s of stats) { report.byStatus[s.status] += s.n; const a = s.area ?? 'No area'; report.byArea[a] = (report.byArea[a] ?? 0) + s.n; } const iso = (d: Date) => d.toISOString().slice(0, 10); const created = rows .map((r) => r.createdAt) .filter((d) => d.getTime() > 0) .sort((a, b) => +a - +b); const completed = rows .map((r) => r.completedAt) .filter((d): d is Date => !!d) .sort((a, b) => +a - +b); if (created.length) report.range.created = [iso(created[0]), iso(created.at(-1)!)]; if (completed.length) report.range.completed = [iso(completed[0]), iso(completed.at(-1)!)]; return report; }); } // --- TickTick backup CSV (Settings → Backup & Import) -------------------------------- // The backup has local row numbers as taskId, so CSV-only tasks get a stable key: // "csv:" + hash(list, title, created second) (+ ":n" for repeats). Rows that also came // through the API are dropped in favour of the API copy (matched on list + title + // created time, falling back to title + created time). /** Minimal RFC 4180 parser (quoted fields may contain commas, quotes and newlines). */ export function parseCsv(text: string): string[][] { const out: string[][] = []; let row: string[] = []; let field = ''; let quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (quoted) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else quoted = false; } else field += c; } else if (c === '"') quoted = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); out.push(row); row = []; field = ''; } else field += c; } if (field || row.length) { row.push(field); out.push(row); } return out; } export const csvKey = (list: string, title: string, created: Date | null) => 'csv:' + createHash('sha1') .update( `${list}\u0000${title.trim()}\u0000${created ? Math.floor(created.getTime() / 1000) : ''}` ) .digest('hex') .slice(0, 20); export type CsvTask = TtTask & { csvId: string; csvParent: string; listName: string; folder: string; }; /** Backup CSV → API-shaped tasks (ids still unset; see mergeCsv). */ export function parseTickTickCsv(text: string): CsvTask[] { const records = parseCsv(text.replace(/^\uFEFF/, '')); const h = records.findIndex((r) => r[0] === 'Folder Name'); if (h < 0) throw new Error('Not a TickTick backup: no "Folder Name" header row'); const cols = records[h]; const tasks: CsvTask[] = []; for (const rec of records.slice(h + 1)) { if (rec.length < cols.length - 1) continue; const r = Object.fromEntries(cols.map((c, i) => [c, rec[i] ?? ''])); const isChecklist = r['Kind'] === 'CHECKLIST' || r['Is Check list'] === 'Y'; const lines = (r['Content'] ?? '').split('\n'); const items: TtItem[] = []; const noteLines: string[] = []; if (isChecklist) for (const l of lines) { const m = l.match(/^\s*([▫▪])\s*(.*)$/u); if (m) items.push({ id: `item${items.length + 1}`, title: m[2], status: m[1] === '▪' ? 1 : 0 }); else noteLines.push(l); } const num = (s: string) => (s === '' || Number.isNaN(Number(s)) ? undefined : Number(s)); tasks.push({ id: '', csvId: r['taskId'], csvParent: r['parentId'], listName: r['List Name'], folder: r['Folder Name'], projectId: '', title: r['Title'], content: isChecklist ? noteLines.join('\n') : r['Content'], status: num(r['Status']), priority: num(r['Priority']), tags: r['Tags'] ? r['Tags'] .split(',') .map((t) => t.trim()) .filter(Boolean) : [], startDate: r['Start Date'] || undefined, dueDate: r['Due Date'] || undefined, isAllDay: r['Is All Day'] === 'true', timeZone: r['Timezone'] || undefined, repeatFlag: r['Repeat'] || undefined, reminders: r['Reminder'] ? r['Reminder'] .split(',') .map((x) => (x.startsWith('TRIGGER:') ? x : `TRIGGER:${x.trim()}`)) : [], items, sortOrder: num(r['Order']), createdTime: r['Created Time'] || undefined, modifiedTime: r['Completed Time'] || r['Created Time'] || undefined, completedTime: r['Completed Time'] || undefined }); } return tasks; } export const listNameOf = (projects: TtProject[], projectId: string) => projectId.startsWith('inbox') ? 'Inbox' : (projects.find((p) => p.id === projectId)?.name ?? projectId); export type CsvMergeReport = { csvRows: number; matchedListTitleCreated: number; matchedTitleCreated: number; added: number; newLists: string[]; }; /** Add the CSV-only tasks to an API export (returns a new export + what matched). */ export function mergeCsv( data: TtExport, csv: CsvTask[] ): { data: TtExport; report: CsvMergeReport } { const sec = (v: string | undefined) => { const d = ttDate(v); return d ? String(Math.floor(d.getTime() / 1000)) : ''; }; const k1 = new Map(); const k2 = new Map(); for (const t of data.tasks) { const title = (t.title ?? '').trim(); const a = `${listNameOf(data.projects, t.projectId)}\u0000${title}\u0000${sec(t.createdTime)}`; const b = `${title}\u0000${sec(t.createdTime)}`; k1.set(a, [...(k1.get(a) ?? []), t.id]); k2.set(b, [...(k2.get(b) ?? []), t.id]); } const used = new Set(); const take = (m: Map, k: string) => { const id = m.get(k)?.find((x) => !used.has(x)); if (id) used.add(id); return id; }; const report: CsvMergeReport = { csvRows: csv.length, matchedListTitleCreated: 0, matchedTitleCreated: 0, added: 0, newLists: [] }; const projects = [...data.projects]; const inboxId = data.inboxId ?? data.tasks.find((t) => t.projectId.startsWith('inbox'))?.projectId ?? 'inbox'; const projectFor = (list: string, folder: string) => { if (list === 'Inbox') return inboxId; const p = projects.find((x) => x.name === list); if (p) return p.id; const id = `csvlist:${list}`; projects.push({ id, name: folder ? `${folder} / ${list}` : list, closed: true }); report.newLists.push(list); return id; }; const idOf = new Map(); // csv taskId → API id or csv key const keyCount = new Map(); const added: CsvTask[] = []; for (const t of csv) { const title = (t.title ?? '').trim(); let id = take(k1, `${t.listName}\u0000${title}\u0000${sec(t.createdTime)}`); if (id) report.matchedListTitleCreated++; else if ((id = take(k2, `${title}\u0000${sec(t.createdTime)}`))) report.matchedTitleCreated++; if (!id) { const base = csvKey(t.listName, title, ttDate(t.createdTime)); const n = (keyCount.get(base) ?? 0) + 1; keyCount.set(base, n); id = n === 1 ? base : `${base}:${n}`; added.push(t); } idOf.set(t.csvId, id); t.id = id; } const tasks: TtTask[] = [...data.tasks]; for (const t of added) { const { csvId, csvParent, listName, folder, ...task } = t; void csvId; tasks.push({ ...task, projectId: projectFor(listName, folder), parentId: (csvParent && idOf.get(csvParent)) || undefined }); } report.added = added.length; return { data: { ...data, projects, tasks }, report }; }