Read an import file without guessing what it means
First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.
Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.
Three decisions where the easy choice would have been silent corruption:
- A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
as a contract end, and any rule invented here creates people not yet
born.
- "31.02.2026" is refused. Date turns it into March 3rd without complaint.
- An unrecognised value in a yes/no column is an error, not "no". Read the
other way, a typo in "Betriebsrat" quietly removes someone's dismissal
protection.
CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.
Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.
One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.
My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
242
lib/import/parse.ts
Normal file
242
lib/import/parse.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// Einlesen einer Importdatei — XLSX oder CSV.
|
||||
//
|
||||
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
|
||||
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
|
||||
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
|
||||
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
|
||||
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
|
||||
export type ImportRow = {
|
||||
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
|
||||
zeile: number;
|
||||
werte: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ImportSheet = {
|
||||
name: string;
|
||||
spalten: string[];
|
||||
zeilen: ImportRow[];
|
||||
};
|
||||
|
||||
export type ParseResult = {
|
||||
blaetter: ImportSheet[];
|
||||
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
|
||||
fehler: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Trennzeichen einer CSV-Datei bestimmen.
|
||||
*
|
||||
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
|
||||
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
|
||||
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
|
||||
* keine der erwarteten Spalten", was in die Irre führt.
|
||||
*
|
||||
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
|
||||
*/
|
||||
export function trennzeichenErkennen(kopfzeile: string): string {
|
||||
const kandidaten = [";", ",", "\t", "|"];
|
||||
let bestes = ";";
|
||||
let meiste = -1;
|
||||
for (const k of kandidaten) {
|
||||
let anzahl = 0;
|
||||
let inAnfuehrung = false;
|
||||
for (let i = 0; i < kopfzeile.length; i++) {
|
||||
const c = kopfzeile[i];
|
||||
if (c === '"') inAnfuehrung = !inAnfuehrung;
|
||||
else if (c === k && !inAnfuehrung) anzahl++;
|
||||
}
|
||||
if (anzahl > meiste) {
|
||||
meiste = anzahl;
|
||||
bestes = k;
|
||||
}
|
||||
}
|
||||
return bestes;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
|
||||
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
|
||||
* Anführungszeichen als Escape, CRLF wie LF.
|
||||
*
|
||||
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
|
||||
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
|
||||
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
|
||||
* verschiebt und die Zeile plausibel falsch importiert wird.
|
||||
*/
|
||||
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
|
||||
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
|
||||
|
||||
const zeilen: string[][] = [];
|
||||
let feld = "";
|
||||
let zeile: string[] = [];
|
||||
let inAnfuehrung = false;
|
||||
|
||||
for (let i = 0; i < ohneBom.length; i++) {
|
||||
const c = ohneBom[i];
|
||||
|
||||
if (inAnfuehrung) {
|
||||
if (c === '"') {
|
||||
if (ohneBom[i + 1] === '"') {
|
||||
feld += '"';
|
||||
i++;
|
||||
} else {
|
||||
inAnfuehrung = false;
|
||||
}
|
||||
} else {
|
||||
feld += c;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '"') {
|
||||
inAnfuehrung = true;
|
||||
} else if (c === trenner) {
|
||||
zeile.push(feld);
|
||||
feld = "";
|
||||
} else if (c === "\n") {
|
||||
zeile.push(feld);
|
||||
zeilen.push(zeile);
|
||||
zeile = [];
|
||||
feld = "";
|
||||
} else if (c !== "\r") {
|
||||
feld += c;
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte Zeile ohne abschliessenden Umbruch.
|
||||
if (feld !== "" || zeile.length > 0) {
|
||||
zeile.push(feld);
|
||||
zeilen.push(zeile);
|
||||
}
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
|
||||
function spaltenName(roh: unknown): string {
|
||||
return String(roh ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zellwert als Zeichenkette.
|
||||
*
|
||||
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
|
||||
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
|
||||
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
|
||||
* Datum sein soll, und kann einen Fehler melden statt zu raten.
|
||||
*/
|
||||
function zellText(wert: ExcelJS.CellValue): string {
|
||||
if (wert === null || wert === undefined) return "";
|
||||
if (wert instanceof Date) {
|
||||
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
|
||||
// schieben und in Österreich einen Tag zu früh ausgeben.
|
||||
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
|
||||
const t = String(wert.getUTCDate()).padStart(2, "0");
|
||||
return `${wert.getUTCFullYear()}-${m}-${t}`;
|
||||
}
|
||||
if (typeof wert === "object") {
|
||||
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
|
||||
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
|
||||
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
|
||||
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
|
||||
if (o.error !== undefined) return String(o.error);
|
||||
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
|
||||
if (o.text !== undefined) return String(o.text);
|
||||
return "";
|
||||
}
|
||||
return String(wert);
|
||||
}
|
||||
|
||||
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
|
||||
const fehler: string[] = [];
|
||||
const kopf = (matrix[0] ?? []).map(spaltenName);
|
||||
|
||||
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
|
||||
const gesehen = new Set<string>();
|
||||
for (const s of kopf) {
|
||||
if (!s) continue;
|
||||
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
|
||||
gesehen.add(s);
|
||||
}
|
||||
|
||||
const zeilen: ImportRow[] = [];
|
||||
for (let i = 1; i < matrix.length; i++) {
|
||||
const roh = matrix[i];
|
||||
const werte: Record<string, string> = {};
|
||||
let leer = true;
|
||||
for (let j = 0; j < kopf.length; j++) {
|
||||
const spalte = kopf[j];
|
||||
if (!spalte) continue;
|
||||
const wert = (roh[j] ?? "").trim();
|
||||
werte[spalte] = wert;
|
||||
if (wert !== "") leer = false;
|
||||
}
|
||||
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
|
||||
// Einträge) und sind keine Fehler.
|
||||
if (!leer) zeilen.push({ zeile: i + 1, werte });
|
||||
}
|
||||
|
||||
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
|
||||
}
|
||||
|
||||
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
|
||||
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
|
||||
const mappe = new ExcelJS.Workbook();
|
||||
try {
|
||||
await mappe.xlsx.load(daten);
|
||||
} catch {
|
||||
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
|
||||
}
|
||||
|
||||
const blaetter: ImportSheet[] = [];
|
||||
const fehler: string[] = [];
|
||||
|
||||
mappe.eachSheet((arbeitsblatt) => {
|
||||
const matrix: string[][] = [];
|
||||
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
|
||||
const werte: string[] = [];
|
||||
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
|
||||
const roh = zeile.values as ExcelJS.CellValue[];
|
||||
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
|
||||
matrix.push(werte);
|
||||
});
|
||||
if (matrix.length === 0) return;
|
||||
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
|
||||
blaetter.push(blatt);
|
||||
fehler.push(...f);
|
||||
});
|
||||
|
||||
return { blaetter, fehler };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine CSV-Datei als *ein* Blatt.
|
||||
*
|
||||
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
|
||||
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
|
||||
* genau wie die Blätter einer Mappe zusammensetzen.
|
||||
*/
|
||||
export function csvLesen(text: string, dateiname: string): ParseResult {
|
||||
const matrix = csvZerlegen(text);
|
||||
if (matrix.length === 0) return { blaetter: [], fehler: [`„${dateiname}“ ist leer.`] };
|
||||
const name = dateiname.replace(/\.[^.]+$/, "").trim();
|
||||
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
|
||||
return { blaetter: [blatt], fehler };
|
||||
}
|
||||
|
||||
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
|
||||
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
|
||||
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
||||
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
|
||||
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
|
||||
return {
|
||||
blaetter: [],
|
||||
fehler: [`„${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user