// 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; }; 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(); 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 = {}; 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 { 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 { 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.`], }; }