tasks-free.openapps.xyz

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.
Owned app · rev 9b0d0f7b9a93
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.
← All files
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
9b0d0f7b9a · 1 day ago
Bind agent address in policy
25f84633c0 · 1 day ago
Use OpenApps service URLs and add contribution strip
07519eb348 · 1 day ago
Bounded source sync
3296872001 · 2 days ago
Bounded source sync
ac8d2f5d18 · 2 days ago
Bounded source sync
2a5b08a001 · 2 days ago
Bounded source sync
d2242ddf34 · 2 days ago