// What the import form reads out of a file in the browser: a small CSV reader (RFC 4180: quoted fields, // doubled quotes, LF or CRLF) and the rows as the service takes them. No component here, so a test reads // it directly; the service still validates every row and reports the ones it refuses. import { IMPORT_CHUNK_ROWS, type ImportRow } from "../contract/index.js" const REQUIRED = ["code", "name", "base_unit"] as const /** The records of a CSV text, each a list of fields. Blank lines are records of one empty field. */ export function csvRows(text: string): string[][] { const records: string[][] = [] let record: string[] = [] let field = "" let quoted = false for (let at = 0; at < text.length; at += 1) { const char = text[at] if (quoted) { if (char !== '"') field += char else if (text[at + 1] === '"') { field += '"' at += 1 } else quoted = false } else if (char === '"') quoted = true else if (char === ",") { record.push(field) field = "" } else if (char === "\n" || char === "\r") { if (char === "\r" && text[at + 1] === "\n") at += 1 record.push(field) records.push(record) record = [] field = "" } else field += char } if (field !== "" || record.length > 0) { record.push(field) records.push(record) } return records } export type ParsedProducts = { kind: "rows"; rows: ImportRow[] } | { kind: "unreadable"; missing: string[] } /** The product rows of a CSV file, by header name, or the required columns its header lacks. */ export function parseProductCsv(text: string): ParsedProducts { const records = csvRows(text.replace(/^/, "")) const header = (records[0] ?? []).map((name) => name.trim().toLowerCase()) const missing = REQUIRED.filter((name) => !header.includes(name)) if (missing.length > 0) return { kind: "unreadable", missing } const column = (record: readonly string[], name: string): string => { const at = header.indexOf(name) return at < 0 ? "" : (record[at] ?? "").trim() } const rows: ImportRow[] = [] records.slice(1).forEach((record, at) => { if (record.every((value) => value.trim() === "")) return const description = column(record, "description") const revision = column(record, "revision") rows.push({ // The line a person sees in the file: the header is line 1. sourceRow: at + 2, ...(revision === "" ? {} : { revision }), product: { code: column(record, "code"), name: column(record, "name"), baseUnit: column(record, "base_unit"), ...(description === "" ? {} : { description }), }, }) }) return { kind: "rows", rows } } /** Rows in chunks of the size one request may carry. */ export function chunksOf(rows: readonly T[]): T[][] { const chunks: T[][] = [] for (let at = 0; at < rows.length; at += IMPORT_CHUNK_ROWS) chunks.push(rows.slice(at, at + IMPORT_CHUNK_ROWS)) return chunks }