Files
DogFatherGitandClaude Opus 5 58932ece8c Excel-Dateien werden gelesen -- und drei Fallen dahinter entschaerft
Filipe: "mach doch bitte so dass man alle dateien hoch laden könne auch
excel dateien. gib gas und krieg das hin."

server/workspace-xlsx.js liest .xlsx mit Bordmitteln: Eine .xlsx ist ein
ZIP mit XML darin, und Node kann beides (`zlib.inflateRawSync`). Kein
zusaetzliches Paket fuer eine Datei mit neun Zeilen.

GEMESSEN AN ZWEI ECHTEN BACKSTAGE-AUSGABEN vom 11. und 12.09.2026, die
auf dem Rechner lagen -- nicht an der Dokumentation. Sie haben mir an
drei Stellen widersprochen, und JEDE davon waere sonst ein stiller
Fehler geworden:

1. DER ZEITRAUM. In "Datenzeitraum" steht `2026-09-01 ~ 2026-09-11`.
   `datumLesen` griff sich davon den ersten Tag -- elf Tage Diamanten
   waeren auf den 1. September gebucht worden. Die Zahl steht da, sie
   ist gross, sie sieht richtig aus, und niemand kann spaeter sagen,
   dass elf Tage darin stecken. Neu: `zeitraumLesen`; eine Zeile mit
   einem Zeitraum ueber mehrere Tage wird abgelehnt UND begruendet
   ("Stell in Backstage den Zeitraum auf EINEN Tag"). Ein Zeitraum von
   einem Tag geht durch.

2. DIE EINHEIT. "LIVE-Dauer" enthaelt `86Std. 10Min. 52Sek.`.
   `zahlLesen` ergab daraus `null` -- die Dauer fiel weg. Bei einem
   anderen Trennzeichen waere es schlimmer gewesen: 86 statt 5171,
   Faktor 60 daneben und plausibel. Neu: `dauerLesen`, versteht die
   deutsche und englische Schreibweise, die Uhrzeitform und weiterhin
   die blosse Zahl.

3. DIE DATUMSSPALTE. `/datum|date|tag|day/i` erklaerte "Tage seit dem
   Beitritt" zur Datumsspalte (Wert "65") und traf in der
   Leistungstabelle "Gueltige LIVE-Gehen-Tage" genauso. Jetzt nur noch
   als ganzes Wort, dafuer mit "zeitraum" -- Backstages Spalte wurde
   bisher nur zufaellig gefunden, weil in "Daten" die Silbe "date"
   steckt.

Gefunden hat das keine Ueberlegung, sondern der ganze Weg einmal mit
der echten Datei durchlaufen.

WEITER GEBAUT:
- Titelzeilen werden uebersprungen: "Creator:innen verwalten" hat in
  Zeile 1 nur "Exportiert am :…", die Ueberschriften stehen darunter.
  Die Regel misst (drei gefuellte Felder UND halb so breit wie die
  breiteste Zeile), statt eine feste Zahl zu nehmen.
- Fehlende Zellen verschieben nichts: Eine leere Zelle steht in der
  Datei gar nicht; wer der Reihe nach liest, verrutscht ab dort jede
  Spalte, und die Zeile sieht voll aus.
- Datums-Seriennummern werden nur umgerechnet, wenn das FORMAT es sagt
  (sonst stuende 46271 in der Vorschau). Der Nullpunkt ist an zwei
  nachschlagbaren Werten festgenagelt.
- Der Backstage-Dialog nimmt die Datei jetzt AUCH -- dort gehoert sie
  hin, denn Filipes Ausgabe enthaelt alle Creator auf einmal. Sie fuellt
  das Einfuegefeld; ab da laeuft derselbe Weg wie beim Einfuegen. Keine
  zweite Fassung derselben Regeln.
- Die alte .xls (BIFF, kein ZIP) wird erkannt und bekommt einen Weg
  gezeigt, statt "ging nicht" zu sagen.

