Zum Inhalt

Workshop CSV — vom ersten Export bis zum geprüften Import

CSV ist das Austauschformat, auf das sich alle einigen können: Excel, die Fachabteilung, das Nachbarsystem. Dieser Workshop baut in kleinen Schritten beides auf — eine Datei erzeugen und eine Datei einlesen — und zeigt an jeder Stelle, woran es in der Praxis scheitert.

Du brauchst keine Vorkenntnisse außer: du weißt, wie man ein Skript speichert und laufen lässt. Jeder Abschnitt ist für sich ausführbar; die Beispiele bauen inhaltlich, aber nicht technisch aufeinander auf.

Was du am Ende kannst

Eine CSV erzeugen, die ein deutsches Excel richtig öffnet. Eine fremde CSV einlesen, ohne an Trennzeichen oder Umlauten zu scheitern. Fehlerhafte Zeilen mit Zeilennummer melden statt still zu verlieren. Und wissen, warum vor manchen Werten plötzlich ein Apostroph steht.

Teil 0 — Wo du das ausprobierst

Am schnellsten geht es in der Skript-Konsole unter Verwaltung → Skripting → Konsole. Dort tippst du ein Skript ein, drückst Ausführen und siehst das Protokoll direkt darunter. Kein Formular, kein Speichern, kein Datensatz.

Das Menü Administration mit dem Eintrag Skript-Konsole

Dort tippst du das Skript in das Feld JavaScript-Code, drückst Ausführen (oder Strg + Eingabe) und liest das Ergebnis unter LOGS:

Die Skript-Konsole mit einem ersten CSV-Ergebnis unter LOGS

Alles, was hier steht, läuft genauso in einem Formular-Hook oder in einem Zeitplaner-Job. Nur die Quelle der Daten ist dann eine andere.

Teil 1 — Deine erste CSV

Eine CSV ist Text: eine Zeile je Datensatz, die Werte durch ein Zeichen getrennt. Du musst diesen Text nicht selbst zusammenbauen — csv.stringify macht aus einer Liste fertigen CSV-Text.

var daten = [
  { Artikel: "Schraube", Menge: 500 },
  { Artikel: "Mutter",   Menge: 120 }
];

log.info(csv.stringify(daten));

Im Protokoll steht:

Artikel,Menge
Schraube,500
Mutter,120

Drei Dinge sind dabei schon passiert, ohne dass du sie hinschreiben musstest:

  • Die Kopfzeile entstand aus den Schlüsseln des ersten Objekts.
  • Die Reihenfolge der Spalten ist die des ersten Objekts — alle weiteren Zeilen folgen ihr, auch wenn ihre Schlüssel anders sortiert sind.
  • Werte mit Komma, Anführungszeichen oder Zeilenumbruch werden in Anführungszeichen gesetzt; nur die, die es brauchen.

Wenn deine Daten schon als Liste von Listen vorliegen, geht das genauso — dann gibt es keine Kopfzeile, weil es keine Schlüssel gibt:

log.info(csv.stringify([
  ["Artikel", "Menge"],
  ["Schraube", 500],
  ["Mutter", 120]
]));

Teil 2 — Damit deutsches Excel sie richtig öffnet

Die Datei aus Teil 1 ist korrekt, aber ein deutsches Excel zeigt sie als eine einzige Spalte an. Das liegt nicht an KCForms: Excel erwartet auf einem deutschen System das Semikolon, und das Komma hält es für einen Dezimaltrenner.

Für eine Datei, die per Doppelklick richtig aufgeht, brauchst du drei Einstellungen:

var daten = [
  { Artikel: "Schraube", Menge: 500, Preis: 0.12 },
  { Artikel: "Mutter",   Menge: 120, Preis: 0.08 }
];

var text = csv.stringify(daten, {
  separator:  ";",       // deutsches Excel trennt mit Semikolon
  decimalSep: ",",       // 0.12 → 0,12, sonst ist es für Excel Text
  bom:        true       // damit Umlaute nicht zu Kästchen werden
});

