Functional Weave
Code in Python

todo.import-csv@1.0.0

impl/typescript.ts

5,379 bytes · the TypeScript implementation · view 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;
}