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;
}