log.info(text);
Einstellung Warum
separator: ";" Sonst landet die ganze Zeile in Spalte A.
decimalSep: "," Ohne sie ist 0.12 für deutsches Excel Text, und du kannst nicht damit rechnen.
bom: true Drei unsichtbare Bytes am Anfang, an denen Excel UTF-8 erkennt. Ohne sie wird aus „Müller" ein „Müller".

decimalSep wirkt nur auf Zahlen. Der Text "1.234 Stück" bleibt, wie er ist — sonst würde aus einem Inhalt ein anderer.

Excel trennt trotzdem nicht?

Manche Excel-Installationen richten sich nicht nach der Länder-Einstellung. Dann hilft sepLine: true: damit steht als erste Zeile sep=; in der Datei, und Excel folgt dieser Angabe. Lies die Datei aber nur mit Programmen, die diese Zeile kennen — für alle anderen ist sie eine Datenzeile. KCForms selbst erkennt sie beim Einlesen.

Teil 3 — Die Datei beim Nutzer ankommen lassen

Text im Protokoll hilft dir beim Entwickeln, nicht der Fachabteilung. Es gibt drei Wege, und sie unterscheiden sich darin, wem die Datei gehört.

Als Download anbieten

Der häufigste Fall: ein Knopf im Formular, ein Klick, die Datei liegt im Download-Ordner.

var p = new csv.StandardPrinter({ separator: ";", bom: true });
p.println(["Artikel", "Menge"]);
p.println(["Schraube", 500]);

var url = delivery.deliver(p.toByteArray(), { name: "artikel.csv", mime: "text/csv" });
view.add(new view.Link(url, "Artikelliste als CSV"));
log.info("Link steht bereit");

Hier steht bewusst der StandardPrinter und nicht csv.stringify: delivery.deliver will Bytes, keinen Text. Und das ist kein Formfehler — zu Bytes gehören Zeichensatz und BOM, und die entscheidet der Printer. Mit p.toByteArray() bekommst du genau die Datei, die beim Nutzer ankommt.

Hast du die Daten schon als Liste, kannst du dir die Schleife trotzdem sparen und den Printer nur zum Verpacken nehmen:

var daten = [{ Artikel: "Schraube", Menge: 500 }, { Artikel: "Mutter", Menge: 120 }];

var p = new csv.StandardPrinter({ separator: ";", bom: true });
for (var i = 0; i < daten.length; i++) p.println(daten[i]);   // Objekt = eine Zeile

var url = delivery.deliver(p.toByteArray(), { name: "artikel.csv", mime: "text/csv" });
log.info("fertig verpackt: " + url);

In einen Ordner schreiben

Für Übergaben an ein anderes System, das einen Ordner überwacht:

var text = csv.stringify([{ Artikel: "Schraube", Menge: 500 }],
                         { separator: ";", bom: true });

fs["/beispiele/csv-workshop/artikel.csv"].writeText(text);
log.info(fs["/beispiele/csv-workshop/artikel.csv"].size + " Bytes geschrieben");

bom: true wirkt hier auch im Text: csv.stringify stellt das BOM-Zeichen voran, und writeText schreibt es als die drei Bytes, an denen Excel UTF-8 erkennt.

An den Datensatz hängen

Damit die Datei dauerhaft beim Vorgang liegt — nachvollziehbar, auch Jahre später:

var text = csv.stringify([{ Artikel: "Schraube", Menge: 500 }], { separator: ";" });

ctx.attachments.add("artikel.csv", text);
log.info("angehängt");

Teil 4 — Eine CSV einlesen

Jetzt die andere Richtung. csv.parse liest eine ganze Datei auf einmal. Mit header: true bekommst du Objekte, deren Eigenschaften die Spaltennamen sind — das liest sich wie Fließtext:

var text = "Artikel;Menge\nSchraube;500\nMutter;120";

var zeilen = csv.parse(text, { header: true });
for (var i = 0; i < zeilen.length; i++) {
  log.info(zeilen[i].Artikel + ": " + zeilen[i].Menge);
}

Beachte: Im Beispiel steht kein separator. Das Trennzeichen wird erkannt — dazu gleich mehr.

Die eine Schleife, die du nicht nehmen darfst

Das Ergebnis von csv.parse ist eine Liste vom Server, kein JS-Array. Bei einem Namen fällt das auf: forEach gibt es auch in Java, und die Java-Fassung übergibt nur das Element, keinen Index.

// FALSCH — i ist undefined, die Meldung lautet "Zeile NaN"
zeilen.forEach(function (z, i) { log.info("Zeile " + (i + 2)); });

// RICHTIG
for (var i = 0; i < zeilen.length; i++) { log.info("Zeile " + (i + 2)); }

map, filter, reduce und slice sind nicht betroffen. Brauchst du keine Zeilennummer, ist forEach ohne zweiten Parameter völlig in Ordnung. Dasselbe gilt für db.query(...).

Alle Werte sind Text

csv.parse liefert Zeichenfolgen — auch bei 500. In einer Datei steht nichts, was eine Zahl von ihrer Schreibweise unterscheidet. Rechnen willst du mit kc.num:

var zeilen = csv.parse("Artikel;Menge\nSchraube;500\nMutter;120", { header: true });

var summe = 0;
for (var i = 0; i < zeilen.length; i++) summe += kc.num(zeilen[i].Menge);
log.info("Gesamt: " + summe);

kc.num versteht dabei auch "1.234,56" — den Fall, der aus deutschem Excel kommt. Siehe Hilfsfunktionen.

Teil 5 — Fremde Dateien: die drei üblichen Stolpersteine

Bis hier kam der Text aus deinem eigenen Skript. Eine Datei von außen bringt drei Probleme mit, und alle drei sind leise.

Das Trennzeichen

Ohne Angabe prüft KCForms ;, ,, Tabulator und | und nimmt das, das über die ersten Zeilen durchgehend dieselbe Spaltenzahl ergibt. Deshalb funktionieren beide Dateien ohne Zutun:

var mitSemikolon = csv.parse("a;b;c\n1;2;3");
var mitKomma     = csv.parse("a,b,c\n1,2,3");

log.info(mitSemikolon[0].length + " Spalten / " + mitKomma[0].length + " Spalten");

Weißt du das Trennzeichen sicher, gib es an — eine Angabe schlägt die Erkennung immer. Das ist der ehrlichere Weg, wenn ein Partnersystem eine feste Zusage gemacht hat.

Die Kodierung

Excel speichert „CSV (Trennzeichen-getrennt)" auf einem deutschen Windows nicht als UTF-8. Liest man so eine Datei trotzdem als UTF-8, wird aus „Müller" ein Zeichen, das niemand bestellt hat — ohne Fehlermeldung.

// Eine Datei anlegen, wie Excel sie schreiben würde
fs["/beispiele/csv-workshop/ansi.csv"].writeText("Name;Ort\nMüller;Köln\n", "windows-1252");

var falsch   = csv.parse(fs["/beispiele/csv-workshop/ansi.csv"], { header: true });
var richtig  = csv.parse(fs["/beispiele/csv-workshop/ansi.csv"], { header: true, encoding: "auto" });

log.info("ohne auto: " + falsch[0].Name);
log.info("mit auto: " + richtig[0].Name);

encoding: "auto" liest streng als UTF-8 und wechselt nur dann auf Windows-1252, wenn die Bytes kein gültiges UTF-8 sind. Die Prüfung geht nur in diese Richtung — eine UTF-8-Datei wird nie fälschlich als ANSI gelesen.

Faustregel für Importe von außen

{ header: true, encoding: "auto", trim: true } — Spaltennamen nutzen, Kodierung erkennen, Leerzeichen um die Werte wegschneiden. Damit laufen die meisten Dateien auf Anhieb.

Eine hochgeladene Datei

Im Formular kommt die CSV meist als Anlage. Die gibst du direkt an csv.parse — ohne Umweg über das Dateisystem:

if (ctx.attachments.length > 0) {
  var zeilen = csv.parse(ctx.attachments[0], { header: true, encoding: "auto", trim: true });
  log.info(zeilen.length + " Zeilen in " + ctx.attachments[0].name);
}

Teil 6 — Prüfen statt hoffen

Ein Import, der die Hälfte verschluckt, ist schlimmer als einer, der abbricht. Zwei Werkzeuge dafür.

Spaltenzahl prüfen

Standardmäßig ist csv.parse tolerant: fehlende Werte werden null, überzählige fallen weg. Für eine Datei, die ein festes Format haben muss, schaltest du strict ein und bekommst die Zeilennummer genannt:

var text = "Artikel;Menge;Preis\nSchraube;500;0,12\nMutter;120";

try {
  csv.parse(text, { header: true, strict: true });
  log.info("Format in Ordnung");
} catch (e) {
  log.warn("Import abgebrochen: " + e);
}

Einen Fehlerbericht sammeln

Meist willst du aber nicht abbrechen, sondern am Ende sagen können, was zu korrigieren ist. Das Muster dafür: gute Zeilen sammeln, schlechte mit Zeilennummer notieren.

var text = "Artikel;Menge\nSchraube;500\n;120\nMutter;viele";

var zeilen  = csv.parse(text, { header: true, trim: true });
var gut = [], schlecht = [];

for (var i = 0; i < zeilen.length; i++) {
  var z   = zeilen[i];
  var nr  = i + 2;            // +1 für die Kopfzeile, +1 weil Menschen ab 1 zählen
  if (!z.Artikel)                     schlecht.push("Zeile " + nr + ": Artikel fehlt");
  else if (!/^\d+$/.test(z.Menge))    schlecht.push("Zeile " + nr + ": Menge „" + z.Menge + "“ ist keine Zahl");
  else                                gut.push({ artikel: z.Artikel, menge: kc.num(z.Menge) });
}

log.info(gut.length + " gültig, " + schlecht.length + " zu korrigieren");
for (var k = 0; k < schlecht.length; k++) log.warn(schlecht[k]);

Die + 2 ist der Grund, warum die Schleifen-Falle aus Teil 4 so teuer ist: mit forEach stünde hier überall „Zeile NaN", und die Meldung wäre wertlos.

Teil 7 — Große Dateien Zeile für Zeile

csv.parse liest die ganze Datei in den Speicher. Das ist bis in die Hunderttausende Zeilen kein Problem. Willst du aber während des Lesens entscheiden — früh abbrechen, in Blöcken speichern —, nimm den StandardParser:

var parser = new csv.StandardParser("Artikel;Menge\nSchraube;500\nMutter;120");

var kopf = parser.nextLine();         // erste Zeile = Spaltennamen
var anzahl = 0, zeile;
while ((zeile = parser.nextLine()) !== null) {     // null = Dateiende
  anzahl++;
  log.debug("Zeile " + parser.line + ": " + zeile.join(" | "));
}
parser.close();

log.info(anzahl + " Zeilen gelesen");

parser.line ist die echte Zeilennummer in der Datei — bequemer als mitzuzählen, und richtig auch dann, wenn ein Wert einen Zeilenumbruch enthält.

Teil 8 — Der Rundlauf

Zum Abschluss das, was in der Praxis am häufigsten gebraucht wird: exportieren, extern bearbeiten, wieder einlesen. Weil csv.stringify und csv.parse Gegenstücke sind, passt das zusammen, ohne dass du dich um Trennzeichen kümmern musst.

// 1. exportieren
var original = [
  { Artikel: "Schraube", Menge: "500" },
  { Artikel: "Mutter",   Menge: "120" }
];
var text = csv.stringify(original, { separator: ";" });

// 2. (hier würde die Fachabteilung die Datei bearbeiten)

// 3. wieder einlesen
var zurueck = csv.parse(text, { header: true });
log.info("zurück: " + zurueck.length + " Zeilen, erste Menge " + zurueck[0].Menge);