NEU: pruef-xlsx.mjs (60) -- baut seine Dateien selbst (ZIP-Schreiber in
helfer-xlsx-bauen.mjs), damit keine Creator-Daten ins Repo wandern und
auch Faelle pruefbar sind, die es als Datei nicht gibt: kaputtes
Verzeichnis, fehlendes Blatt, abgeschnittene Datei. Jeder davon mit
Gegenprobe, dass die heile Datei durchgeht.

pruef-backstage-import 77 -> 82, dabei zwei Pruefungen GEDREHT: Die
.xlsx bekommt keine Absage mehr, sondern eine Vorschau.

DREI EIGENE FEHLER DABEI, alle von einer Messung gefunden:
- Ich hielt Seriennummer 46264 fuer den 06.09.; es ist der 30.08. Der
  Code hatte recht. Deshalb stehen jetzt zwei nachschlagbare Anker drin.
- Eine Zeile war gruen, weil mein Muster den SPALTENNAMEN
  "Datenzeitraum" traf statt der Begruendung. Jetzt wird auf den Text
  der Ablehnung geprueft.
- Beim Umbau habe ich pruef-backstage-import beschaedigt (ein
  Suchtreffer weiter oben als gemeint) und aus Git zurueckgeholt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 11:35:21 +02:00

368 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
EXCEL-DATEIEN LESEN — ohne fremdes Paket, mit Bordmitteln
(14.09.2026)
Filipe: "mach doch bitte so dass man alle dateien hoch laden könne
auch excel dateien. gib gas und krieg das hin."
---------------------------------------------------------------------
WARUM SELBST GEBAUT UND NICHT `npm i xlsx`
Eine .xlsx ist ein ZIP-Archiv mit XML darin. Node kann beides von
Haus aus: `zlib.inflateRawSync` packt aus, der Rest ist Textarbeit.
Ein Paket dafür wäre eine Abhängigkeit mehr, die gepflegt, geprüft
und aktualisiert werden will -- für eine Datei mit neun Zeilen.
GEMESSEN AN DEN ECHTEN DATEIEN, nicht an der Dokumentation: Zwei
Backstage-Ausgaben vom 11. und 12.09.2026 lagen vor. Was sie zeigen:
* 12 Einträge im ZIP, alle mit Verfahren 8 (Deflate).
* Ein einziges Blatt.
* ALLE Zellen sind `t="s"` -- Backstage legt sogar Zahlen als
Zeichenkette ab. Datums-Seriennummern kommen darin also gar
nicht vor. (Der Leser kann sie trotzdem, siehe unten: Sobald
jemand die Datei in Excel öffnet und speichert, sind sie da.)
* Bei "Creator:innen verwalten" steht in Zeile 1 nur ein Titel
("Exportiert am :…"), die Überschriften stehen in Zeile 2.
---------------------------------------------------------------------
WAS DIESER LESER NICHT TUT
Er formatiert nicht. Eine Zelle wird zu dem Text, der darin steht --
Formeln werden mit ihrem zuletzt berechneten Wert gelesen, nicht
ausgerechnet. Das ist Absicht: Wer eine Tabelle mit Formeln
hochlädt, will die Zahlen, die er selbst gesehen hat.
===================================================================== */
import { inflateRawSync } from "node:zlib";
/* Ein ZIP-Eintrag, wie ihn das Zentralverzeichnis beschreibt. Gelesen
wird IMMER über das Zentralverzeichnis am Dateiende und nie durch
Vorwärtslaufen über die lokalen Köpfe: Bei einem Archiv mit
Datendeskriptoren (Bit 3 im Merker) steht die gepackte Länge im
lokalen Kopf auf 0, und wer sich darauf verlässt, liest nichts. */
const ENDE = 0x06054b50; // "PK\x05\x06"
const ZENTRAL = 0x02014b50; // "PK\x01\x02"
/** Fehler mit Grund -- der DRITTE AUSGANG, nicht ein stilles `null`.
* Wer eine kaputte Datei hochlädt, soll erfahren, WAS daran kaputt
* ist; "ging nicht" schickt ihn sonst auf die Suche bei sich selbst. */
export class XlsxFehler extends Error {
constructor(text) { super(text); this.name = "XlsxFehler"; }
}
/** Ist das überhaupt eine ZIP-Datei? Am INHALT erkannt, nicht am Namen.
* Eine umbenannte Datei ist keine andere Datei. */
export function istZip(puffer) {
return puffer?.length >= 4 && puffer[0] === 0x50 && puffer[1] === 0x4b
&& (puffer[2] === 0x03 || puffer[2] === 0x05 || puffer[2] === 0x07);
}
/** Alte .xls (BIFF, vor 2007) -- ein ganz anderes Format, kein ZIP.
* Wird nicht gelesen, aber ERKANNT, damit die Meldung stimmt. */
export function istAltesExcel(puffer) {
return puffer?.length >= 8 && puffer[0] === 0xd0 && puffer[1] === 0xcf
&& puffer[2] === 0x11 && puffer[3] === 0xe0;
}
/** Das Archiv auspacken -- Name -> Inhalt (Buffer). */
function zipLesen(puffer) {
/* Das Ende-Kennzeichen steht hinten und kann bis zu 64 KB Kommentar
hinter sich haben. Deshalb rückwärts suchen, nicht raten. */
let ende = -1;
const frueheste = Math.max(0, puffer.length - 22 - 65535);
for (let i = puffer.length - 22; i >= frueheste; i--) {
if (puffer.readUInt32LE(i) === ENDE) { ende = i; break; }
}
if (ende < 0) throw new XlsxFehler("Das ist keine gültige Excel-Datei (ZIP-Ende fehlt).");
const anzahl = puffer.readUInt16LE(ende + 10);
let stelle = puffer.readUInt32LE(ende + 16);
if (anzahl === 0xffff || stelle === 0xffffffff) {
throw new XlsxFehler("Die Datei ist zu groß für dieses Format (ZIP64).");
}
const dateien = new Map();
for (let i = 0; i < anzahl; i++) {
if (stelle + 46 > puffer.length || puffer.readUInt32LE(stelle) !== ZENTRAL) {
throw new XlsxFehler("Das Inhaltsverzeichnis der Datei ist beschädigt.");
}
const verfahren = puffer.readUInt16LE(stelle + 10);
const gepackt = puffer.readUInt32LE(stelle + 20);
const nameLang = puffer.readUInt16LE(stelle + 28);
const extraLang = puffer.readUInt16LE(stelle + 30);
const kommLang = puffer.readUInt16LE(stelle + 32);
const lokal = puffer.readUInt32LE(stelle + 42);
const name = puffer.subarray(stelle + 46, stelle + 46 + nameLang).toString("utf8");
/* Die Längen im LOKALEN Kopf gelten, nicht die im Zentralverzeichnis:
Nur dort steht, wie lang Name und Extrafeld an DIESER Stelle
wirklich sind -- sie dürfen sich unterscheiden. */
const lNameLang = puffer.readUInt16LE(lokal + 26);
const lExtraLang = puffer.readUInt16LE(lokal + 28);
const von = lokal + 30 + lNameLang + lExtraLang;
const roh = puffer.subarray(von, von + gepackt);
/* Nur die XML-Teile werden gebraucht. Bilder (Backstage legt ein
19-KB-Logo bei) bleiben ungepackt liegen -- das spart bei jeder
Datei die Arbeit und kann nicht schiefgehen. */
if (name.endsWith(".xml") || name.endsWith(".rels")) {
try {
dateien.set(name, verfahren === 0 ? roh : inflateRawSync(roh));
} catch {
throw new XlsxFehler(`Ein Teil der Datei ließ sich nicht entpacken (${name}).`);
}
}
stelle += 46 + nameLang + extraLang + kommLang;
}
return dateien;
}
/* ---------- XML, so viel wie nötig ------------------------------------ */
const ENTITAETEN = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
/** Entschärfte Zeichen zurückholen. `&#228;` und `&#xE4;` kommen in
* Exporten mit Umlauten wirklich vor -- ohne sie stünde in der
* Vorschau "Gltige LIVE-Gehen-Tage". */
function entschluesseln(text) {
if (!text.includes("&")) return text;
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (ganz, inhalt) => {
if (inhalt[0] === "#") {
const n = inhalt[1] === "x" || inhalt[1] === "X"
? parseInt(inhalt.slice(2), 16) : parseInt(inhalt.slice(1), 10);
return Number.isFinite(n) ? String.fromCodePoint(n) : ganz;
}
return ENTITAETEN[inhalt] ?? ganz;
});
}
/** Die Zeichenkettentabelle. Ein Eintrag kann aus mehreren Stücken
* bestehen (`<r>`-Läufe, wenn Teile anders formatiert sind) -- die
* gehören zusammengesetzt, sonst fehlt die Hälfte des Namens. */
function zeichenkettenLesen(dateien) {
const roh = dateien.get("xl/sharedStrings.xml");
if (!roh) return [];
const text = roh.toString("utf8");
const liste = [];
for (const eintrag of text.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) {
let stueck = "";
for (const t of eintrag[1].matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) {
stueck += entschluesseln(t[1]);
}
liste.push(stueck);
}
return liste;
}
/* ---------- Datum: nur, wenn das Format es sagt ----------------------- */
/* Die eingebauten Zahlenformate, die ein Datum oder eine Uhrzeit sind.
Sie stehen in der Norm (ECMA-376) und nicht in der Datei -- deshalb
hier, und deshalb als Liste und nicht als Bereich: 23 bis 44 sind
KEINE Datumsformate, ein `>= 14 && <= 47` wäre falsch. */
const DATUMS_IDS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
/** Welche Zellvorlage zeigt ein Datum? Ergebnis: Menge von Vorlagen-
* nummern (`s`-Attribut der Zelle).
*
* WARUM DAS SEIN MUSS: In der Datei steht bei einem Datum die Zahl
* 46264. Ohne diese Zuordnung landet genau das in der Vorschau -- eine
* Zahl, die aussieht wie eine Zahl und ein Datum ist. */
function datumsVorlagen(dateien) {
const roh = dateien.get("xl/styles.xml");
const vorlagen = new Set();
if (!roh) return vorlagen;
const text = roh.toString("utf8");
/* Eigene Formate: Ein Format ist ein Datum, wenn darin d/m/y/h/s
außerhalb von Anführungszeichen vorkommen. Die Anführungszeichen
müssen weg, sonst macht `"Stand:"` aus jedem Format ein Datum. */
const eigene = new Set();
for (const m of text.matchAll(/<numFmt[^>]*numFmtId="(\d+)"[^>]*formatCode="([^"]*)"/g)) {
const code = entschluesseln(m[2]).replace(/"[^"]*"/g, "").replace(/\\./g, "");
if (/[dmyhs]/i.test(code)) eigene.add(Number(m[1]));
}
const block = /<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/.exec(text);
if (!block) return vorlagen;
let nummer = 0;
for (const xf of block[1].matchAll(/<xf\b[^>]*>/g)) {
const id = Number(/numFmtId="(\d+)"/.exec(xf[0])?.[1] ?? 0);
if (DATUMS_IDS.has(id) || eigene.has(id)) vorlagen.add(nummer);
nummer++;
}
return vorlagen;
}
/** Excel-Seriennummer -> "JJJJ-MM-TT" (oder mit Uhrzeit).
*
* DER SPRUNGTAG, DEN ES NIE GAB: Excel hält 1900 für ein Schaltjahr.
* Deshalb ist der Nullpunkt der 30.12.1899 und nicht der 31.12. --
* damit stimmen alle Tage ab dem 01.03.1900, und das sind alle, die
* hier je vorkommen. */
function ausSeriennummer(zahl, seit1904) {
const basis = seit1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30);
const ms = basis + Math.round(zahl * 86400 * 1000);
const d = new Date(ms);
if (!Number.isFinite(d.getTime())) return String(zahl);
const p = (n) => String(n).padStart(2, "0");
const datum = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
const rest = zahl - Math.floor(zahl);
if (rest < 1 / 86400) return datum;
return `${datum} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
/* ---------- Spaltenname -> Nummer ------------------------------------- */
/** "A" -> 0, "Z" -> 25, "AA" -> 26.
* Gebraucht, weil leere Zellen in der Datei FEHLEN: Eine Zeile kann
* A, B, dann D enthalten. Wer stur der Reihe nach einliest, verschiebt
* ab dort jede Spalte -- und merkt es nicht, weil die Zeile voll
* aussieht. */
function spalteAus(bezug) {
const m = /^([A-Z]+)/.exec(bezug || "");
if (!m) return -1;
let n = 0;
for (const z of m[1]) n = n * 26 + (z.charCodeAt(0) - 64);
return n - 1;
}
/* ---------- Das Blatt ------------------------------------------------- */
/** Welches Blatt ist das erste? Der Name kommt aus workbook.xml, der
* Dateiname aus den Beziehungen. Fällt eines davon aus, wird auf
* sheet1.xml zurückgefallen -- mit Vermerk, nicht stillschweigend. */
function erstesBlatt(dateien) {
const mappe = dateien.get("xl/workbook.xml")?.toString("utf8") || "";
const bez = dateien.get("xl/_rels/workbook.xml.rels")?.toString("utf8") || "";
const eintrag = /<sheet\b[^>]*>/.exec(mappe)?.[0] || "";
const name = entschluesseln(/name="([^"]*)"/.exec(eintrag)?.[1] || "Tabelle1");
const rid = /r:id="([^"]*)"/.exec(eintrag)?.[1];
let pfad = null;
if (rid) {
const ziel = new RegExp(`<Relationship[^>]*Id="${rid}"[^>]*Target="([^"]*)"`).exec(bez)?.[1];
if (ziel) pfad = ziel.startsWith("/") ? ziel.slice(1) : `xl/${ziel.replace(/^\.\//, "")}`;
}
if (!pfad || !dateien.has(pfad)) pfad = "xl/worksheets/sheet1.xml";
return { name, pfad };
}
/**
* Eine .xlsx in Zeilen aus Text verwandeln.
*
* @param {Buffer} puffer die Datei
* @returns {{ blatt: string, zeilen: string[][] }}
*/
export function xlsxLesen(puffer) {
if (istAltesExcel(puffer)) {
throw new XlsxFehler(
"Das ist eine alte Excel-Datei (.xls). Speichere sie in Excel einmal "
+ "als .xlsx oder als CSV – dann geht sie durch.");
}
if (!istZip(puffer)) throw new XlsxFehler("Das ist keine Excel-Datei.");
const dateien = zipLesen(puffer);
const { name, pfad } = erstesBlatt(dateien);
const roh = dateien.get(pfad);
if (!roh) throw new XlsxFehler("In der Datei ist kein Tabellenblatt zu finden.");
const texte = zeichenkettenLesen(dateien);
const datumsZellen = datumsVorlagen(dateien);
const seit1904 = /date1904="(1|true)"/.test(
dateien.get("xl/workbook.xml")?.toString("utf8") || "");
const blatt = roh.toString("utf8");
const zeilen = [];
for (const zeile of blatt.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
const felder = [];
for (const zelle of zeile[1].matchAll(/<c\b([^>]*)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
const kopf = zelle[1];
const inhalt = zelle[2] || "";
const spalte = spalteAus(/r="([A-Z]+\d+)"/.exec(kopf)?.[1]);
const typ = /t="([^"]*)"/.exec(kopf)?.[1] || "n";
const vorlage = Number(/s="(\d+)"/.exec(kopf)?.[1] ?? -1);
let wert = "";
if (typ === "s") {
const i = Number(/<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1] ?? -1);
wert = texte[i] ?? "";
} else if (typ === "inlineStr") {
for (const t of inhalt.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) {
wert += entschluesseln(t[1]);
}
} else {
const roh2 = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1];
wert = roh2 === undefined ? "" : entschluesseln(roh2);
if (typ === "b") wert = wert === "1" ? "WAHR" : "FALSCH";
else if (typ === "e") wert = wert; // #DIV/0! usw. bleibt stehen
else if (typ === "n" && wert !== "" && datumsZellen.has(vorlage)
&& Number.isFinite(Number(wert))) {
wert = ausSeriennummer(Number(wert), seit1904);
}
}
/* An die RICHTIGE Stelle, nicht ans Ende. Fehlende Zellen werden
zu leeren Feldern -- siehe spalteAus(). */
if (spalte >= 0) {
while (felder.length < spalte) felder.push("");
felder[spalte] = wert;
} else {
felder.push(wert);
}
}
zeilen.push(felder);
}
return { blatt: name, zeilen };
}
/* =====================================================================
WO FÄNGT DIE TABELLE AN?
Backstages Ausgabe "Creator:innen verwalten" hat in Zeile 1 nur
"Exportiert am :2026-09-11 13:10:35" -- eine einzige Zelle. Die
Überschriften stehen in Zeile 2. Wer stur die erste Zeile als
Überschrift nimmt, bekommt eine Tabelle mit einer Spalte und meldet
"keine Diamanten-Spalte gefunden": eine richtige Meldung auf eine
falsche Fährte.
DIE REGEL MISST, SIE RÄT NICHT: Überschrift ist die erste Zeile, die
mindestens drei gefüllte Felder hat UND mindestens halb so breit ist
wie die breiteste Zeile der Datei. Eine Titelzeile ist damit sicher
draußen, eine echte schmale Tabelle (drei Spalten) sicher drin.
===================================================================== */
export function tabelleAbKopf(zeilen) {
const gefuellt = (z) => z.filter((x) => String(x ?? "").trim() !== "").length;
const breiteste = zeilen.reduce((a, z) => Math.max(a, gefuellt(z)), 0);
if (breiteste === 0) return { ab: 0, uebersprungen: 0, zeilen: [] };
let ab = 0;
for (let i = 0; i < zeilen.length; i++) {
const n = gefuellt(zeilen[i]);
if (n >= 3 && n * 2 >= breiteste) { ab = i; break; }
/* Nur führende Zeilen dürfen wegfallen. Findet sich gar keine
passende, bleibt es bei Zeile 0 -- lieber eine schlechte
Überschrift als eine verschluckte Tabelle. */
}
return { ab, uebersprungen: ab, zeilen: zeilen.slice(ab) };
}
/** Zeilen -> TAB-getrennter Text.
*
* WARUM TAB UND WARUM ÜBERHAUPT TEXT: Dahinter liegt `csvZerlegen`,
* seit dem 11.09.2026 auf Tabulatoren eingerichtet und mit 77
* Prüfungen abgesichert. Eine zweite Zerlegung für Excel wäre eine
* zweite Fassung derselben Regeln -- und die zweite ist die, in der
* ein Sonderfall fehlt. Die Excel-Datei wird also zu genau dem, was
* auch beim Einfügen aus dem Browser ankommt.
*
* Tabulatoren und Zeilenumbrüche IM FELD werden zu Leerzeichen. Sonst
* zerfiele ein Feld in zwei Spalten, und ab da stimmt keine Zeile
* mehr. */
export function alsText(zeilen) {
return zeilen
.map((z) => z.map((w) => String(w ?? "").replace(/[\t\r\n]+/g, " ")).join("\t"))
.join("\n");
}