Files
template-app/modules/catalog/ui/import-rows.ts
T
2026-11-14 09:56:45 +00:00

80 lines
2.9 KiB
TypeScript
Raw 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
}