todo.import-csv
Read todos from a CSV file (RFC 4180) as todo.export-csv writes it, checking every row, with row-numbered errors.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 46 tests, run in TypeScript, Python and Rust.
What it does
Reads todos from CSV text, as todo.export-csv writes it or as a spreadsheet saves it, and checks every row. It returns the todos in file order, each one valid by todo.item's `validateTodo`, or throws on the first problem with the row it is in.
## The file format
For example
import_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order )→ the header alone is an empty listimport_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order)→ the header without a final line breakimport_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 )→ ×1 one plain row: empty fields become null, false, order 0
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.
pub fn import_csv(csv: &str) -> Vec<Todo>
| csv | string | the file's text; a UTF-8 byte order mark and LF line endings are accepted |
| returns | Todo[] | the todos in file order, each one valid by todo.item's validateTodo |
Your code names it in one line, in the file that uses it
fune!(todo.import-csv@^1); // then call import_csv(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use std::collections::HashSet;
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::todo_item::{todos_to_value, validate_todo, Recurrence, Todo}; ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_normalise_tags::normalise_tags; ← from todo.normalise-tags ^1.0.0 · built alongside by fune
const COLUMNS: [&str; 13] = [
"id", "title", "notes", "done", "priority", "due", "tags",
"recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
];
fn header_rule() -> String {
format!("the header must have the columns {}", COLUMNS.join(", "))
}
fn fail(row: usize, message: &str) -> ! {
panic!("row {}: {}", row, message)
}
/// One RFC 4180 record from `pos`: its fields and where the next record
/// starts. Only ASCII bytes are looked at, so every slice is valid UTF-8.
fn read_record(text: &str, mut pos: usize, row: usize) -> (Vec<String>, usize) {
let b = text.as_bytes();
let size = b.len();
let mut fields: Vec<String> = Vec::new();
loop {
let value: String;
if pos < size && b[pos] == b'"' {
pos += 1;
let mut out = String::new();
loop {
let quote = match text[pos..].find('"') {
Some(q) => pos + q,
None => fail(row, "a quoted field is not closed"),
};
out.push_str(&text[pos..quote]);
if quote + 1 < size && b[quote + 1] == b'"' {
out.push('"');
pos = quote + 2;
} else {
pos = quote + 1;
break;
}
}
if pos < size && !matches!(b[pos], b',' | b'\r' | b'\n') {
fail(row, "text after the closing quote of a field");
}
value = out;
} else {
let mut end = pos;
while end < size && !matches!(b[end], b',' | b'\r' | b'\n') {
if b[end] == b'"' {
fail(row, "a field with a double quote in it must be quoted");
}
end += 1;
}
value = text[pos..end].to_string();
pos = end;
}
fields.push(value);
if pos >= size {
return (fields, pos);
}
if b[pos] == b',' {
pos += 1;
} else if b[pos] == b'\n' {
return (fields, pos + 1);
} else if pos + 1 < size && b[pos + 1] == b'\n' {
return (fields, pos + 2);
} else {
fail(row, "a CR outside quotes must be followed by LF");
}
}
}
/// Where each of COLUMNS sits in the file's header.
fn read_header(names: &[String]) -> Vec<usize> {
let mut at: Vec<(&str, usize)> = Vec::new();
for (i, name) in names.iter().enumerate() {
if !COLUMNS.contains(&name.as_str()) {
panic!("{}: unknown column \"{}\"", header_rule(), name);
}
if at.iter().any(|(n, _)| n == name) {
panic!("{}: column \"{}\" appears twice", header_rule(), name);
}
at.push((name.as_str(), i));
}
COLUMNS
.iter()
.map(|column| match at.iter().find(|(n, _)| n == column) {
Some((_, i)) => *i,
None => panic!("{}: missing column \"{}\"", header_rule(), column),
})
.collect()
}
fn whole(row: usize, column: &str, text: &str) -> i64 {
let digits = text.strip_prefix('-').unwrap_or(text);
if digits.is_empty() || digits.len() > 15 || !digits.bytes().all(|c| c.is_ascii_digit()) {
fail(row, &format!("{} must be a whole number, found \"{}\"", column, text));
}
text.parse::<i64>().unwrap()
}
fn opt(text: &str) -> Option<String> {
if text.is_empty() { None } else { Some(text.to_string()) }
}
fn read_todo(fields: &[String], at: &[usize], row: usize) -> Todo {
if fields.len() != COLUMNS.len() {
fail(row, &format!("expected {} fields, found {}", COLUMNS.len(), fields.len()));
}
let get = |i: usize| fields[at[i]].as_str();
let done = get(3);
if done != "true" && done != "false" {
fail(row, &format!("done must be true or false, found \"{}\"", done));
}
let (frequency, interval, anchor) = (get(7), get(8), get(9));
let filled = [frequency, interval, anchor].iter().filter(|t| !t.is_empty()).count();
let recurrence = match filled {
0 => None,
3 => Some(Recurrence {
frequency: frequency.to_string(),
interval: whole(row, "recurrenceInterval", interval),
anchor: anchor.to_string(),
}),
_ => fail(row, "fill in all three recurrence columns or leave them all empty"),
};
let tags: Vec<String> = get(6).split(' ').map(|t| t.to_string()).collect();
Todo {
id: get(0).to_string(),
title: get(1).to_string(),
notes: opt(get(2)),
done: done == "true",
priority: get(4).to_string(),
due: opt(get(5)),
tags: normalise_tags(&tags),
recurrence,
created_at: get(10).to_string(),
completed_at: opt(get(11)),
order: whole(row, "order", get(12)),
}
}
/// Todos from CSV: the 13 columns in any order, CRLF or LF, an optional
/// byte order mark. Every row is checked with validate_todo.
///
/// # Panics
/// With "row N: ..." (the header is row 1) on the first malformed or invalid
/// row, or on a header without exactly the 13 columns.
pub fn import_csv(csv: &str) -> Vec<Todo> {
let text = csv.strip_prefix('\u{feff}').unwrap_or(csv);
let b = text.as_bytes();
let mut todos: Vec<Todo> = Vec::new();
let mut ids: HashSet<String> = HashSet::new();
let mut at: Option<Vec<usize>> = None;
let mut row = 0;
let mut pos = 0;
while pos < b.len() {
if b[pos] == b'\n' {
pos += 1;
continue;
}
if b[pos] == b'\r' && pos + 1 < b.len() && b[pos + 1] == b'\n' {
pos += 2;
continue;
}
row += 1;
let (fields, next) = read_record(text, pos, row);
pos = next;
let columns = match &at {
None => {
at = Some(read_header(&fields));
continue;
}
Some(columns) => columns,
};
let todo = read_todo(&fields, columns, row);
if let Some((field, message)) = validate_todo(&todo).errors.first() {
fail(row, &format!("{}: {}", field, message));
}
if !ids.insert(todo.id.clone()) {
fail(row, &format!("duplicate id \"{}\"", todo.id));
}
todos.push(todo);
}
if at.is_none() {
panic!("the CSV has no header row");
}
todos
}
pub fn fune_vector(args: &[Value]) -> Value {
todos_to_value(&import_csv(args[0].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add todo.import-csv
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.import-csv-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.import-csv@1.0.0:rust.
The whole function, every language, is one file too: todo.import-csv-1.0.0.fune, 43,391 bytes, sha256 395dfb021064802d0bdf93d3ed3c090c104159ba61537513055acb08dc2a1eb9. 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.import-csv
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.import-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.import-csv
// fune: replace todo.normalise-tags in todo.import-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.import-csv --steps.
// fune: step todo.import-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 | |
|---|---|---|---|
| the header alone is an empty list | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order | → | |
| the header without a final line break | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order | → | |
| one plain row: empty fields become null, false, order 0 | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | ×1 |
| the last row without a final line break | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | ×1 |
| LF line endings and a UTF-8 byte order mark | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | ×1 |
| columns in another order are matched by name | order,completedAt,createdAt,recurrenceAnchor,recurrenceInterval,recurrenceFrequency,tags,due,priority,done,notes,title,id 7,2026-09-28T10:00:00Z,2026-09-20T08:00:00Z,2026-10-01,2… | → | ×1 |
| round trip: the file todo.export-csv writes for quotes, commas, line breaks, unicode, a repeat and a done todo | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Read ""Dune"", then lend it","Chapter 1 Chapter 2 s… | → | ×2 |
| empty lines between and after rows are skipped | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 … | → | ×2 |
| tags from another tool are normalised: # dropped, lower-cased, commas and repeats gone | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,"#Home Work, home Home-Office"… | → | ×1 |
| quoted fields that did not need quoting, and an empty quoted field for notes (null) | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order "t1","Buy milk","","false","none","","","","","","2026-0… | → | ×1 |
Show the other 36 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a lone CR inside quotes is kept as it is | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"a b",false,none,,,,,,2026-09-28T09:30:00Z,,… | → | ×1 |
| leading zeros in order and interval are read as the number | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,003,2026-10-01… | → | ×1 |
| spaces around a field are data, not trimmed (notes keep them) | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk, note ,false,none,,,,,,2026-09-28T09:30:00… | → | ×1 |
| the same title twice is fine; only ids must differ | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t2… | → | ×2 |
| empty text has no header row | → | error: the CSV has no header row | |
| only empty lines and a byte order mark have no header row | | → | error: the CSV has no header row |
| a header without the order column | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt | → | error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: missing column "order" |
| a header with an extra column | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,colour | → | error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column "colour" |
| header names are matched exactly: Title is not title | id,Title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order | → | error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column "Title" |
| a header naming a column twice | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,title | → | error: the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: column "title" appears twice |
| a row one field short | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t1… | → | error: row 3: expected 13 fields, found 12 |
| a row with a trailing comma has one field too many | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0, | → | error: row 2: expected 13 fields, found 14 |
| a quoted field that is never closed | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: a quoted field is not closed |
| text after a closing quote | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,"Buy" milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: text after the closing quote of a field |
| a double quote inside a field that is not quoted | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Read "Dune",,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: a field with a double quote in it must be quoted |
| a lone CR outside quotes (old Mac line endings) | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 1: a CR outside quotes must be followed by LF |
| done must be true or false, not yes | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: done must be true or false, found "yes" |
| done is case-sensitive: TRUE is refused | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,TRUE,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: done must be true or false, found "TRUE" |
| an interval that is not a whole number | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,1.5,2026-10-01… | → | error: row 2: recurrenceInterval must be a whole number, found "1.5" |
| an empty order | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,, | → | error: row 2: order must be a whole number, found "" |
| an order with a plus sign | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,+1 | → | error: row 2: order must be a whole number, found "+1" |
| an order in Arabic-Indic digits | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,٣ | → | error: row 2: order must be a whole number, found "٣" |
| an order with 16 digits is refused | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,12345… | → | error: row 2: order must be a whole number, found "1234567890123456" |
| recurrence columns partly filled | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,,2026-10-01,20… | → | error: row 2: fill in all three recurrence columns or leave them all empty |
| a blank title | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1, ,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: title: Enter a title. |
| spaces around the title | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1, Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: title: Remove the spaces around the title. |
| an unknown priority and a bad due date: priority comes first | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,urgent,2026-02-30,,,,,2026-09-28T09:3… | → | error: row 2: priority: Choose a priority: none, low, medium or high. |
| a done todo without a completion time | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,true,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: completedAt: A done todo needs the time it was completed. |
| a negative order is a whole number, so validateTodo reports it | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,-1 | → | error: row 2: order: Order must be a whole number, 0 or more. |
| an interval of 0 | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,2026-10-01,,daily,0,2026-10-01,2… | → | error: row 2: recurrence: Repeat every 1 to 999 days, weeks, months or years. |
| eleven different tags | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,a b c d e f g h i j k,,,,2026-0… | → | error: row 2: tags: Use no more than 10 tags. |
| an empty id | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 | → | error: row 2: id: Every todo needs an id. |
| a timestamp with an offset | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00+01:00,,… | → | error: row 2: createdAt: Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z. |
| the same id twice | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 t1… | → | error: row 3: duplicate id "t1" |
| rows are records, not lines: empty lines and line breaks in quotes are not counted | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,"a b c",false,none,,,,,,2026-09-28T09:30:0… | → | error: row 3: done must be true or false, found "maybe" |
| the first bad row wins, even when a later row cannot be read | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,no,none,,,,,,2026-09-28T09:30:00Z,,0 t2,"o… | → | error: row 2: done must be true or false, found "no" |
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 `@`.
## What it accepts beyond that
Files come back from spreadsheets and other tools, so reading is a little wider than writing:
- CRLF or LF line endings (CR or LF inside a quoted field is kept as it is), an optional UTF-8 byte order mark at the start, and an optional line break after the last row. Empty lines are skipped. - The 13 columns in any order, each exactly once and nothing else. Names are matched exactly (`Title` is not `title`). - Any field may be quoted, needed or not. - Tags are split on spaces and normalised with todo.normalise-tags, so `#Home Work,` imports as `home work`. - `order` and `recurrenceInterval` accept leading zeros and a leading `-` (so a negative order is reported by `validateTodo`'s own message).
Empty `notes`, `due` and `completedAt` fields become null.
## Errors
Rows are records, counted from 1 for the header, so the first todo is row 2. A quoted field with line breaks is one row, and skipped empty lines are not counted. Rows are read in order and the first problem is thrown:
- `the CSV has no header row` (empty text, or only empty lines) - `the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: ` then `unknown column "colour"`, `column "title" appears twice` or `missing column "order"` - `row N: a quoted field is not closed` - `row N: text after the closing quote of a field` - `row N: a field with a double quote in it must be quoted` - `row N: a CR outside quotes must be followed by LF` (old Mac line endings) - `row N: expected 13 fields, found 12` - `row N: done must be true or false, found "yes"` (case-sensitive) - `row N: fill in all three recurrence columns or leave them all empty` - `row N: recurrenceInterval must be a whole number, found "1.5"` and the same for `order`: ASCII digits, an optional leading `-`, at most 15 digits (no `+`, spaces, decimals or exponents) - `row N: <field>: <message>`, the first of `validateTodo`'s messages in field order, e.g. `row 2: title: Remove the spaces around the title.` - `row N: duplicate id "t1"`
## Why it is shaped this way
All or nothing: a half-imported list is harder to fix than a file, so one bad row refuses the whole file and says where to look. The row number counts records rather than lines because that is what a spreadsheet shows, and a quoted note with line breaks is one spreadsheet row.
Unknown columns are refused rather than ignored, so a misspelt header (`compltedAt`) is caught instead of silently dropping data.
Files
| Path | Bytes |
|---|---|
| README.md | 4,216 |
| impl/python.py | 5,135 |
| impl/rust.rs | 6,748 |
| impl/typescript.ts | 5,379 |
| vectors.json | 18,064 |