mirror of
https://git.stupid.fish/teidesu/scripts.git
synced 2025-07-28 02:32:11 +10:00
chore: update public repo
This commit is contained in:
parent
25d88cb28b
commit
8c04afc6d2
1 changed files with 147 additions and 0 deletions
147
utils/csv.ts
Normal file
147
utils/csv.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import { FramedReader, type IReadable, TextDelimiterCodec } from '@fuman/io'
|
||||||
|
|
||||||
|
interface CsvReaderOptions {
|
||||||
|
/** @default '\n' */
|
||||||
|
lineDelimiter: string
|
||||||
|
/** @default ',' */
|
||||||
|
delimiter: string
|
||||||
|
/** @default '"' */
|
||||||
|
quote: string
|
||||||
|
/** @default '"' */
|
||||||
|
quoteEscape: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if true, missing values in a line will be treated as empty strings
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
assumeEmptyValues: boolean
|
||||||
|
|
||||||
|
/** whether to treat header line as a data line */
|
||||||
|
includeHeader: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CsvReader<const Fields extends string[] = string[]> {
|
||||||
|
#codec: FramedReader<string>
|
||||||
|
readonly options: CsvReaderOptions
|
||||||
|
#schema?: Fields
|
||||||
|
constructor(
|
||||||
|
stream: IReadable,
|
||||||
|
options: Partial<CsvReaderOptions> & {
|
||||||
|
/** fields that are expected in the csv */
|
||||||
|
schema?: Fields
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.options = {
|
||||||
|
lineDelimiter: '\n',
|
||||||
|
delimiter: ',',
|
||||||
|
quote: '"',
|
||||||
|
quoteEscape: '"',
|
||||||
|
assumeEmptyValues: false,
|
||||||
|
includeHeader: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#codec = new FramedReader(stream, new TextDelimiterCodec(this.options.lineDelimiter))
|
||||||
|
this.#schema = options.schema
|
||||||
|
|
||||||
|
if (options.includeHeader) {
|
||||||
|
if (!options.schema) throw new Error('schema is required if includeHeader is true')
|
||||||
|
this.#header = options.schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#header?: string[]
|
||||||
|
|
||||||
|
async read(): Promise<Record<Fields[number], string> | null> {
|
||||||
|
let line = await this.#codec.read()
|
||||||
|
if (!line) return null
|
||||||
|
|
||||||
|
line = line.trim()
|
||||||
|
if (line === '') return this.read()
|
||||||
|
|
||||||
|
if (!this.#header) {
|
||||||
|
this.#header = line.split(this.options.delimiter).map(s => s.trim())
|
||||||
|
if (JSON.stringify(this.#schema!) !== JSON.stringify(this.#header)) {
|
||||||
|
throw new Error(`schema and header are the same (expected ${this.#schema!.join(', ')}; got ${this.#header.join(', ')})`)
|
||||||
|
}
|
||||||
|
return this.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj: Record<string, string> = {}
|
||||||
|
|
||||||
|
let insideQuote = false
|
||||||
|
let currentFieldIdx = 0
|
||||||
|
let currentValue = ''
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] === this.options.quoteEscape) {
|
||||||
|
if (insideQuote && line[i + 1] === this.options.quote) {
|
||||||
|
i++
|
||||||
|
currentValue += this.options.quote
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line[i] === this.options.quote) {
|
||||||
|
if (!insideQuote) {
|
||||||
|
if (currentValue !== '') {
|
||||||
|
throw new Error('unexpected open quote mid-value')
|
||||||
|
}
|
||||||
|
insideQuote = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i !== line.length - 1 && line[i + 1] !== this.options.delimiter) {
|
||||||
|
console.log(i, line.length, line[i + 1])
|
||||||
|
throw new Error(`unexpected close quote mid-value at ${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
insideQuote = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insideQuote) {
|
||||||
|
currentValue += line[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line[i] === this.options.delimiter) {
|
||||||
|
obj[this.#header[currentFieldIdx]] = currentValue
|
||||||
|
currentFieldIdx += 1
|
||||||
|
currentValue = ''
|
||||||
|
if (currentFieldIdx > this.#header.length) {
|
||||||
|
throw new Error('too many fields')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue += line[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
obj[this.#header[currentFieldIdx++]] = currentValue
|
||||||
|
|
||||||
|
if (currentFieldIdx < this.#header.length) {
|
||||||
|
if (this.options.assumeEmptyValues) {
|
||||||
|
for (let i = currentFieldIdx; i < this.#header.length; i++) {
|
||||||
|
obj[this.#header[i]] = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`missing values for fields: ${this.#header.slice(currentFieldIdx).join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj as Record<Fields[number], string>
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
const iter: AsyncIterableIterator<Record<Fields[number], string>> = {
|
||||||
|
next: async () => {
|
||||||
|
const obj = await this.read()
|
||||||
|
if (!obj) return { done: true, value: undefined }
|
||||||
|
return { done: false, value: obj }
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]: () => iter,
|
||||||
|
}
|
||||||
|
|
||||||
|
return iter
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue