Functional Weave
Code in TypeScript

todo.export-csv

Write todos as a CSV file (RFC 4180) with one column per field, for backups, spreadsheets and other apps.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 14 tests, run in TypeScript, Python and Rust.

What it does

Writes a list of todos as CSV text: a header row and one row per todo, in the order given. Use it for a "download my todos" button, a backup, or handing the list to a spreadsheet or another app. todo.import-csv reads the same text back.

## The file format

For example

  • exportCsv() → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order no todos is just the header line, still ending CRLF
  • exportCsv(todos ×1) → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 a todo with no optional fields: empty fields for nulls, false, order 0
  • exportCsv(todos ×1) → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t2,Pay rent,Standing order,true,high,2026-10-01,home bil… every field filled: done, due, two tags joined by a space, a monthly repeat

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function exportCsv(todos: readonly Todo[]): string
todosTodo[]in the order the rows should appear
returnsstringa header line and one line per todo, every line ending CRLF

Your code names it in one line, in the file that uses it

import { exportCsv } from "#fune/todo.export-csv@^1";
impl/typescript.ts · 41 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type Todo } from "./todo_item.ts";  ← from todo.item ^1.0.0 · built alongside by fune

const HEADER = [
  "id", "title", "notes", "done", "priority", "due", "tags",
  "recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
];

/** Quote a field only when RFC 4180 needs it: a comma, a double quote, CR or LF. */
function field(text: string): string {
  if (!/[",\r\n]/.test(text)) return text;
  return '"' + text.split('"').join('""') + '"';
}

/**
 * Todos as CSV, one row per todo in the order given, under a fixed header
 * of 13 columns. Nothing is escaped for spreadsheets: a title starting with
 * "=" is written as it is, so the file reads back exactly (see README).
 */
export function exportCsv(todos: readonly Todo[]): string {
  const lines = [HEADER.join(",")];
  for (const todo of todos) {
    const rule = todo.recurrence ?? null;
    const cells = [
      todo.id,
      todo.title,
      todo.notes ?? "",
      todo.done ? "true" : "false",
      todo.priority,
      todo.due ?? "",
      todo.tags.join(" "),
      rule === null ? "" : rule.frequency,
      rule === null ? "" : String(rule.interval),
      rule === null ? "" : rule.anchor,
      todo.createdAt,
      todo.completedAt ?? "",
      String(todo.order),
    ];
    lines.push(cells.map(field).join(","));
  }
  return lines.map((line) => line + "\r\n").join("");
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add todo.export-csv
Download for TypeScript todo.export-csv-1.0.0-typescript.fune · 14,272 bytes sha256 cedeb7a4ff41998de2cc202cfff71b8a9e57832e7cfef8020208aba016a7e706

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.export-csv-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.export-csv@1.0.0:typescript.

The whole function, every language, is one file too: todo.export-csv-1.0.0.fune, 17,768 bytes, sha256 f2f17720f311707190e8643af25b46732546ab8cf545f39a5f5f873f7139bc83. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before todo.export-csv

after — your function gets the result and the arguments, and returns the final result.

// fune: after todo.export-csv

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace todo.item in todo.export-csv

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.export-csv --steps.

// fune: step todo.export-csv after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
no todos is just the header line, still ending CRLF → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order
a todo with no optional fields: empty fields for nulls, false, order 0 todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0
every field filled: done, due, two tags joined by a space, a monthly repeat todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t2,Pay rent,Standing order,true,high,2026-10-01,home bil…
a comma in the title makes it quoted todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Eggs, milk",,false,none,,,,,,2026-09-28T09:30:00Z,,0…
double quotes are doubled inside a quoted field todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Read ""Dune"" again",,false,none,,,,,,2026-09-28T09:…
line breaks in notes (LF, CRLF and a lone CR) are kept inside quotes, as they are todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"line 1 line 2 line 3 end",false,none,,,,,,…
a title starting with = is written as is: no spreadsheet formula escaping todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,=1+1,,false,none,,,,,,2026-09-28T09:30:00Z,,0
unicode, semicolons, tabs and spaces need no quotes todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Café ☕ 😀; tab here, padded ,false,none,,,,,,2026-0…
todos stay in the order given, not sorted by order or id todos ×2 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order b,Second,,false,none,,,,,,2026-09-28T09:30:00Z,,5 a,Fir…
an interval above 9 and a weekly repeat todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-05,,weekly,12,2026-09-28…
Show the other 4 tests
CaseArgumentsExpected
empty notes are written like null notes: an empty field todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0
a field that is only a double quote todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"""",false,none,,,,,,2026-09-28T09:30:00Z,,0…
export does not validate: a negative order is written as it is todos ×1 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,-1
round trip: quotes, commas, line breaks, unicode, a repeat and a done todo (the text importCsv reads back) todos ×2 → id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Read ""Dune"", then lend it","Chapter 1 Chapter 2 s…

More from the author

CSV as in [RFC 4180, Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://www.rfc-editor.org/rfc/rfc4180). The same format is written by todo.export-csv and read by todo.import-csv.

- A header row, then one row per todo. todo.export-csv writes these 13 columns in this order: `id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order` - `done` is `true` or `false`. `tags` are joined with single spaces. The repeat takes three columns, all empty when the todo does not repeat. `order` and `recurrenceInterval` are decimal integers. A null field (`notes`, `due`, `completedAt`) is empty. - A field is quoted with double quotes exactly when it contains a comma, a double quote, CR or LF, and a double quote inside it is doubled. Nothing else is quoted, and spaces are kept as they are. - Every row, the last one included, ends with CRLF.

**Caveat for spreadsheets.** Nothing is escaped against formula injection: a title such as `=1+1` or `=HYPERLINK(...)` is written as it is, because prefixing it with `'` would change the title when the file is read back. If your app offers the file for opening in Excel, LibreOffice or Google Sheets, warn people, or write a separate spreadsheet export that escapes cells starting with `=`, `+`, `-` or `@`.

## Why it is shaped this way

One column per field, with the repeat split into three plain columns rather than packed into one, so the file is readable in a spreadsheet and easy for other tools to produce. Columns are named after the `Todo` fields, so the header documents itself.

CRLF line endings and minimal quoting are what RFC 4180 describes and what spreadsheets expect; quoting only when needed keeps the file diff-friendly.

## Edge cases

- No todos: just the header line, still ending in CRLF. - Notes that are `""` are written as an empty field, the same as null notes, so they read back as null. Every other valid list reads back exactly: `importCsv(exportCsv(todos))` equals `todos`. - Line breaks inside notes (LF, CRLF or a lone CR) are kept inside quotes as they are. - It does not validate and never throws: a todo is written as it is, even an invalid one (a negative order is written `-1`). Validate on the way in, or let todo.import-csv refuse the file. - The text is a string; encode it as UTF-8 when saving. No byte order mark is written (some versions of Excel need one to read UTF-8; add `\uFEFF` in front if yours does, todo.import-csv accepts it).

Files

PathBytes
README.md2,814
impl/python.py1,419
impl/rust.rs1,834
impl/typescript.ts1,356
vectors.json7,779