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.1.0 · published 2026-10-03 by charlie · Anterra
Pinned by 59 tests, run in TypeScript, Python and Rust.importCsv 46 · validateTodoCsv 13
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
The functions
A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- importCsv (csv: string) -> Todo[]
- validateTodoCsv (csv: string) -> CsvValidation
The types it declares, generated into your project
/** One problem in a todo CSV file. */
export interface CsvRowError {
/** the record it is in, counting the header as row 1, as a spreadsheet numbers rows */
readonly row: number;
/** the todo field or CSV column it is about; null when the row as a whole is wrong */
readonly field: string | null;
readonly message: string;
}
/** Every problem in a todo CSV file, so a screen can list them all at once. */
export interface CsvValidation {
readonly valid: boolean;
/** todo rows read, not counting the header */
readonly rows: number;
/** in file order, and in check order within a row; empty when valid */
readonly errors: readonly CsvRowError[];
}
Once installed, your code imports each one from the group's module.
importCsv throws on bad input 46 tests
export function importCsv(csv: string): readonly 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 |
For example
importCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order )→ the header alone is an empty listimportCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order)→ the header without a final line breakimportCsv(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
import { importCsv } from "#fune/todo.import-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
import { readTodoCsv } from "./todo_import_csv_validate_todo_csv.ts"; ← validateTodoCsv, another function of this group · built into the same file, even by a slim install
/**
* Todos from CSV text as todo.export-csv writes it, or as a spreadsheet saves
* it: the 13 columns in any order, CRLF or LF, an optional byte order mark.
* Every row is checked with validateTodo; the first problem is thrown with
* its row number, counting the header as row 1. validateTodoCsv lists them
* all.
*/
export function importCsv(csv: string): readonly Todo[] {
const reading = readTodoCsv(csv);
if (reading.problems.length > 0) throw new Error(reading.problems[0].thrown);
return reading.todos;
}validateTodoCsv 13 tests
export function validateTodoCsv(csv: string): CsvValidation
| csv | string | the same text importCsv reads |
| returns | CsvValidation | every problem in the file, row by row, instead of only the first |
For example
validateTodoCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2…)→ valid true, rows 2, errors a good file is valid and counts its rowsvalidateTodoCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order )→ valid true, rows 0, errors a header with no rows is a valid, empty filevalidateTodoCsv()→ valid false, rows 0, errors ×1 empty text has no header row
import { validateTodoCsv } from "#fune/todo.import-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 CsvRowError, type CsvValidation } from "./todo_import_csv_types.ts";
import { type Recurrence, type Todo, validateTodo } from "./todo_item.ts"; ← from todo.item ^1.0.0 · built alongside by fune
import { normaliseTags } from "./todo_normalise_tags.ts"; ← from todo.normalise-tags ^1.0.0 · built alongside by fune
const COLUMNS = [
"id", "title", "notes", "done", "priority", "due", "tags",
"recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
];
const HEADER_RULE = "the header must have the columns " + COLUMNS.join(", ");
const WHOLE = /^-?[0-9]{1,15}$/;
/** One problem, and the message importCsv throws when it is the first. */
export interface TodoCsvProblem {
error: CsvRowError;
thrown: string;
}
/** What reading a whole file found: the good rows' todos, and every problem. */
export interface TodoCsvReading {
todos: Todo[];
rows: number;
problems: TodoCsvProblem[];
}
/**
* One RFC 4180 record starting at `start`: its fields and where the next
* record starts (after its CRLF or LF), or the reason it cannot be read.
* Inside quotes everything, CR and LF included, is kept as it is.
*/
function recordAt(text: string, start: number): { fields: string[]; next: number } | string {
const fields: string[] = [];
let pos = start;
for (;;) {
let value = "";
if (text[pos] === '"') {
pos++;
for (;;) {
const quote = text.indexOf('"', pos);
if (quote < 0) return "a quoted field is not closed";
value += text.slice(pos, quote);
if (text[quote + 1] === '"') {
value += '"';
pos = quote + 2;
} else {
pos = quote + 1;
break;
}
}
if (pos < text.length && text[pos] !== "," && text[pos] !== "\r" && text[pos] !== "\n") {
return "text after the closing quote of a field";
}
} else {
let end = pos;
while (end < text.length && text[end] !== "," && text[end] !== "\r" && text[end] !== "\n") {
if (text[end] === '"') return "a field with a double quote in it must be quoted";
end++;
}
value = text.slice(pos, end);
pos = end;
}
fields.push(value);
if (pos >= text.length) return { fields, next: pos };
if (text[pos] === ",") {
pos++;
} else if (text[pos] === "\n") {
return { fields, next: pos + 1 };
} else if (text[pos + 1] === "\n") {
return { fields, next: pos + 2 };
} else {
return "a CR outside quotes must be followed by LF";
}
}
}
/** Where each of COLUMNS sits in the file's header, or what is wrong with it. */
function columnsAt(names: string[]): number[] | string {
const at = new Map<string, number>();
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (!COLUMNS.includes(name)) return `${HEADER_RULE}: unknown column "${name}"`;
if (at.has(name)) return `${HEADER_RULE}: column "${name}" appears twice`;
at.set(name, i);
}
const out: number[] = [];
for (const name of COLUMNS) {
const i = at.get(name);
if (i === undefined) return `${HEADER_RULE}: missing column "${name}"`;
out.push(i);
}
return out;
}
function wholeNumber(text: string): number | null {
if (!WHOLE.test(text)) return null;
const n = Number(text);
return n === 0 ? 0 : n; // "-0" is 0, not JavaScript's -0
}
/**
* Reads the whole file, collecting every problem rather than stopping at the
* first. Checks run in the order importCsv 1.0.0 ran them, so the first
* problem is the one it threw. A header or a record that cannot be split
* into fields ends the reading, since the rows after it cannot be found.
*/
export function readTodoCsv(csv: string): TodoCsvReading {
const text = csv.startsWith("") ? csv.slice(1) : csv;
const todos: Todo[] = [];
const problems: TodoCsvProblem[] = [];
const ids = new Set<string>();
let at: number[] | null = null;
let row = 0;
let rows = 0;
let pos = 0;
const add = (field: string | null, message: string, thrown: string) =>
problems.push({ error: { row, field, message }, thrown });
while (pos < text.length) {
if (text[pos] === "\n") {
pos++;
continue;
}
if (text[pos] === "\r" && text[pos + 1] === "\n") {
pos += 2;
continue;
}
row++;
const record = recordAt(text, pos);
if (typeof record === "string") {
add(null, record, `row ${row}: ${record}`);
break;
}
pos = record.next;
const fields = record.fields;
if (at === null) {
const header = columnsAt(fields);
if (typeof header === "string") {
add(null, header, header);
break;
}
at = header;
continue;
}
rows++;
if (fields.length !== COLUMNS.length) {
const message = `expected ${COLUMNS.length} fields, found ${fields.length}`;
add(null, message, `row ${row}: ${message}`);
continue;
}
const before = problems.length;
const plain = (field: string, message: string) => add(field, message, `row ${row}: ${message}`);
const [id, title, notes, done, priority, due, tags, frequency, interval, anchor, createdAt, completedAt, order] =
(at as number[]).map((i) => fields[i]);
const doneRead = done === "true" || done === "false";
if (!doneRead) plain("done", `done must be true or false, found "${done}"`);
const filled = [frequency, interval, anchor].filter((t) => t !== "").length;
let recurrence: Recurrence | null = null;
let recurrenceRead = true;
if (filled === 3) {
const every = wholeNumber(interval);
if (every === null) {
plain("recurrenceInterval", `recurrenceInterval must be a whole number, found "${interval}"`);
recurrenceRead = false;
} else {
recurrence = { frequency: frequency as Recurrence["frequency"], interval: every, anchor };
}
} else if (filled !== 0) {
plain("recurrence", "fill in all three recurrence columns or leave them all empty");
recurrenceRead = false;
}
const position = wholeNumber(order);
if (position === null) plain("order", `order must be a whole number, found "${order}"`);
const todo: Todo = {
id,
title,
notes: notes === "" ? null : notes,
done: done === "true",
priority: priority as Todo["priority"],
due: due === "" ? null : due,
tags: normaliseTags(tags.split(" ")),
recurrence,
createdAt,
completedAt: completedAt === "" ? null : completedAt,
order: position ?? 0,
};
// A column that could not be read has already been reported; the
// stand-in value must not raise a second, misleading message.
const errors = validateTodo(todo).errors;
for (const field of Object.keys(errors)) {
if (field === "completedAt" && !doneRead) continue;
if (field === "recurrence" && !recurrenceRead) continue;
if (field === "order" && position === null) continue;
add(field, errors[field], `row ${row}: ${field}: ${errors[field]}`);
}
if (!("id" in errors)) {
if (ids.has(id)) plain("id", `duplicate id "${id}"`);
ids.add(id);
}
if (problems.length === before) todos.push(todo);
}
if (at === null && problems.length === 0) {
row = 1;
add(null, "the CSV has no header row", "the CSV has no header row");
}
return { todos, rows, problems };
}
/**
* Every problem in a todo CSV file, row by row, for a screen that lists them
* all: the row (header = 1), the field or column, and the message. The
* file is valid exactly when importCsv would accept it.
*/
export function validateTodoCsv(csv: string): CsvValidation {
const reading = readTodoCsv(csv);
return { valid: reading.problems.length === 0, rows: reading.rows, errors: reading.problems.map((p) => p.error) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, 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.import-csv
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add todo.import-csv --only importCsv
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.import-csv-1.1.0-typescript.fune, or fetch it from a terminal with fune pull todo.import-csv@1.1.0:typescript.
The whole function, every language, is one file too: todo.import-csv-1.1.0.fune, 67,710 bytes, sha256 b929c1dc7a055e568d43fe74cd107a4a7846eb0bd4b458f321a6feb040f764b1. 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.importCsv
// fune: before todo.import-csv.validateTodoCsv
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.import-csv.importCsv
// fune: after todo.import-csv.validateTodoCsv
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 a 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.<fn> 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.
importCsv 46 tests
| 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" |
validateTodoCsv 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a good file is valid and counts its rows | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2… | → | valid true, rows 2, errors |
| a header with no rows is a valid, empty file | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order | → | valid true, rows 0, errors |
| empty text has no header row | → | valid false, rows 0, errors ×1 | |
| every bad row is listed, and a good row between them is not | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,,0 t2, … | → | valid false, rows 4, errors ×6 |
| one row with three columns that cannot be read lists all three | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,maybe,none,,,weekly,,2026-09-28,2026-09-28T… | → | valid false, rows 1, errors ×3 |
| an unreadable done does not also blame the completion time | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,2026-09-… | → | valid false, rows 1, errors ×1 |
| an unreadable interval is reported once, not again as a bad repeat | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Water plants,,false,none,2026-09-28,,daily,1.5,2026-0… | → | valid false, rows 1, errors ×1 |
| a duplicate id is listed alongside the row's other problem | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t1… | → | valid false, rows 2, errors ×2 |
| a row with the wrong number of fields does not stop the rows after it | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk t2,Call mum,,false,none,,,,,,2026-09-28T09:… | → | valid false, rows 3, errors ×2 |
| an unclosed quote ends the check at that row, since the rows after it cannot be found | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 t2… | → | valid false, rows 1, errors ×1 |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a header without every column is one problem on row 1 | id,title t1,Buy milk | → | valid false, rows 0, errors ×1 |
| two rows without an id each say so, and are not duplicates of each other | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 ,Cal… | → | valid false, rows 2, errors ×2 |
| rows count records, not lines: a byte order mark and blank lines are skipped | id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0 … | → | valid false, rows 2, errors ×1 |
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.
## New in 1.1.0: every problem at once
`validateTodoCsv(csv)` reads the same file and returns every problem instead of throwing the first, so an import screen can list them all and the person can fix the file in one go:
{"valid": false, "rows": 4, "errors": [
{"row": 2, "field": "done", "message": "done must be true or false, found \"yes\""},
{"row": 3, "field": "title", "message": "Enter a title."},
{"row": 5, "field": "priority", "message": "Choose a priority: none, low, medium or high."},
{"row": 5, "field": "due", "message": "Enter the due date as a real date, YYYY-MM-DD."}
]}- `row` counts records with the header as row 1, as importCsv does. `rows` is how many todo rows were read (the header not counted). - `field` is the todo field `validateTodo` names (`title`, `due`, `completedAt`...) or the CSV column that could not be read (`done`, `recurrenceInterval`, `order`; `recurrence` when only some of the three repeat columns are filled; `id` for a duplicate). It is null when the row as a whole is wrong (the wrong number of fields, a quoting problem) and for header problems. - `message` is importCsv's message without the `row N:` prefix, and without the `field:` prefix for `validateTodo`'s messages. - Within a row, problems come in the order importCsv checks them: the field count, `done`, the repeat columns, `order`, then `validateTodo`'s messages in field order, then a duplicate id. So the first problem in the list is exactly what importCsv throws, and the file is valid exactly when importCsv accepts it. - **No knock-on messages.** A column that cannot be read is reported once: an unreadable `done` does not also blame `completedAt`, and an unreadable interval does not also give the "repeat every 1 to 999" message. - A duplicate id is reported even when the row has other problems, and every row's id counts, so fixing the first problem does not reveal a new one. Rows without an id say so and are not duplicates of each other. - **What stops the check:** a header that is not exactly the 13 columns (one problem, row 1), no header at all, and a record that cannot be split into fields (an unclosed quote, text after a closing quote, a bare quote or CR). After that the rows cannot be found reliably, so the list ends there.
`importCsv` is unchanged: it now reads the file through the same code and throws the first problem, word for word as 1.0.0 did, and every 1.0.0 vector is kept. 1.0.0 was one file; 1.1.0 is a group of two (see `spec/AUTHORING.md`, Groups), so `import { importCsv } from "#fune/todo.import-csv@^1"` keeps working.