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 CRLFexportCsv(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 0exportCsv(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
| todos | Todo[] | in the order the rows should appear |
| returns | string | a 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";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,814 |
| impl/python.py | 1,419 |
| impl/rust.rs | 1,834 |
| impl/typescript.ts | 1,356 |
| vectors.json | 7,779 |