All checks were successful
Deploy production / deploy (push) Successful in 2m29s
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
// 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<TaskStatus, number> = { 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<any, any>;
|
|
|
|
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<Record<TaskStatus, number>>;
|
|
newByArea: Record<string, number>;
|
|
byStatus: Record<TaskStatus, number>;
|
|
byArea: Record<string, number>;
|
|
range: { created: [string, string] | null; completed: [string, string] | null };
|
|
};
|
|
|
|
/**
|
|
* Upsert a TickTick export (idempotent). Rows are keyed on external_id = TickTick id
|
|
* (checklist items: "<taskId>:<itemId>"). 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<SyncReport> {
|
|
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<string, number>();
|
|
const [{ maxSort }] = await tx
|
|
.select({ maxSort: sql<number>`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<string, number>();
|
|
const perProject = new Map<string, MappedRow[]>();
|
|
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<string, typeof candidates>();
|
|
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<number>();
|
|
|
|
const idFor = new Map<string, number>();
|
|
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<number>`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<string, string[]>();
|
|
const k2 = new Map<string, string[]>();
|
|
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<string>();
|
|
const take = (m: Map<string, string[]>, 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<string, string>(); // csv taskId → API id or csv key
|
|
const keyCount = new Map<string, number>();
|
|
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 };
|
|
}
|