Files
2026-11-14 09:56:45 +00:00

80 lines
2.9 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<T>(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
}