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.
- import_csv (csv: string) -> Todo[]
- validate_todo_csv (csv: string) -> CsvValidation
The types it declares, generated into your project
@dataclass(frozen=True)
class CsvRowError:
"""One problem in a todo CSV file."""
#: the record it is in, counting the header as row 1, as a spreadsheet numbers rows
row: int
#: the todo field or CSV column it is about; null when the row as a whole is wrong
field: Optional[str]
message: str
@dataclass(frozen=True)
class CsvValidation:
"""Every problem in a todo CSV file, so a screen can list them all at once."""
valid: bool
#: todo rows read, not counting the header
rows: int
#: in file order, and in check order within a row; empty when valid
errors: List[CsvRowError]
Once installed, your code imports each one from the group's module.
import_csv throws on bad input 46 tests
def import_csv(csv: str) -> List[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
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
from fune.todo.import_csv import import_csv # 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.
from typing import List
from .todo_import_csv_validate_todo_csv import read_todo_csv ← validateTodoCsv, another function of this group · built into the same file, even by a slim install
from .todo_item import Todo ← from todo.item ^1.0.0 · built alongside by fune
def import_csv(csv: str) -> List[Todo]:
"""Todos from CSV: 13 columns in any order, CRLF or LF, optional BOM; errors name the row (header = row 1)."""
reading = read_todo_csv(csv)
if reading.problems:
raise ValueError(reading.problems[0].thrown)
return reading.todosvalidate_todo_csv 13 tests
def validate_todo_csv(csv: str) -> 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
validate_todo_csv(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 rowsvalidate_todo_csv(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 filevalidate_todo_csv()→ valid false, rows 0, errors ×1 empty text has no header row
from fune.todo.import_csv import validate_todo_csv # 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 re
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple, Union
from .todo_import_csv_types import CsvRowError, CsvValidation
from .todo_item import Recurrence, Todo, validate_todo ← from todo.item ^1.0.0 · built alongside by fune
from .todo_normalise_tags import normalise_tags ← from todo.normalise-tags ^1.0.0 · built alongside by fune
_COLUMNS = (
"id", "title", "notes", "done", "priority", "due", "tags",
"recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
)
_HEADER_RULE = "the header must have the columns " + ", ".join(_COLUMNS)
_WHOLE = re.compile(r"-?[0-9]{1,15}")
@dataclass(frozen=True)
class TodoCsvProblem:
"""One problem, and the message import_csv raises when it is the first."""
error: CsvRowError
thrown: str
@dataclass(frozen=True)
class TodoCsvReading:
"""What reading a whole file found: the good rows' todos, and every problem."""
todos: List[Todo]
rows: int
problems: List[TodoCsvProblem]
def _record_at(text: str, pos: int) -> Union[Tuple[List[str], int], str]:
"""One RFC 4180 record from pos: its fields and where the next starts, or why it cannot be read."""
fields: List[str] = []
size = len(text)
while True:
if pos < size and text[pos] == '"':
pos += 1
parts: List[str] = []
while True:
quote = text.find('"', pos)
if quote < 0:
return "a quoted field is not closed"
parts.append(text[pos:quote])
if quote + 1 < size and text[quote + 1] == '"':
parts.append('"')
pos = quote + 2
else:
pos = quote + 1
break
if pos < size and text[pos] not in ",\r\n":
return "text after the closing quote of a field"
value = "".join(parts)
else:
end = pos
while end < size and text[end] not in ",\r\n":
if text[end] == '"':
return "a field with a double quote in it must be quoted"
end += 1
value = text[pos:end]
pos = end
fields.append(value)
if pos >= size:
return fields, pos
if text[pos] == ",":
pos += 1
elif text[pos] == "\n":
return fields, pos + 1
elif pos + 1 < size and text[pos + 1] == "\n":
return fields, pos + 2
else:
return "a CR outside quotes must be followed by LF"
def _columns_at(names: List[str]) -> Union[List[int], str]:
at: Dict[str, int] = {}
for i, name in enumerate(names):
if name not in _COLUMNS:
return f'{_HEADER_RULE}: unknown column "{name}"'
if name in at:
return f'{_HEADER_RULE}: column "{name}" appears twice'
at[name] = i
for name in _COLUMNS:
if name not in at:
return f'{_HEADER_RULE}: missing column "{name}"'
return [at[name] for name in _COLUMNS]
def _whole_number(text: str) -> Optional[int]:
return int(text) if _WHOLE.fullmatch(text) else None
def read_todo_csv(csv: str) -> TodoCsvReading:
"""Reads the whole file, collecting every problem; the first is the one import_csv 1.0.0 raised."""
text = csv[1:] if csv.startswith("") else csv
todos: List[Todo] = []
problems: List[TodoCsvProblem] = []
ids: Set[str] = set()
at: Optional[List[int]] = None
row = 0
rows = 0
pos = 0
size = len(text)
def add(field: Optional[str], message: str, thrown: str) -> None:
problems.append(TodoCsvProblem(error=CsvRowError(row=row, field=field, message=message), thrown=thrown))
def plain(field: str, message: str) -> None:
add(field, message, f"row {row}: {message}")
while pos < size:
if text[pos] == "\n":
pos += 1
continue
if text.startswith("\r\n", pos):
pos += 2
continue
row += 1
record = _record_at(text, pos)
if isinstance(record, str):
add(None, record, f"row {row}: {record}")
break
fields, pos = record
if at is None:
header = _columns_at(fields)
if isinstance(header, str):
add(None, header, header)
break
at = header
continue
rows += 1
if len(fields) != len(_COLUMNS):
message = f"expected {len(_COLUMNS)} fields, found {len(fields)}"
add(None, message, f"row {row}: {message}")
continue
before = len(problems)
(id_, title, notes, done, priority, due, tags, frequency, interval, anchor, created_at, completed_at,
order) = [fields[i] for i in at]
done_read = done in ("true", "false")
if not done_read:
plain("done", f'done must be true or false, found "{done}"')
filled = sum(1 for t in (frequency, interval, anchor) if t != "")
recurrence: Optional[Recurrence] = None
recurrence_read = True
if filled == 3:
every = _whole_number(interval)
if every is None:
plain("recurrenceInterval", f'recurrenceInterval must be a whole number, found "{interval}"')
recurrence_read = False
else:
recurrence = Recurrence(frequency=frequency, interval=every, anchor=anchor)
elif filled != 0:
plain("recurrence", "fill in all three recurrence columns or leave them all empty")
recurrence_read = False
position = _whole_number(order)
if position is None:
plain("order", f'order must be a whole number, found "{order}"')
todo = Todo(
id=id_,
title=title,
notes=None if notes == "" else notes,
done=done == "true",
priority=priority,
due=None if due == "" else due,
tags=normalise_tags(tags.split(" ")),
recurrence=recurrence,
created_at=created_at,
completed_at=None if completed_at == "" else completed_at,
order=0 if position is None else position,
)
# A column that could not be read has already been reported; the
# stand-in value must not raise a second, misleading message.
errors = validate_todo(todo).errors
for field, message in errors.items():
if field == "completedAt" and not done_read:
continue
if field == "recurrence" and not recurrence_read:
continue
if field == "order" and position is None:
continue
add(field, message, f"row {row}: {field}: {message}")
if "id" not in errors:
if id_ in ids:
plain("id", f'duplicate id "{id_}"')
ids.add(id_)
if len(problems) == before:
todos.append(todo)
if at is None and not problems:
row = 1
add(None, "the CSV has no header row", "the CSV has no header row")
return TodoCsvReading(todos=todos, rows=rows, problems=problems)
def validate_todo_csv(csv: str) -> CsvValidation:
"""Every problem in a todo CSV file, row by row: the row (header = 1), the field and the message."""
reading = read_todo_csv(csv)
return CsvValidation(valid=not reading.problems, rows=reading.rows, errors=[p.error for p in reading.problems])Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./todo.import-csv-1.1.0-python.fune, or fetch it from a terminal with fune pull todo.import-csv@1.1.0:python.
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.