Files
website/src/lib/server/ticktick.ts
2026-10-24 03:28:14 +00:00

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 };
}