Dasselbe mit Daten aus der Datenbank — db.query liefert schon Objekte, also ist der Export ein Einzeiler:

var zeilen = db.query("SELECT 1 AS nr, 'Hagen' AS ort UNION SELECT 2, 'Köln'");
log.info(csv.stringify(zeilen, { separator: ";" }));

Wichtig: Formeln in Zellen

Eine Zelle, die mit =, +, - oder @ beginnt, ist für Excel und LibreOffice eine Formel. In einer CSV aus KCForms stehen Formularinhalte, also fremde Eingaben — wer so eine Datei öffnet, führt sie aus. Das ist eine bekannte Angriffsart, und Anführungszeichen helfen nicht dagegen: die gehören zur CSV-Syntax und sind beim Öffnen schon weg.

KCForms stellt solchen Zellen deshalb von sich aus ein Apostroph voran. Excel zeigt dann den Text und rechnet nicht:

log.info(csv.stringify([["=1+1", "-1500,00"]]));
// '=1+1,"-1500,00"

Reine Zahlen bleiben verschont — sonst würde aus jedem negativen Betrag eine Textzelle, mit der niemand rechnen kann. -1500,00 bleibt also eine Zahl, -2+3 wird entschärft.

Wenn du in einer erzeugten Datei ein Apostroph findest, das du nicht geschrieben hast: das ist der Grund. Brauchst du die Werte unverändert — etwa für ein System, das keine Formeln kennt — schaltest du es ab: { escapeFormulae: false }.

Wenn etwas nicht geht

Beobachtung Ursache Abhilfe
Excel zeigt alles in Spalte A Komma als Trennzeichen separator: ";"
Umlaute sind Kästchen oder ü BOM fehlt bom: true
Umlaute sind Fragezeichen beim Lesen Datei ist ANSI, nicht UTF-8 encoding: "auto"
Zahlen sind in Excel linksbündig (= Text) Dezimalpunkt decimalSep: ","
Beim Lesen nur eine Spalte Trennzeichen fest auf Komma gesetzt Angabe weglassen oder richtig setzen
„Zeile NaN" in einer Meldung forEach statt indizierter Schleife Teil 4
Kopfzeile heißt remove, size, get, … Object.keys auf einer Server-Zeile csv.stringify nimmt die Spalten selbst richtig
Rechnen ergibt NaN Werte aus CSV sind Text kc.num(wert)
Apostroph vor einem Wert Formel-Schutz Abschnitt oben
Eine Spalte fehlt im Ergebnis zwei gleiche Spaltennamen die zweite heißt name_2
sort(...) bricht ab auf einer Server-Liste gibt sort nichts zurück erst filter(...) oder slice(...)

Kurz-Referenz

// Schreiben
csv.stringify(zeilen);                                   // Objekte oder Arrays
csv.stringify(zeilen, { separator: ";", decimalSep: ",", bom: true });
csv.stringify(zeilen, { columns: ["b", "a"], header: false });

// Lesen
csv.parse(quelle);                                       // Array von Arrays
csv.parse(quelle, { header: true });                     // Array von Objekten
csv.parse(quelle, { header: true, encoding: "auto", trim: true, strict: true });

// Zeilenweise
var p = new csv.StandardParser(quelle);
while ((z = p.nextLine()) !== null) { /* … */ }

// Ausliefern
delivery.deliver(p.toByteArray(), { name: "export.csv", mime: "text/csv" });   // Bytes!
fs["/pfad/export.csv"].writeText(text);
ctx.attachments.add("export.csv", text);

Quelle für alle Lese-Funktionen: Text, fs-Datei, Dateianlage (ctx.attachments[i]) oder Bytes.

Aufräumen

Die Übungsdateien dieses Workshops wieder entfernen:

var ordner = fs["/beispiele/csv-workshop"];
if (ordner.exists) {
  var dateien = ordner.list();
  for (var i = 0; i < dateien.length; i++) dateien[i].remove();
}
log.info("aufgeräumt");

Weiterlesen