Functional Weave
Code in TypeScript

todo.import-csv

Read todos from a CSV file (RFC 4180) as todo.export-csv writes it, checking every row, with row-numbered errors.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 46 tests, run in TypeScript, Python and Rust.

What it does

Reads todos from CSV text, as todo.export-csv writes it or as a spreadsheet saves it, and checks every row. It returns the todos in file order, each one valid by todo.item's `validateTodo`, or throws on the first problem with the row it is in.

## The file format

For example

  • importCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order ) → the header alone is an empty list
  • importCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order) → the header without a final line break
  • importCsv(id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order t1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0 ) → ×1 one plain row: empty fields become null, false, order 0

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function importCsv(csv: string): readonly 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

Your code names it in one line, in the file that uses it

import { importCsv } from "#fune/todo.import-csv@^1";
impl/typescript.ts · 156 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 { 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}$/;

function fail(row: number, message: string): never {
  throw new Error(`row ${row}: ${message}`);
}

/**
 * One RFC 4180 record starting at `pos`: its fields, and where the next
 * record starts (after its CRLF or LF). Inside quotes everything, CR and LF
 * included, is kept as it is.
 */
function readRecord(text: string, start: number, row: number): [string[], number] {
  const fields: string[] = [];
  let pos = start;
  for (;;) {
    let value = "";
    if (text[pos] === '"') {
      pos++;
      for (;;) {
        const quote = text.indexOf('"', pos);
        if (quote < 0) fail(row, "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") {
        fail(row, "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] === '"') fail(row, "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, pos];
    if (text[pos] === ",") {
      pos++;
    } else if (text[pos] === "\n") {
      return [fields, pos + 1];
    } else if (text[pos + 1] === "\n") {
      return [fields, pos + 2];
    } else {
      fail(row, "a CR outside quotes must be followed by LF");
    }
  }
}

/** Where each of COLUMNS sits in the file's header. */
function readHeader(names: string[]): number[] {
  const at = new Map<string, number>();
  names.forEach((name, i) => {
    if (!COLUMNS.includes(name)) throw new Error(`${HEADER_RULE}: unknown column "${name}"`);
    if (at.has(name)) throw new Error(`${HEADER_RULE}: column "${name}" appears twice`);
    at.set(name, i);
  });
  return COLUMNS.map((name) => {
    const i = at.get(name);
    if (i === undefined) throw new Error(`${HEADER_RULE}: missing column "${name}"`);
    return i;
  });
}

function whole(row: number, column: string, text: string): number {
  if (!WHOLE.test(text)) fail(row, `${column} must be a whole number, found "${text}"`);
  const n = Number(text);
  return n === 0 ? 0 : n; // "-0" is 0, not JavaScript's -0
}

function readTodo(fields: string[], at: number[], row: number): Todo {
  if (fields.length !== COLUMNS.length) fail(row, `expected ${COLUMNS.length} fields, found ${fields.length}`);
  const [id, title, notes, done, priority, due, tags, frequency, interval, anchor, createdAt, completedAt, order] =
    at.map((i) => fields[i]);
  if (done !== "true" && done !== "false") fail(row, `done must be true or false, found "${done}"`);
  const filled = [frequency, interval, anchor].filter((text) => text !== "").length;
  let recurrence: Recurrence | null = null;
  if (filled === 3) {
    recurrence = {
      frequency: frequency as Recurrence["frequency"],
      interval: whole(row, "recurrenceInterval", interval),
      anchor,
    };
  } else if (filled !== 0) {
    fail(row, "fill in all three recurrence columns or leave them all empty");
  }
  return {
    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: whole(row, "order", order),
  };
}

/**
 * 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.
 */
export function importCsv(csv: string): readonly Todo[] {
  const text = csv.startsWith("") ? csv.slice(1) : csv;
  const todos: Todo[] = [];
  const ids = new Set<string>();
  let at: number[] | null = null;
  let row = 0;
  let pos = 0;
  while (pos < text.length) {
    if (text[pos] === "\n") {
      pos++;
      continue;
    }
    if (text[pos] === "\r" && text[pos + 1] === "\n") {
      pos += 2;
      continue;
    }
    row++;
    const [fields, next] = readRecord(text, pos, row);
    pos = next;
    if (at === null) {
      at = readHeader(fields);
      continue;
    }
    const todo = readTodo(fields, at, row);
    const result = validateTodo(todo);
    const field = Object.keys(result.errors)[0];
    if (field !== undefined) fail(row, `${field}: ${result.errors[field]}`);
    if (ids.has(todo.id)) fail(row, `duplicate id "${todo.id}"`);
    ids.add(todo.id);
    todos.push(todo);
  }
  if (at === null) throw new Error("the CSV has no header row");
  return todos;
}

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
Download for TypeScript todo.import-csv-1.0.0-typescript.fune · 30,913 bytes sha256 f0dd07fcb818a9be76521a3c89815d6ffeb67e1a666547daa105651ad0710644

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.import-csv-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.import-csv@1.0.0:typescript.

The whole function, every language, is one file too: todo.import-csv-1.0.0.fune, 43,391 bytes, sha256 395dfb021064802d0bdf93d3ed3c090c104159ba61537513055acb08dc2a1eb9. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before todo.import-csv

after — your function gets the result and the arguments, and returns the final result.

// fune: after todo.import-csv

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace todo.item in todo.import-csv
// fune: replace todo.normalise-tags in todo.import-csv

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.import-csv --steps.

// fune: step todo.import-csv after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

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"

More from the author

CSV as in [RFC 4180, Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://www.rfc-editor.org/rfc/rfc4180). The same format is written by todo.export-csv and read by todo.import-csv.

- A header row, then one row per todo. todo.export-csv writes these 13 columns in this order: `id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order` - `done` is `true` or `false`. `tags` are joined with single spaces. The repeat takes three columns, all empty when the todo does not repeat. `order` and `recurrenceInterval` are decimal integers. A null field (`notes`, `due`, `completedAt`) is empty. - A field is quoted with double quotes exactly when it contains a comma, a double quote, CR or LF, and a double quote inside it is doubled. Nothing else is quoted, and spaces are kept as they are. - Every row, the last one included, ends with CRLF.

**Caveat for spreadsheets.** Nothing is escaped against formula injection: a title such as `=1+1` or `=HYPERLINK(...)` is written as it is, because prefixing it with `'` would change the title when the file is read back. If your app offers the file for opening in Excel, LibreOffice or Google Sheets, warn people, or write a separate spreadsheet export that escapes cells starting with `=`, `+`, `-` or `@`.

## What it accepts beyond that

Files come back from spreadsheets and other tools, so reading is a little wider than writing:

- CRLF or LF line endings (CR or LF inside a quoted field is kept as it is), an optional UTF-8 byte order mark at the start, and an optional line break after the last row. Empty lines are skipped. - The 13 columns in any order, each exactly once and nothing else. Names are matched exactly (`Title` is not `title`). - Any field may be quoted, needed or not. - Tags are split on spaces and normalised with todo.normalise-tags, so `#Home Work,` imports as `home work`. - `order` and `recurrenceInterval` accept leading zeros and a leading `-` (so a negative order is reported by `validateTodo`'s own message).

Empty `notes`, `due` and `completedAt` fields become null.

## Errors

Rows are records, counted from 1 for the header, so the first todo is row 2. A quoted field with line breaks is one row, and skipped empty lines are not counted. Rows are read in order and the first problem is thrown:

- `the CSV has no header row` (empty text, or only empty lines) - `the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: ` then `unknown column "colour"`, `column "title" appears twice` or `missing column "order"` - `row N: a quoted field is not closed` - `row N: text after the closing quote of a field` - `row N: a field with a double quote in it must be quoted` - `row N: a CR outside quotes must be followed by LF` (old Mac line endings) - `row N: expected 13 fields, found 12` - `row N: done must be true or false, found "yes"` (case-sensitive) - `row N: fill in all three recurrence columns or leave them all empty` - `row N: recurrenceInterval must be a whole number, found "1.5"` and the same for `order`: ASCII digits, an optional leading `-`, at most 15 digits (no `+`, spaces, decimals or exponents) - `row N: <field>: <message>`, the first of `validateTodo`'s messages in field order, e.g. `row 2: title: Remove the spaces around the title.` - `row N: duplicate id "t1"`

## Why it is shaped this way

All or nothing: a half-imported list is harder to fix than a file, so one bad row refuses the whole file and says where to look. The row number counts records rather than lines because that is what a spreadsheet shows, and a quoted note with line breaks is one spreadsheet row.

Unknown columns are refused rather than ignored, so a misspelt header (`compltedAt`) is caught instead of silently dropping data.

Files

PathBytes
README.md4,216
impl/python.py5,135
impl/rust.rs6,748
impl/typescript.ts5,379
vectors.json18,064