Functional Weave
Code in Python

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.

  1. import_csv (csv: string) -> Todo[]
  2. 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]
csvstringthe file's text; a UTF-8 byte order mark and LF line endings are accepted
returnsTodo[]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 list
  • import_csv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order) → the header without a final line break
  • import_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
impl/python/import_csv.py · 12 lines · open · raw

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

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.todos

validate_todo_csv 13 tests

def validate_todo_csv(csv: str) -> CsvValidation
csvstringthe same text importCsv reads
returnsCsvValidationevery 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 rows
  • validate_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 file
  • validate_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
impl/python/validate_todo_csv.py · 201 lines · open · raw

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

import 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
Download for Python todo.import-csv-1.1.0-python.fune · 46,946 bytes sha256 2f9fb37fb701699e859119d86f49e94ee30b8b9cf2fc70ec98873de5f79bd916

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

CaseArgumentsExpected
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
CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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.

Files

PathBytes
README.md6,846
impl/python/import_csv.py407
impl/python/validate_todo_csv.py7,472
impl/rust/import_csv.rs768
impl/rust/validate_todo_csv.rs10,649
impl/typescript/import_csv.ts639
impl/typescript/validate_todo_csv.ts7,729
vectors.json25,621