summaryrefslogtreecommitdiffstats
path: root/src/csv.ts
blob: 1a9431c494734e15d3c22d132f1140a6c37030ce (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
 * A minimal RFC 4180 reader, for the `/csv` batch endpoint.
 *
 * Values may contain commas and are then double-quoted with `""` escaping, so
 * splitting on `,` corrupts every row that carries a street name like
 * `"Rua Um, Dois"`. This is here because the platform ships no CSV parser and
 * the library ships no dependencies.
 *
 * @module
 */

/** One parsed CSV record, keyed by the header row. */
export type CsvRecord = Record<string, string>;

function parseRows(text: string): string[][] {
  const rows: string[][] = [];
  let row: string[] = [];
  let field = "";
  let quoted = false;
  let started = false;

  for (let i = 0; i < text.length; i += 1) {
    const char = text[i];

    if (quoted) {
      if (char === '"') {
        if (text[i + 1] === '"') {
          field += '"';
          i += 1;
        } else {
          quoted = false;
        }
      } else {
        field += char;
      }
      continue;
    }

    if (char === '"') {
      quoted = true;
      started = true;
    } else if (char === ",") {
      row.push(field);
      field = "";
      started = true;
    } else if (char === "\n") {
      row.push(field);
      rows.push(row);
      row = [];
      field = "";
      started = false;
    } else if (char !== "\r") {
      field += char;
      started = true;
    }
  }

  if (started || field !== "" || row.length > 0) {
    row.push(field);
    rows.push(row);
  }

  return rows;
}

/**
 * Parse CSV text into records keyed by its header row.
 *
 * A leading byte-order mark is dropped, `CRLF` and `LF` are both accepted, and
 * a row shorter than the header has its remaining columns filled with the
 * empty string rather than `undefined`. An empty input yields no records.
 */
export function parseCsv(text: string): CsvRecord[] {
  const rows = parseRows(text.replace(/^\uFEFF/, ""));
  const header = rows.shift();
  if (header === undefined) {
    return [];
  }

  const records: CsvRecord[] = [];
  for (const row of rows) {
    if (row.length === 1 && row[0] === "") {
      continue;
    }
    const record: CsvRecord = {};
    for (let i = 0; i < header.length; i += 1) {
      record[header[i] as string] = row[i] ?? "";
    }
    records.push(record);
  }
  return records;
}