Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone f6f2112e77bc8c011e38c83a
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/lib/tasks.ts
// Tasks' board logic, shared by the web app and the public API: the stored shapes, limits, colors,
// templates, reading a board description (from a person, an assistant, a Tasks export or a Trello
// export), turning it into writes, card order, due dates and CSV.
export interface Board { title: string; archived: boolean; createdBy: string; createdAt: number }
export interface List { id: string; title: string; pos: number; archived: boolean; createdAt: number }
export interface Card {
id: string;
listId: string;
title: string;
description: string;
pos: number;
/** "" (none), "2026-10-19" or "2026-10-19T15:30", wall-clock time as written. */
due: string;
done: boolean;
/** JSON list of label ids. */
labels: string;
/** JSON list of member ids. */
assignees: string;
/** JSON list of {id, text, done}. */
checklist: string;
cover: string;
archived: boolean;
createdBy: string;
createdAt: number;
}
export interface Label { id: string; name: string; color: string; createdAt: number }
export interface Member { id: string; name: string; removed: boolean; createdAt: number }
export interface Comment { id: string; cardId: string; text: string; by: string; name: string; createdAt: number; editedAt?: number | null }
export interface CheckItem { id: string; text: string; done: boolean }
export const LIMITS = {
boardTitle: 120,
listTitle: 120,
cardTitle: 500,
description: 20000,
checkItems: 100,
checkText: 500,
checklist: 60000,
labelName: 40,
memberName: 40,
comment: 5000,
lists: 100,
cards: 1000,
labels: 50,
members: 100,
commentsPerCard: 200,
comments: 2000,
};
/** Label and cover colors: muted, so a board stays calm. bg is the chip, fg its text, solid the cover strip. */
export const COLORS: Record<string, { name: string; bg: string; fg: string; solid: string }> = {
green: { name: "Green", bg: "#dfeee3", fg: "#2f5a3c", solid: "#7aa585" },
yellow: { name: "Yellow", bg: "#f6ecc9", fg: "#6d5413", solid: "#d6b552" },
orange: { name: "Orange", bg: "#f8e2cc", fg: "#7a4416", solid: "#dc9257" },
red: { name: "Red", bg: "#f6dcd5", fg: "#7c2f1d", solid: "#c86b52" },
pink: { name: "Pink", bg: "#f7dfe9", fg: "#7a2f52", solid: "#d080a3" },
purple: { name: "Purple", bg: "#e8e0f3", fg: "#4b3577", solid: "#8d76ba" },
blue: { name: "Blue", bg: "#dfe7f8", fg: "#1f4597", solid: "#5f86d3" },
sky: { name: "Sky", bg: "#d9eef3", fg: "#1e5866", solid: "#5fa9bb" },
gray: { name: "Gray", bg: "#e8eaee", fg: "#3c4048", solid: "#9ca2ac" },
};
export const COLOR_IDS = Object.keys(COLORS);
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
export function parseIds(raw: string | null | undefined): string[] {
try {
const v = JSON.parse(raw || "[]");
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
} catch {
return [];
}
}
export function parseChecklist(raw: string | null | undefined): CheckItem[] {
try {
const v = JSON.parse(raw || "[]");
if (!Array.isArray(v)) return [];
return v.filter((x) => x && typeof x.text === "string").map((x) => ({ id: String(x.id || randomId(8)), text: x.text, done: Boolean(x.done) }));
} catch {
return [];
}
}
// ---------- Order ----------
const GAP = 1024;
/** A position between two neighbours (either may be missing). */
export function posBetween(before?: number, after?: number): number {
if (before == null && after == null) return GAP;
if (before == null) return (after as number) - GAP;
if (after == null) return before + GAP;
return (before + after) / 2;
}
/** True when two neighbours are too close to fit another card between them, so the list needs renumbering. */
export const tooClose = (a: number, b: number) => Math.abs(b - a) < 1e-6;
export const byPos = <T extends { pos: number; createdAt?: number; id: string }>(a: T, b: T) =>
a.pos - b.pos || (a.createdAt ?? 0) - (b.createdAt ?? 0) || (a.id < b.id ? -1 : 1);
// ---------- Due dates ----------
export const DUE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/;
/** Reads "2026-10-19", "2026-10-19T15:30", a full ISO time (converted to this machine's local time) or "" into the stored form. */
export function readDue(value: unknown): string | null {
if (value == null || value === "") return "";
const s = String(value).trim();
if (DUE_RE.test(s)) {
const d = new Date(`${s.slice(0, 10)}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s.slice(0, 10)) return null;
if (s.length === 16 && (Number(s.slice(11, 13)) > 23 || Number(s.slice(14, 16)) > 59)) return null;
return s;
}
const t = Date.parse(s);
if (!Number.isFinite(t)) return null;
const d = new Date(t);
const p = (n: number) => String(n).padStart(2, "0");
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
const time = `${p(d.getHours())}:${p(d.getMinutes())}`;
return time === "00:00" ? day : `${day}T${time}`;
}
export function localDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function addDays(day: string, n: number): string {
const d = new Date(`${day}T12:00:00`);
d.setDate(d.getDate() + n);
return localDay(d);
}
export type DueState = "none" | "done" | "overdue" | "today" | "soon" | "later";
/** How a due date stands today: overdue, today, within a week, later, done or none. */
export function dueState(due: string, done: boolean, now = new Date()): DueState {
if (!due) return "none";
if (done) return "done";
const today = localDay(now);
const day = due.slice(0, 10);
if (day < today) return "overdue";
if (day === today) {
if (due.length === 16 && due.slice(11) < `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`) return "overdue";
return "today";
}
return day <= addDays(today, 7) ? "soon" : "later";
}
// ---------- Board descriptions (seeds) ----------
export interface CardSeed {
title: string;
description: string;
due: string;
done: boolean;
labels: string[];
assignees: string[];
checklist: { text: string; done: boolean }[];
cover: string;
archived: boolean;
comments: { name: string; text: string; at?: number }[];
}
export interface ListSeed { title: string; archived: boolean; cards: CardSeed[] }
export interface BoardSeed { title: string; labels: { name: string; color: string }[]; members: string[]; lists: ListSeed[] }
export class SeedError extends Error {}
const str = (v: unknown) => (v == null ? "" : String(v)).trim();
const clip = (v: unknown, max: number) => str(v).slice(0, max);
function readNames(raw: unknown, field: string, max: number): string[] {
if (raw == null) return [];
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : null;
if (!list) throw new SeedError(`${field} must be a list of names.`);
const out: string[] = [];
for (const n of list.map(str).filter(Boolean)) {
if (n.length > max) throw new SeedError(`Keep each name in ${field} under ${max} characters.`);
if (!out.some((x) => x.toLowerCase() === n.toLowerCase())) out.push(n);
}
return out;
}
/** A checklist from ["text", ...] or [{text, done}] (a plain string splits on new lines). */
export function readChecklist(raw: unknown): { text: string; done: boolean }[] {
if (raw == null || raw === "") return [];
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split("\n") : null;
if (!list) throw new SeedError(`checklist must be a list, like ["Draft", {"text": "Review", "done": true}].`);
const items = list.map((x) => (typeof x === "string" ? { text: x.trim(), done: false } : { text: str(x?.text ?? x?.name), done: Boolean(x?.done ?? x?.checked) })).filter((x) => x.text);
if (items.length > LIMITS.checkItems) throw new SeedError(`A checklist can have up to ${LIMITS.checkItems} items.`);
if (items.some((x) => x.text.length > LIMITS.checkText)) throw new SeedError(`Keep each checklist item under ${LIMITS.checkText} characters.`);
return items;
}
export function readColor(raw: unknown, fallback = "gray"): string {
const c = str(raw).toLowerCase().replace(/_(dark|light)$/, "");
if (COLORS[c]) return c;
return ({ lime: "green", black: "gray", grey: "gray", teal: "sky", cyan: "sky", violet: "purple" } as Record<string, string>)[c] ?? fallback;
}
/** One card from a string or an object. Throws SeedError with a sentence for the caller. */
export function readCard(raw: unknown, where = "card"): CardSeed {
const c: Record<string, any> = typeof raw === "string" ? { title: raw } : raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, any> : {};
const title = str(c.title ?? c.name);
if (!title) throw new SeedError(`Each ${where} needs a title.`);
if (title.length > LIMITS.cardTitle) throw new SeedError(`Keep card titles under ${LIMITS.cardTitle} characters.`);
const description = str(c.description ?? c.desc);
if (description.length > LIMITS.description) throw new SeedError(`Keep card descriptions under ${LIMITS.description} characters.`);
const due = readDue(c.due);
if (due === null) throw new SeedError(`due must be a date like "2026-10-30" or "2026-10-30T15:00".`);
const comments = Array.isArray(c.comments) ? c.comments.slice(0, LIMITS.commentsPerCard).map((m: any) => ({
name: clip(typeof m === "string" ? "" : m?.name ?? m?.by, LIMITS.memberName),
text: clip(typeof m === "string" ? m : m?.text, LIMITS.comment),
...(typeof m === "object" && m?.at ? { at: typeof m.at === "number" ? m.at : Math.floor(Date.parse(m.at) / 1000) || undefined } : {}),
})).filter((m: { text: string }) => m.text) : [];
return {
title,
description,
due,
done: Boolean(c.done ?? c.dueComplete),
labels: readNames(c.labels, "labels", LIMITS.labelName),
assignees: readNames(c.assignees ?? c.people, "assignees", LIMITS.memberName),
checklist: readChecklist(c.checklist),
cover: c.cover ? readColor(c.cover, "") : "",
archived: Boolean(c.archived),
comments,
};
}
/** A whole board from a lenient description: lists as names or {title, cards}, cards as titles or objects. */
export function readSeed(raw: unknown): BoardSeed {
const b: Record<string, any> = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, any> : {};
const title = str(b.title ?? b.name ?? b.board?.title);
if (!title) throw new SeedError("title is required.");
if (title.length > LIMITS.boardTitle) throw new SeedError(`Keep the board title under ${LIMITS.boardTitle} characters.`);
if (b.lists != null && !Array.isArray(b.lists)) throw new SeedError(`lists must be a list, like ["To do", {"title": "Doing", "cards": ["Write the brief"]}].`);
const lists: ListSeed[] = (b.lists || []).map((l: any, i: number) => {
const t = str(typeof l === "string" ? l : l?.title ?? l?.name);
if (!t) throw new SeedError(`lists[${i}] needs a title.`);
if (t.length > LIMITS.listTitle) throw new SeedError(`Keep list titles under ${LIMITS.listTitle} characters.`);
if (l?.cards != null && !Array.isArray(l.cards)) throw new SeedError(`lists[${i}].cards must be a list.`);
return { title: t, archived: Boolean(l?.archived), cards: (l?.cards || []).map((c: unknown) => readCard(c)) };
});
if (lists.length > LIMITS.lists) throw new SeedError(`A board can start with up to ${LIMITS.lists} lists.`);
const cardCount = lists.reduce((a, l) => a + l.cards.length, 0);
if (cardCount > LIMITS.cards) throw new SeedError(`A board can start with up to ${LIMITS.cards} cards.`);
const commentCount = lists.reduce((a, l) => a + l.cards.reduce((x, c) => x + c.comments.length, 0), 0);
if (commentCount > LIMITS.comments) throw new SeedError(`A board can start with up to ${LIMITS.comments} comments.`);
const labels: { name: string; color: string }[] = [];
for (const l of Array.isArray(b.labels) ? b.labels : []) {
const name = clip(typeof l === "string" ? l : l?.name, LIMITS.labelName);
const color = readColor(typeof l === "string" ? "" : l?.color, COLOR_IDS[labels.length % COLOR_IDS.length]);
if (!labels.some((x) => x.name.toLowerCase() === name.toLowerCase() && x.color === color)) labels.push({ name, color });
}
const members = readNames(Array.isArray(b.members) ? b.members.map((m: any) => (typeof m === "string" ? m : m?.name)) : b.members, "members", LIMITS.memberName);
// Labels and people named on cards join the board's lists of them.
for (const card of lists.flatMap((l) => l.cards)) {
for (const n of card.labels) if (!labels.some((x) => x.name.toLowerCase() === n.toLowerCase())) labels.push({ name: n, color: COLOR_IDS[labels.length % COLOR_IDS.length] });
for (const n of card.assignees) if (!members.some((x) => x.toLowerCase() === n.toLowerCase())) members.push(n);
for (const m of card.comments) if (m.name && !members.some((x) => x.toLowerCase() === m.name.toLowerCase())) members.push(m.name);
}
if (labels.length > LIMITS.labels) throw new SeedError(`A board can have up to ${LIMITS.labels} labels.`);
if (members.length > LIMITS.members) throw new SeedError(`A board can have up to ${LIMITS.members} people.`);
return { title, labels, members, lists };
}
// ---------- Templates ----------
export interface Template { id: string; name: string; summary: string; seed: (today: string) => BoardSeed }
const card = (title: string, extra: Partial<CardSeed> = {}): CardSeed => ({
title, description: "", due: "", done: false, labels: [], assignees: [], checklist: [], cover: "", archived: false, comments: [], ...extra,
});
const items = (...texts: string[]) => texts.map((text) => ({ text, done: false }));
export const TEMPLATES: Template[] = [
{
id: "blank",
name: "Blank board",
summary: "Three lists: To do, Doing and Done.",
seed: () => ({ title: "", labels: [], members: [], lists: ["To do", "Doing", "Done"].map((title) => ({ title, archived: false, cards: [] })) }),
},
{
id: "kanban",
name: "Kanban",
summary: "To do, Doing and Done, with a few cards that show how a board works.",
seed: () => ({
title: "Team board",
labels: [{ name: "High priority", color: "red" }, { name: "Quick win", color: "green" }, { name: "Waiting", color: "yellow" }],
members: [],
lists: [
{ title: "To do", archived: false, cards: [
card("Drag a card to Doing when you start it", { description: "Drag with a mouse or a finger (press and hold on a phone).\n\nWith a keyboard: focus a card, press **Space** to pick it up, move it with the arrow keys and press **Space** again to drop it.", labels: ["Quick win"] }),
card("Open a card to add a checklist, a due date or labels", { checklist: items("Add a due date", "Add a label", "Assign someone") }),
card("Share the board link with your team", { description: "Everyone with the link can edit the board, and changes show up for everyone right away. Nobody needs an account.", labels: ["High priority"] }),
] },
{ title: "Doing", archived: false, cards: [] },
{ title: "Done", archived: false, cards: [card("Make a board")] },
],
}),
},
{
id: "weekly-plan",
name: "Weekly plan",
summary: "A list for each day, so you can drag this week's tasks to the day you will do them.",
seed: (today) => ({
title: "This week",
labels: [{ name: "Work", color: "blue" }, { name: "Home", color: "green" }, { name: "Errand", color: "yellow" }],
members: [],
lists: [
{ title: "This week", archived: false, cards: [
card("Drag each task to the day you will do it"),
card("Book a dentist appointment", { labels: ["Errand"] }),
card("Plan meals for the week", { labels: ["Home"], checklist: items("Check the fridge", "Write the shopping list") }),
] },
...["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"].map((title, i) => ({
title, archived: false,
cards: i === 0 ? [card("Weekly review", { labels: ["Work"], due: today, checklist: items("Clear the inbox", "Check the calendar", "Pick the three most important tasks") })]
: i === 4 ? [card("Send the weekly update", { labels: ["Work"] })] : [],
})),
{ title: "Weekend", archived: false, cards: [] },
{ title: "Done", archived: false, cards: [] },
],
}),
},
{
id: "content-calendar",
name: "Content calendar",
summary: "Ideas to published, with due dates you can see on the calendar view.",
seed: (today) => ({
title: "Content calendar",
labels: [{ name: "Blog", color: "blue" }, { name: "Newsletter", color: "purple" }, { name: "Video", color: "red" }, { name: "Social", color: "sky" }],
members: [],
lists: [
{ title: "Ideas", archived: false, cards: [
card("Customer story: how one team plans its week", { labels: ["Blog"] }),
card("Behind the scenes video", { labels: ["Video"] }),
] },
{ title: "Writing", archived: false, cards: [
card("Monthly newsletter", { labels: ["Newsletter"], due: addDays(today, 5), checklist: items("Draft", "Pick images", "Add links", "Proofread") }),
] },
{ title: "Editing", archived: false, cards: [] },
{ title: "Scheduled", archived: false, cards: [
card("Launch post", { labels: ["Social"], due: `${addDays(today, 7)}T09:00`, description: "Post at 9:00 and reply to comments for the first hour." }),
] },
{ title: "Published", archived: false, cards: [] },
],
}),
},
{
id: "bug-tracker",
name: "Bug tracker",
summary: "Reported to fixed, with severity labels and a bug report layout.",
seed: () => ({
title: "Bugs",
labels: [{ name: "Critical", color: "red" }, { name: "Major", color: "orange" }, { name: "Minor", color: "yellow" }, { name: "Feature request", color: "blue" }],
members: [],
lists: [
{ title: "Reported", archived: false, cards: [
card("Example: the sign-in button does nothing on Safari", {
labels: ["Major"],
description: "## Steps to reproduce\n1. Open the sign-in page in Safari\n2. Enter an email\n3. Press Sign in\n\n## Expected\nThe code arrives by email.\n\n## Actual\nNothing happens.\n\n## Device and browser\nMacBook, Safari 18",
checklist: items("Reproduce", "Find the cause", "Fix", "Test on Safari"),
}),
] },
{ title: "Confirmed", archived: false, cards: [card("Example: export cuts off long names", { labels: ["Minor"] })] },
{ title: "In progress", archived: false, cards: [] },
{ title: "In review", archived: false, cards: [] },
{ title: "Fixed", archived: false, cards: [] },
],
}),
},
];
export const templateById = (id: string) => TEMPLATES.find((t) => t.id === id);
// ---------- Trello import ----------
/** True when a parsed JSON file looks like a Trello board export. */
export const isTrello = (raw: any) => Boolean(raw && Array.isArray(raw.lists) && Array.isArray(raw.cards) && (raw.idOrganization !== undefined || raw.prefs || raw.shortUrl || raw.actions));
/** A Trello board export (Menu, Print and export, Export as JSON) as a board description. */
export function fromTrello(raw: any): BoardSeed {
const lists = [...(raw.lists || [])].sort((a: any, b: any) => (a.pos ?? 0) - (b.pos ?? 0));
const labelsById = new Map<string, { name: string; color: string }>();
for (const l of raw.labels || []) labelsById.set(l.id, { name: clip(l.name || COLORS[readColor(l.color)]?.name || "Label", LIMITS.labelName), color: readColor(l.color) });
const membersById = new Map<string, string>();
for (const m of raw.members || []) membersById.set(m.id, clip(m.fullName || m.username || "Someone", LIMITS.memberName));
const checklistsByCard = new Map<string, any[]>();
for (const c of raw.checklists || []) checklistsByCard.set(c.idCard, [...(checklistsByCard.get(c.idCard) || []), c]);
const commentsByCard = new Map<string, { name: string; text: string; at?: number }[]>();
for (const a of raw.actions || []) {
if (a?.type !== "commentCard" || !a.data?.card?.id) continue;
const list = commentsByCard.get(a.data.card.id) || [];
list.push({ name: clip(a.memberCreator?.fullName || a.memberCreator?.username || "", LIMITS.memberName), text: clip(a.data.text, LIMITS.comment), at: Math.floor(Date.parse(a.date) / 1000) || undefined });
commentsByCard.set(a.data.card.id, list);
}
const cards = [...(raw.cards || [])].sort((a: any, b: any) => (a.pos ?? 0) - (b.pos ?? 0));
const seen = new Set<string>();
const labelName = (id: string) => labelsById.get(id)?.name;
const seed = {
title: clip(raw.name || "Imported board", LIMITS.boardTitle),
labels: [...labelsById.values()],
members: [...new Set(membersById.values())],
lists: lists.slice(0, LIMITS.lists).map((l: any) => ({
title: clip(l.name || "List", LIMITS.listTitle) || "List",
archived: Boolean(l.closed),
cards: cards.filter((c: any) => c.idList === l.id).map((c: any) => {
seen.add(c.id);
const lists = (checklistsByCard.get(c.id) || []).sort((a, b) => (a.pos ?? 0) - (b.pos ?? 0));
const check = lists.flatMap((cl) => [...(cl.checkItems || [])].sort((a: any, b: any) => (a.pos ?? 0) - (b.pos ?? 0))
.map((it: any) => ({ text: clip(lists.length > 1 ? `${cl.name}: ${it.name}` : it.name, LIMITS.checkText), done: it.state === "complete" })));
return {
title: clip(c.name || "Card", LIMITS.cardTitle) || "Card",
description: clip(c.desc, LIMITS.description),
due: (c.due && readDue(c.due)) || "",
done: Boolean(c.dueComplete),
labels: (c.idLabels || []).map(labelName).filter(Boolean) as string[],
assignees: (c.idMembers || []).map((id: string) => membersById.get(id)).filter(Boolean) as string[],
checklist: check.filter((x) => x.text).slice(0, LIMITS.checkItems),
cover: c.cover?.color ? readColor(c.cover.color, "") : "",
archived: Boolean(c.closed),
comments: (commentsByCard.get(c.id) || []).filter((m) => m.text).sort((a, b) => (a.at ?? 0) - (b.at ?? 0)).slice(-LIMITS.commentsPerCard),
};
}),
})),
};
// Labels with the same name and color in Trello become one label here.
const uniq: { name: string; color: string }[] = [];
for (const l of seed.labels) if (!uniq.some((u) => u.name === l.name && u.color === l.color)) uniq.push(l);
seed.labels = uniq;
return readSeed(seed);
}
// ---------- Writes ----------
export interface Write { path: string; document: Record<string, unknown> }
/**
* The writes that fill a new board with a description: labels, people, lists, cards and comments, in an
* order where everything a write points at comes before it. `now` is the server time operation.
*/
export function seedWrites(boardId: string, seed: BoardSeed, by: string, now: unknown): { writes: Write[]; ids: { lists: string[]; labels: Map<string, string>; members: Map<string, string> } } {
const base = `boards/${boardId}`;
const writes: Write[] = [];
const labels = new Map<string, string>();
const labelIds = new Map<string, string>();
for (const l of seed.labels) {
const id = randomId(10);
labelIds.set(`${l.name.toLowerCase()}|${l.color}`, id);
if (!labels.has(l.name.toLowerCase())) labels.set(l.name.toLowerCase(), id);
writes.push({ path: `${base}/labels/${id}`, document: { name: l.name, color: l.color, createdAt: now } });
}
const members = new Map<string, string>();
for (const name of seed.members) {
const id = randomId(10);
members.set(name.toLowerCase(), id);
writes.push({ path: `${base}/members/${id}`, document: { name, removed: false, createdAt: now } });
}
const listIds: string[] = [];
const cardWrites: Write[] = [];
const commentWrites: Write[] = [];
seed.lists.forEach((l, i) => {
const listId = randomId(10);
listIds.push(listId);
writes.push({ path: `${base}/lists/${listId}`, document: { title: l.title, pos: (i + 1) * 1024, archived: l.archived, createdAt: now } });
l.cards.forEach((c, j) => {
const cardId = randomId(12);
cardWrites.push({
path: `${base}/cards/${cardId}`,
document: {
listId, title: c.title, description: c.description, pos: (j + 1) * 1024, due: c.due, done: c.done,
labels: JSON.stringify(c.labels.map((n) => labels.get(n.toLowerCase())).filter(Boolean)),
assignees: JSON.stringify(c.assignees.map((n) => members.get(n.toLowerCase())).filter(Boolean)),
checklist: JSON.stringify(c.checklist.map((x) => ({ id: randomId(8), text: x.text, done: x.done }))),
cover: c.cover, archived: c.archived, createdBy: by, createdAt: now,
},
});
for (const m of c.comments) {
commentWrites.push({
path: `${base}/comments/${randomId(14)}`,
document: { cardId, text: m.text, by, name: m.name || "Someone", createdAt: m.at && m.at > 0 && m.at < Date.now() / 1000 - 5 ? m.at : now },
});
}
});
});
return { writes: [...writes, ...cardWrites, ...commentWrites], ids: { lists: listIds, labels, members } };
}
/** Splits writes into batches small enough to send one at a time, keeping their order. */
export function chunk<T>(list: T[], size = 100): T[][] {
const out: T[][] = [];
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
return out;
}
// ---------- Export ----------
export interface BoardData { board: Board & { id?: string }; lists: List[]; cards: Card[]; labels: Label[]; members: Member[]; comments?: Comment[] }
/** The board as a plain description: the same shape POST /boards and Import accept, so it round-trips. */
export function exportBoard(d: BoardData, url?: (cardId: string) => string) {
const label = new Map(d.labels.map((l) => [l.id, l]));
const member = new Map(d.members.filter((m) => !m.removed).map((m) => [m.id, m]));
const comments = new Map<string, Comment[]>();
for (const c of d.comments || []) comments.set(c.cardId, [...(comments.get(c.cardId) || []), c]);
const lists = [...d.lists].sort(byPos);
return {
title: d.board.title,
archived: d.board.archived,
labels: [...d.labels].sort((a, b) => a.createdAt - b.createdAt).map((l) => ({ id: l.id, name: l.name, color: l.color })),
members: d.members.filter((m) => !m.removed).map((m) => ({ id: m.id, name: m.name })),
lists: lists.map((l) => ({
id: l.id,
title: l.title,
archived: l.archived,
cards: d.cards.filter((c) => c.listId === l.id).sort(byPos).map((c) => {
const check = parseChecklist(c.checklist);
return {
id: c.id,
title: c.title,
description: c.description,
due: c.due,
done: c.done,
labels: parseIds(c.labels).map((id) => label.get(id)?.name).filter((n): n is string => n != null),
assignees: parseIds(c.assignees).map((id) => member.get(id)?.name).filter((n): n is string => n != null),
checklist: check.map((x) => ({ text: x.text, done: x.done })),
cover: c.cover,
archived: c.archived,
...(url ? { url: url(c.id) } : {}),
...(d.comments ? { comments: (comments.get(c.id) || []).sort((a, b) => a.createdAt - b.createdAt).map((m) => ({ name: m.name, text: m.text, at: new Date(m.createdAt * 1000).toISOString() })) } : {}),
};
}),
})),
};
}
const cell = (v: unknown) => {
const s = String(v ?? "");
return /[",\n\r]/.test(s) || /^[=+\-@\t\r]/.test(s) ? `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"` : s;
};
/** One row per card, ready for a spreadsheet. Cells that start like a formula are quoted so spreadsheets show them as text. */
export function boardCsv(d: BoardData): string {
const e = exportBoard(d);
const rows = [["List", "Card", "Description", "Labels", "Assigned to", "Due", "Done", "Checklist", "Archived"]];
for (const l of e.lists) {
for (const c of l.cards) {
const doneItems = c.checklist.filter((x) => x.done).length;
rows.push([l.title, c.title, c.description, c.labels.join(", "), c.assignees.join(", "), c.due.replace("T", " "), c.done ? "yes" : "", c.checklist.length ? `${doneItems}/${c.checklist.length}` : "", c.archived || l.archived ? "yes" : ""]);
}
}
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n";
}
export function fileName(title: string, ext: string): string {
return `${title.replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "board"}.${ext}`;
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync