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]>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/* =====================================================================
|
||||
EIN ZIP- UND XLSX-SCHREIBER FUER PRUEFUNGEN (14.09.2026)
|
||||
|
||||
Er steht hier und nicht in einer der Pruefdateien, weil ihn seit
|
||||
heute ZWEI brauchen: pruef-xlsx (der Leser) und pruef-backstage-import
|
||||
(der Weg durch die Oberflaeche). Zwei Abschriften waeren zwei
|
||||
Gelegenheiten, dass die eine ein Feld anders schreibt als die andere
|
||||
-- und dann misst man zwei verschiedene Dinge und nennt beide "eine
|
||||
Excel-Datei".
|
||||
|
||||
Gebaut wird nach der echten Backstage-Form: alles in der
|
||||
Zeichenkettentabelle (`t="s"`), ein Blatt, keine Formate.
|
||||
|
||||
`crc32` gibt es in node:zlib seit Node 22. Fehlt es, sagen die
|
||||
Pruefungen das -- der dritte Ausgang, kein stiller Aussetzer.
|
||||
===================================================================== */
|
||||
|
||||
import { deflateRawSync, crc32 } from "node:zlib";
|
||||
|
||||
export const kannXlsxBauen = typeof crc32 === "function";
|
||||
|
||||
export function zipBauen(eintraege) {
|
||||
const lokale = [];
|
||||
const zentrale = [];
|
||||
let stelle = 0;
|
||||
for (const [name, inhalt] of eintraege) {
|
||||
const roh = Buffer.from(inhalt, "utf8");
|
||||
const gepackt = deflateRawSync(roh);
|
||||
const pruef = crc32(roh) >>> 0;
|
||||
const nameB = Buffer.from(name, "utf8");
|
||||
|
||||
const lokal = Buffer.alloc(30);
|
||||
lokal.writeUInt32LE(0x04034b50, 0);
|
||||
lokal.writeUInt16LE(20, 4); // Version
|
||||
lokal.writeUInt16LE(0, 6); // Merker
|
||||
lokal.writeUInt16LE(8, 8); // Deflate
|
||||
lokal.writeUInt32LE(pruef, 14);
|
||||
lokal.writeUInt32LE(gepackt.length, 18);
|
||||
lokal.writeUInt32LE(roh.length, 22);
|
||||
lokal.writeUInt16LE(nameB.length, 26);
|
||||
lokale.push(lokal, nameB, gepackt);
|
||||
|
||||
const zentral = Buffer.alloc(46);
|
||||
zentral.writeUInt32LE(0x02014b50, 0);
|
||||
zentral.writeUInt16LE(20, 4);
|
||||
zentral.writeUInt16LE(20, 6);
|
||||
zentral.writeUInt16LE(8, 10);
|
||||
zentral.writeUInt32LE(pruef, 16);
|
||||
zentral.writeUInt32LE(gepackt.length, 20);
|
||||
zentral.writeUInt32LE(roh.length, 24);
|
||||
zentral.writeUInt16LE(nameB.length, 28);
|
||||
zentral.writeUInt32LE(stelle, 42);
|
||||
zentrale.push(zentral, nameB);
|
||||
|
||||
stelle += lokal.length + nameB.length + gepackt.length;
|
||||
}
|
||||
const koerper = Buffer.concat(lokale);
|
||||
const verzeichnis = Buffer.concat(zentrale);
|
||||
const ende = Buffer.alloc(22);
|
||||
ende.writeUInt32LE(0x06054b50, 0);
|
||||
ende.writeUInt16LE(eintraege.length, 8);
|
||||
ende.writeUInt16LE(eintraege.length, 10);
|
||||
ende.writeUInt32LE(verzeichnis.length, 12);
|
||||
ende.writeUInt32LE(koerper.length, 16);
|
||||
return Buffer.concat([koerper, verzeichnis, ende]);
|
||||
}
|
||||
|
||||
export const RELS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
/** Baut eine .xlsx aus Zeilen. Alles wandert in die
|
||||
* Zeichenkettentabelle – genau wie Backstage es tut. */
|
||||
export function xlsxBauen(zeilen, { blattName = "Sheet1", extra = "" } = {}) {
|
||||
const texte = [];
|
||||
const nummer = (w) => {
|
||||
const i = texte.indexOf(w);
|
||||
if (i >= 0) return i;
|
||||
texte.push(w);
|
||||
return texte.length - 1;
|
||||
};
|
||||
const buchstabe = (n) => {
|
||||
let s = "";
|
||||
n += 1;
|
||||
while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = (n - r - 1) / 26; }
|
||||
return s;
|
||||
};
|
||||
const zeilenXml = zeilen.map((z, r) => {
|
||||
const zellen = z.map((w, c) => {
|
||||
if (w === null || w === undefined || w === "") return "";
|
||||
return `<c r="${buchstabe(c)}${r + 1}" t="s"><v>${nummer(String(w))}</v></c>`;
|
||||
}).join("");
|
||||
return `<row r="${r + 1}">${zellen}</row>`;
|
||||
}).join("");
|
||||
|
||||
const blatt = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${zeilenXml}</sheetData></worksheet>`;
|
||||
const strings = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${texte.length}" uniqueCount="${texte.length}">`
|
||||
+ texte.map((t) => `<si><t>${t.replace(/&/g, "&").replace(/</g, "<")}</t></si>`).join("")
|
||||
+ "</sst>";
|
||||
const mappe = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"${extra}><sheets><sheet name="${blattName}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
|
||||
return zipBauen([
|
||||
["xl/workbook.xml", mappe],
|
||||
["xl/_rels/workbook.xml.rels", RELS],
|
||||
["xl/sharedStrings.xml", strings],
|
||||
["xl/worksheets/sheet1.xml", blatt],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { notbremse } from "./helfer-notbremse.mjs";
|
||||
notbremse(240_000, "pruef-backstage-import");
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const { xlsxBauen } = await import("./helfer-xlsx-bauen.mjs");
|
||||
const PORT = 4389;
|
||||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-backstage-"));
|
||||
@@ -455,6 +456,63 @@ console.log("\n=== Die Seite ===");
|
||||
{ path: join(HIER, "pruef-netz-leer.png") });
|
||||
}
|
||||
|
||||
/* ---- DIE BACKSTAGE-AUSGABE ALS DATEI (14.09.2026) ---------------
|
||||
Filipes echte Ausgabe ("Creator:innendaten …xlsx") enthaelt ALLE
|
||||
Creator auf einmal -- sie gehoert hierher und nicht in den
|
||||
Einzel-Import. Nachgebaut wird ihre Form, inklusive der Titelzeile,
|
||||
die "Creator:innen verwalten" ueber die Tabelle setzt. */
|
||||
const netzDatei = xlsxBauen([
|
||||
["Exportiert am :2026-09-11 13:10:35"],
|
||||
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten", "LIVE-Dauer"],
|
||||
[`${gestern} ~ ${gestern}`, "nova_live", "12.480", "3Std. 4Min."],
|
||||
[`${gestern} ~ ${gestern}`, "Kiro", "3.900", "1Std. 35Min."],
|
||||
]);
|
||||
await seite.setInputFiles("#netz-datei", {
|
||||
name: "Creator_innendaten.xlsx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: netzDatei,
|
||||
});
|
||||
await seite.waitForTimeout(1800);
|
||||
const ausDatei = await seite.evaluate(() => ({
|
||||
feld: document.getElementById("netz-text")?.value || "",
|
||||
fehler: document.getElementById("netz-fehler")?.textContent || "",
|
||||
vorschau: document.getElementById("netz-vorschau")?.hidden,
|
||||
text: document.getElementById("netz-vorschau")?.textContent || "",
|
||||
}));
|
||||
ok(ausDatei.fehler === "", `die Datei wird angenommen ("${ausDatei.fehler.slice(0, 40)}")`);
|
||||
ok(ausDatei.feld.includes("nova_live") && ausDatei.feld.includes("Datenzeitraum"),
|
||||
"das Einfuegefeld ist danach gefuellt -- ab da derselbe Weg wie beim Einfuegen");
|
||||
ok(!ausDatei.feld.startsWith("Exportiert am"),
|
||||
"die Titelzeile ist weg, die Ueberschrift steht oben");
|
||||
ok(ausDatei.vorschau === false && /zugeordnet/.test(ausDatei.text),
|
||||
`und die Vorschau erscheint von selbst ("${ausDatei.text.slice(0, 45)}…")`);
|
||||
|
||||
/* DER TEUERSTE FALL, gemessen statt vermutet: Ein ZEITRAUM ueber
|
||||
mehrere Tage darf NICHT auf einen Tag geschrieben werden. Genau das
|
||||
steht in Filipes echter Datei (2026-09-01 ~ 2026-09-11), und genau
|
||||
das haette `datumLesen` stillschweigend auf den 1. September
|
||||
gebucht -- elf Tage Diamanten, plausibel und falsch. */
|
||||
const zeitraumDatei = xlsxBauen([
|
||||
["Datenzeitraum", "Creator*innen-Anmeldename", "Diamanten"],
|
||||
["2026-09-01 ~ 2026-09-11", "nova_live", "53.679"],
|
||||
]);
|
||||
await seite.setInputFiles("#netz-datei", {
|
||||
name: "zeitraum.xlsx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: zeitraumDatei,
|
||||
});
|
||||
await seite.waitForTimeout(1800);
|
||||
const spanne = await seite.evaluate(() =>
|
||||
document.getElementById("netz-vorschau")?.textContent || "");
|
||||
ok(spanne.includes("mehrere Tage") && spanne.includes("2026-09-01"),
|
||||
`ein Zeitraum ueber mehrere Tage wird abgelehnt und BEGRUENDET ("${spanne.slice(0, 90)}…")`);
|
||||
ok(!/1 von 1 Zeilen zugeordnet/.test(spanne),
|
||||
"und die Zeile wird NICHT als zugeordnet gezaehlt");
|
||||
if (process.env.SCHIRM) {
|
||||
await seite.locator("#netz-dialog").screenshot(
|
||||
{ path: join(HIER, "pruef-netz-zeitraum.png") });
|
||||
}
|
||||
|
||||
await seite.click("#netz-zu");
|
||||
await seite.waitForTimeout(300);
|
||||
/* Gemessen wird SATZ FUER SATZ, nicht der Block. Ein Block, der
|
||||
@@ -466,61 +524,69 @@ console.log("\n=== Die Seite ===");
|
||||
ok(/Backstage/.test(netzSatz),
|
||||
"unter den Knöpfen steht dauerhaft, woher die Zahlen kommen");
|
||||
|
||||
/* ---- EINE EXCEL-DATEI IST EINE SACKGASSE MIT WEGWEISER ----------
|
||||
Filipe: "meine excel datei kann ich nicht auswaehlen wieso??"
|
||||
/* ---- EINE EXCEL-DATEI WIRD GELESEN (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."
|
||||
|
||||
Sie liess sich nicht einmal AUSWAEHLEN -- ausgegraut, ohne ein
|
||||
Wort dazu. Jetzt laesst sie sich waehlen und bekommt eine Antwort,
|
||||
die weiterhilft.
|
||||
HIER STAND DAS GEGENTEIL, und es war richtig: Am 11.09. bekam eine
|
||||
.xlsx eine Absage mit Wegweiser ("nimm den Knopf daneben"). Seit
|
||||
heute wird sie gelesen -- die Pruefung wurde deshalb GEDREHT, nicht
|
||||
geloescht.
|
||||
|
||||
GEPRUEFT WIRD AN EINEM ECHTEN ZIP-KOPF ("PK"), denn genau das ist
|
||||
eine .xlsx. Die Erkennung haengt am INHALT, nicht an der Endung --
|
||||
deshalb heisst die Testdatei hier bewusst ".xlsx" UND traegt den
|
||||
richtigen Kopf; eine Pruefung ueber die Endung waere gruen, ohne
|
||||
die Erkennung je zu beruehren. */
|
||||
GEBAUT WIRD EINE ECHTE DATEI, kein Stueck Text mit ZIP-Anfang. Der
|
||||
erste Entwurf schickte zehn Bytes "PK…" und war gruen, weil der
|
||||
Server "ZIP-Ende fehlt" meldete -- das haette auch bei kaputtem
|
||||
Leser gestimmt. */
|
||||
const xlsxDatei = xlsxBauen([
|
||||
["Datum", "Creator", "Diamanten", "LIVE-Dauer"],
|
||||
[gestern, "@Nova_Live", "12.480", "3Std. 4Min."],
|
||||
[gestern, "Kiro", "3.900", "1Std. 35Min."],
|
||||
]);
|
||||
await seite.click("#import-oeffnen");
|
||||
await seite.waitForTimeout(300);
|
||||
await seite.setInputFiles("#datei", {
|
||||
name: "backstage.xlsx",
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
buffer: Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x06, 0x00, 0x08, 0x00]),
|
||||
buffer: xlsxDatei,
|
||||
});
|
||||
await seite.waitForTimeout(600);
|
||||
await seite.waitForTimeout(1500);
|
||||
const xlsx = await seite.evaluate(() => ({
|
||||
text: document.getElementById("import-fehler")?.textContent || "",
|
||||
fehler: document.getElementById("import-fehler")?.textContent || "",
|
||||
vorschau: document.getElementById("vorschau")?.hidden,
|
||||
los: document.getElementById("import-los")?.disabled,
|
||||
feld: document.getElementById("datei")?.value || "",
|
||||
text: document.getElementById("vorschau")?.textContent || "",
|
||||
}));
|
||||
ok(/Excel/.test(xlsx.text), `es steht da, dass es eine Excel-Datei ist (${xlsx.text.slice(0, 50)}…)`);
|
||||
ok(/Backstage-Tabelle einfügen/.test(xlsx.text),
|
||||
"und welcher Knopf stattdessen hilft");
|
||||
ok(/CSV/.test(xlsx.text), "und wie man sie umspeichert");
|
||||
ok(xlsx.vorschau === true && xlsx.los === true,
|
||||
"es entsteht KEINE Vorschau und der Übernehmen-Knopf bleibt gesperrt");
|
||||
ok(xlsx.feld === "",
|
||||
"und das Feld ist wieder leer -- eine Datei, die nicht geht, bleibt nicht stehen");
|
||||
if (process.env.SCHIRM) {
|
||||
await seite.locator("#import-dialog").screenshot(
|
||||
{ path: join(HIER, "pruef-import-excel.png") });
|
||||
}
|
||||
ok(xlsx.fehler === "",
|
||||
`die Excel-Datei wird ohne Absage gelesen ("${xlsx.fehler.slice(0, 50)}")`);
|
||||
ok(xlsx.vorschau === false, "und es entsteht eine Vorschau");
|
||||
ok(xlsx.text.includes("Diamanten") && xlsx.text.includes("LIVE-Dauer"),
|
||||
`die Vorschau nennt die erkannten Spalten ("${xlsx.text.slice(0, 55)}…")`);
|
||||
|
||||
/* GEGENPROBE: Eine echte Text-Tabelle geht durch denselben Weg
|
||||
weiterhin hindurch. Sonst hiesse das oben nur, dass gar nichts
|
||||
mehr eingelesen wird. */
|
||||
/* DIE ALTE .xls IST ETWAS ANDERES -- kein ZIP, sondern BIFF. Sie
|
||||
bekommt weiterhin eine Absage, und die muss WEITERHELFEN. */
|
||||
await seite.setInputFiles("#datei", {
|
||||
name: "alt.xls",
|
||||
mimeType: "application/vnd.ms-excel",
|
||||
buffer: Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0, 0, 0]),
|
||||
});
|
||||
await seite.waitForTimeout(700);
|
||||
const altText = await seite.evaluate(() =>
|
||||
document.getElementById("import-fehler")?.textContent || "");
|
||||
ok(altText.includes(".xls") && (altText.includes("xlsx") || altText.includes("CSV")),
|
||||
`eine alte .xls bekommt einen Weg gezeigt ("${altText.slice(0, 55)}…")`);
|
||||
|
||||
/* GEGENPROBE: Eine echte Text-Tabelle geht weiterhin durch. */
|
||||
await seite.setInputFiles("#datei", {
|
||||
name: "tage.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from(`Datum;Diamanten;LIVE-Dauer (Minuten)
|
||||
${gestern};1234;90
|
||||
`, "utf8"),
|
||||
buffer: Buffer.from("Datum;Diamanten;LIVE-Dauer (Minuten)" + String.fromCharCode(10)
|
||||
+ gestern + ";1234;90" + String.fromCharCode(10), "utf8"),
|
||||
});
|
||||
await seite.waitForTimeout(900);
|
||||
const csv = await seite.evaluate(() => ({
|
||||
fehler: document.getElementById("import-fehler")?.textContent || "",
|
||||
vorschau: document.getElementById("vorschau")?.hidden,
|
||||
}));
|
||||
ok(csv.vorschau === false && !/Excel/.test(csv.fehler),
|
||||
ok(csv.vorschau === false && csv.fehler === "",
|
||||
`eine CSV kommt weiterhin durch (Vorschau da: ${csv.vorschau === false})`);
|
||||
await seite.click("#import-zu");
|
||||
await seite.waitForTimeout(250);
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/* EXCEL-DATEIEN LESEN (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."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WOGEGEN HIER GEPRÜFT WIRD
|
||||
|
||||
Die Vorlage sind ZWEI ECHTE Backstage-Ausgaben (11. und 12.09.2026),
|
||||
die auf dem Rechner lagen. Sie haben mir gesagt, was Backstage
|
||||
wirklich schreibt – und das war an drei Stellen anders, als ich es
|
||||
mir gedacht hätte:
|
||||
|
||||
* ALLE Zellen sind `t="s"` (Zeichenkettentabelle). Backstage legt
|
||||
sogar Zahlen als Text ab.
|
||||
* "Creator:innen verwalten" hat in Zeile 1 nur einen Titel
|
||||
("Exportiert am :…"), die Überschriften stehen in Zeile 2.
|
||||
* Die Spalte "LIVE-Dauer" enthält `86Std. 10Min. 52Sek.` und
|
||||
"Datenzeitraum" einen ZEITRAUM: `2026-09-01 ~ 2026-09-11`.
|
||||
|
||||
DIE ECHTEN DATEIEN STEHEN NICHT IM REPO – in ihnen stehen die Namen
|
||||
und Zahlen von Filipes Creator:innen. Stattdessen baut diese Prüfung
|
||||
eine .xlsx mit **derselben Form** und erfundenen Namen. Die echten
|
||||
Dateien haben die Form belegt; die Prüfung hält sie fest.
|
||||
|
||||
WARUM DIE DATEI HIER GEBAUT WIRD UND NICHT DANEBEN LIEGT: Eine
|
||||
Beispieldatei im Repo altert unbemerkt. Eine, die im Lauf entsteht,
|
||||
sagt in ihrem Quelltext, was sie darstellt – und wer sie ändert,
|
||||
sieht sofort, was er ändert.
|
||||
|
||||
Aufruf: node server/pruef-xlsx.mjs
|
||||
===================================================================== */
|
||||
|
||||
|
||||
let fehler = 0;
|
||||
let geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
const { xlsxLesen, tabelleAbKopf, alsText, istZip, istAltesExcel, XlsxFehler } =
|
||||
await import("./workspace-xlsx.js");
|
||||
const { dauerLesen, zeitraumLesen, datumLesen, zahlLesen, csvZerlegen, spaltenRaten } =
|
||||
await import("./workspace-leistung.js");
|
||||
|
||||
/* =======================================================================
|
||||
EIN ZIP-SCHREIBER — damit die Prüfung ECHTE Dateien erzeugt
|
||||
=======================================================================
|
||||
Ohne ihn müsste ich eine fertige .xlsx ins Repo legen und hoffen,
|
||||
dass sie die Wirklichkeit trifft. So steht jedes Byte hier im
|
||||
Quelltext, und die Prüfung kann Fälle bauen, die es als Datei gar
|
||||
nicht gibt (kaputtes Verzeichnis, fehlendes Blatt, leere Tabelle).
|
||||
|
||||
`crc32` gibt es in Node seit v22 – fällt es aus, sagt die Prüfung
|
||||
das (dritter Ausgang) statt stillschweigend etwas Falsches zu bauen.
|
||||
======================================================================= */
|
||||
const { zipBauen, xlsxBauen, RELS, kannXlsxBauen } =
|
||||
await import("./helfer-xlsx-bauen.mjs");
|
||||
if (!kannXlsxBauen) {
|
||||
console.error("KONNTE NICHT NACHSEHEN: node:zlib kennt kein crc32 "
|
||||
+ "(gebraucht wird Node 22+). Die Pruefung baut ihre Dateien selbst "
|
||||
+ "und kann ohne Pruefsumme keine gueltige .xlsx erzeugen.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Die Form, die Backstage wirklich liefert
|
||||
======================================================================= */
|
||||
console.log("\n=== Die Backstage-Form ===");
|
||||
const BACKSTAGE = [
|
||||
["Datenzeitraum", "Creator*in-ID", "Creator*innen-Anmeldename", "Gruppe",
|
||||
"Diamanten", "LIVE-Dauer", "Gültige LIVE-Gehen-Tage", "Neue Follower*innen"],
|
||||
["2026-09-06 ~ 2026-09-06", "700001", "nova_live", "Spicy",
|
||||
"12.480", "86Std. 10Min. 52Sek.", "11", "205"],
|
||||
["2026-09-06 ~ 2026-09-06", "700002", "kiro_on", "Spicy",
|
||||
"3.900", "2Std. 5Min.", "4", "18"],
|
||||
];
|
||||
{
|
||||
const datei = xlsxBauen(BACKSTAGE);
|
||||
ok(istZip(datei), "die gebaute Datei ist ein ZIP (wie eine echte .xlsx)");
|
||||
const { blatt, zeilen } = xlsxLesen(datei);
|
||||
ok(blatt === "Sheet1", `das Blatt heisst "${blatt}"`);
|
||||
ok(zeilen.length === 3, `drei Zeilen gelesen (${zeilen.length})`);
|
||||
ok(zeilen[0].length === 8, `acht Spalten (${zeilen[0].length})`);
|
||||
ok(zeilen[0][0] === "Datenzeitraum" && zeilen[0][6] === "Gültige LIVE-Gehen-Tage",
|
||||
"die Umlaute kommen heil an");
|
||||
ok(zeilen[1][5] === "86Std. 10Min. 52Sek.",
|
||||
`die LIVE-Dauer steht woertlich da ("${zeilen[1][5]}")`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
2. Die Titelzeile, über die ich gestolpert wäre
|
||||
======================================================================= */
|
||||
console.log("\n=== Eine Titelzeile über der Tabelle ===");
|
||||
{
|
||||
/* So sieht "Creator:innen verwalten" aus: Zeile 1 eine einzige
|
||||
Zelle, die Überschriften erst darunter. */
|
||||
const mitTitel = [["Exportiert am :2026-09-11 13:10:35"], ...BACKSTAGE];
|
||||
const { zeilen } = xlsxLesen(xlsxBauen(mitTitel));
|
||||
ok(zeilen.length === 4, `vier Zeilen in der Datei (${zeilen.length})`);
|
||||
|
||||
const t = tabelleAbKopf(zeilen);
|
||||
ok(t.uebersprungen === 1, `eine Titelzeile faellt weg (${t.uebersprungen})`);
|
||||
ok(t.zeilen[0][0] === "Datenzeitraum",
|
||||
`die Ueberschrift ist jetzt die richtige ("${t.zeilen[0][0]}")`);
|
||||
|
||||
/* GEGENPROBE: Ohne Titelzeile darf NICHTS wegfallen -- sonst waere
|
||||
die Regel eine, die immer eine Zeile frisst. */
|
||||
const ohne = tabelleAbKopf(xlsxLesen(xlsxBauen(BACKSTAGE)).zeilen);
|
||||
ok(ohne.uebersprungen === 0, `ohne Titel faellt nichts weg (${ohne.uebersprungen})`);
|
||||
|
||||
/* UND EINE ECHTE SCHMALE TABELLE bleibt ganz. Drei Spalten sind eine
|
||||
Tabelle, keine Ueberschrift. */
|
||||
const schmal = tabelleAbKopf(
|
||||
xlsxLesen(xlsxBauen([["Datum", "Diamanten", "Dauer"], ["2026-09-06", "10", "20"]])).zeilen);
|
||||
ok(schmal.uebersprungen === 0, `eine schmale Tabelle bleibt unangetastet (${schmal.uebersprungen})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. Der teuerste Fehler: ein Zeitraum, der wie ein Tag aussieht
|
||||
======================================================================= */
|
||||
console.log("\n=== Zeitraum ist kein Tag ===");
|
||||
{
|
||||
ok(datumLesen("2026-09-01 ~ 2026-09-11") === "2026-09-01",
|
||||
"datumLesen allein greift sich weiterhin den ersten Tag – deshalb braucht es zeitraumLesen");
|
||||
const z = zeitraumLesen("2026-09-01 ~ 2026-09-11");
|
||||
ok(z?.von === "2026-09-01" && z?.bis === "2026-09-11",
|
||||
`der Zeitraum wird als solcher erkannt (${z?.von} bis ${z?.bis})`);
|
||||
const einTag = zeitraumLesen("2026-09-06 ~ 2026-09-06");
|
||||
ok(einTag?.von === einTag?.bis,
|
||||
"ein Zeitraum von EINEM Tag ist kein Zeitraum -- der geht durch");
|
||||
ok(zeitraumLesen("2026-09-06") === null,
|
||||
"ein einzelnes Datum faellt nicht darauf herein");
|
||||
ok(zeitraumLesen("Diamanten") === null, "und Text erst recht nicht");
|
||||
const deutsch = zeitraumLesen("01.09.2026 - 11.09.2026");
|
||||
ok(deutsch?.von === "2026-09-01" && deutsch?.bis === "2026-09-11",
|
||||
"auch die deutsche Schreibweise");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. Die Einheit ist die Falle
|
||||
======================================================================= */
|
||||
console.log("\n=== Dauer mit Einheit ===");
|
||||
{
|
||||
const faelle = [
|
||||
["86Std. 10Min. 52Sek.", 5171, "die echte Backstage-Schreibweise"],
|
||||
["86 Std 10 Min", 5170, "mit Leerzeichen"],
|
||||
["2 Stunden 5 Minuten", 125, "ausgeschrieben"],
|
||||
["3h 20m", 200, "englisch kurz"],
|
||||
["86:10:52", 5171, "als Uhrzeit"],
|
||||
["184", 184, "blosse Zahl bleibt eine Zahl (Minuten, wie bisher)"],
|
||||
["0", 0, "Null ist ein Wert, nicht nichts"],
|
||||
];
|
||||
for (const [text, soll, warum] of faelle) {
|
||||
const ist = dauerLesen(text);
|
||||
ok(ist === soll, `"${text}" -> ${ist} (erwartet ${soll}) – ${warum}`);
|
||||
}
|
||||
ok(dauerLesen("") === null && dauerLesen(null) === null,
|
||||
"leer bleibt leer -- und wird nicht zu 0");
|
||||
|
||||
/* WARUM DAS EINE EIGENE FUNKTION BRAUCHTE: Der alte Weg ergab hier
|
||||
nichts. Haette er "86" geliefert, stuenden 86 Minuten statt 5171
|
||||
in der Datenbank -- plausibel und um Faktor 60 daneben. */
|
||||
ok(zahlLesen("86Std. 10Min. 52Sek.") === null,
|
||||
"zahlLesen allein kommt damit nicht klar -- das ist der Grund fuer dauerLesen");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Der ganze Weg: Datei -> Text -> Zerlegung -> Spalten
|
||||
======================================================================= */
|
||||
console.log("\n=== Vom Excel bis zur Spaltenzuordnung ===");
|
||||
{
|
||||
const { zeilen } = xlsxLesen(xlsxBauen([["Exportiert am :…"], ...BACKSTAGE]));
|
||||
const text = alsText(tabelleAbKopf(zeilen).zeilen);
|
||||
ok(text.split("\n").length === 3, `drei Textzeilen (${text.split("\n").length})`);
|
||||
ok(text.includes("\t"), "TAB-getrennt -- genau wie eine eingefuegte Tabelle");
|
||||
|
||||
const tabelle = csvZerlegen(text);
|
||||
ok(tabelle.length === 3, `csvZerlegen findet drei Zeilen (${tabelle.length})`);
|
||||
ok(tabelle[0].length === 8, `und acht Spalten (${tabelle[0].length})`);
|
||||
|
||||
const zu = spaltenRaten(tabelle[0]);
|
||||
ok(tabelle[0][zu.diamanten] === "Diamanten", "die Diamanten-Spalte wird gefunden");
|
||||
ok(tabelle[0][zu.dauer_min] === "LIVE-Dauer", "die Dauer-Spalte ebenfalls");
|
||||
ok(tabelle[0][zu.follower_neu] === "Neue Follower*innen", "und die Follower-Spalte");
|
||||
ok(tabelle[0][zu.tag] === "Datenzeitraum", "und der Zeitraum als Datumsspalte");
|
||||
|
||||
/* GEFUNDEN AM ECHTEN EXPORT (14.09.2026): Das alte Muster
|
||||
`/datum|date|tag|day/i` erklaerte "Tage seit dem Beitritt" zur
|
||||
Datumsspalte -- Wert "65". In der Leistungstabelle daneben traefe
|
||||
es "Gueltige LIVE-Gehen-Tage" genauso. Beide sind Zaehlungen, kein
|
||||
Datum.
|
||||
|
||||
Die Gegenprobe steht daneben, damit "trifft nicht" nicht einfach
|
||||
heisst, dass gar nichts mehr trifft. */
|
||||
for (const [name, sollTreffen] of [
|
||||
["Datenzeitraum", true], ["Datum", true], ["Tag", true], ["Day", true],
|
||||
["Tage seit dem Beitritt", false], ["Gültige LIVE-Gehen-Tage", false],
|
||||
["Livestreams", false],
|
||||
]) {
|
||||
const trifft = spaltenRaten([name]).tag === 0;
|
||||
ok(trifft === sollTreffen,
|
||||
`"${name}" ${sollTreffen ? "ist" : "ist NICHT"} die Datumsspalte (erkannt: ${trifft})`);
|
||||
}
|
||||
|
||||
/* Und die Werte, die dahinter herauskommen. */
|
||||
ok(zahlLesen(tabelle[1][zu.diamanten]) === 12480,
|
||||
`"12.480" wird zu ${zahlLesen(tabelle[1][zu.diamanten])} und nicht zu 12,48`);
|
||||
ok(dauerLesen(tabelle[1][zu.dauer_min]) === 5171,
|
||||
`und die Dauer zu ${dauerLesen(tabelle[1][zu.dauer_min])} Minuten`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Lücken, Zahlen, Datumsformate
|
||||
======================================================================= */
|
||||
console.log("\n=== Was eine Tabelle sonst noch enthaelt ===");
|
||||
{
|
||||
/* FEHLENDE ZELLEN VERSCHIEBEN NICHTS. In der Datei steht eine leere
|
||||
Zelle gar nicht -- wer stur der Reihe nach liest, schiebt ab dort
|
||||
jede Spalte um eins. Die Zeile sieht danach voll aus. */
|
||||
const loecher = [["A", "B", "C", "D"], ["1", "", "", "4"]];
|
||||
const { zeilen } = xlsxLesen(xlsxBauen(loecher));
|
||||
ok(zeilen[1].length === 4, `die Luecken-Zeile hat vier Felder (${zeilen[1].length})`);
|
||||
ok(zeilen[1][0] === "1" && zeilen[1][3] === "4",
|
||||
`und "4" steht in Spalte 4, nicht in Spalte 2 ("${zeilen[1][3]}")`);
|
||||
}
|
||||
{
|
||||
/* ECHTE ZAHLEN UND ECHTE DATEN -- also nicht die Backstage-Form,
|
||||
sondern das, was entsteht, sobald jemand die Datei in Excel
|
||||
oeffnet und speichert. Dann stehen dort Seriennummern. */
|
||||
const blatt = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||
<row r="1"><c r="A1" t="inlineStr"><is><t>Datum</t></is></c><c r="B1" t="inlineStr"><is><t>Diamanten</t></is></c></row>
|
||||
<row r="2"><c r="A2" s="1"><v>46271</v></c><c r="B2"><v>12480</v></c></row>
|
||||
<row r="3"><c r="A3" s="0"><v>46271</v></c><c r="B3" t="b"><v>1</v></c></row>
|
||||
</sheetData></worksheet>`;
|
||||
const styles = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="14"/></cellXfs></styleSheet>`;
|
||||
const mappe = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Zahlen" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const datei = zipBauen([
|
||||
["xl/workbook.xml", mappe],
|
||||
["xl/_rels/workbook.xml.rels", RELS],
|
||||
["xl/styles.xml", styles],
|
||||
["xl/worksheets/sheet1.xml", blatt],
|
||||
]);
|
||||
const { blatt: name, zeilen } = xlsxLesen(datei);
|
||||
ok(name === "Zahlen", `der Blattname kommt aus der Mappe ("${name}")`);
|
||||
ok(zeilen[0][0] === "Datum", "eingebettete Zeichenketten (inlineStr) werden gelesen");
|
||||
ok(zeilen[1][0] === "2026-09-06",
|
||||
`die Seriennummer 46271 wird zum Datum ("${zeilen[1][0]}")`);
|
||||
ok(zeilen[1][1] === "12480", `eine gewoehnliche Zahl bleibt eine Zahl ("${zeilen[1][1]}")`);
|
||||
/* GEGENPROBE ZUM DATUM: DIESELBE Zahl ohne Datumsformat bleibt eine
|
||||
Zahl. Ohne sie hiesse die Zeile darueber nur, dass alles zu einem
|
||||
Datum wird. */
|
||||
ok(zeilen[2][0] === "46271",
|
||||
`dieselbe Zahl ohne Datumsformat bleibt eine Zahl ("${zeilen[2][0]}")`);
|
||||
ok(zeilen[2][1] === "WAHR", `ein Wahrheitswert wird lesbar ("${zeilen[2][1]}")`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
7. Was schiefgehen kann, muss SAGEN was schiefging
|
||||
======================================================================= */
|
||||
console.log("\n=== Der dritte Ausgang ===");
|
||||
{
|
||||
const versuch = (puffer, was) => {
|
||||
try { xlsxLesen(puffer); return { ok: true }; }
|
||||
catch (f) { return { ok: false, name: f?.name, text: f?.message }; }
|
||||
};
|
||||
const alt = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0]);
|
||||
ok(istAltesExcel(alt), "eine alte .xls wird am Inhalt erkannt");
|
||||
const a = versuch(alt);
|
||||
ok(!a.ok && a.name === "XlsxFehler" && /\.xlsx|CSV/.test(a.text),
|
||||
`und bekommt eine Antwort, die weiterhilft ("${(a.text || "").slice(0, 46)}…")`);
|
||||
|
||||
const b = versuch(Buffer.from("Datum;Diamanten\n2026-09-06;10\n", "utf8"));
|
||||
ok(!b.ok && /keine Excel-Datei/i.test(b.text || ""),
|
||||
`eine CSV wird nicht fuer eine Excel-Datei gehalten ("${(b.text || "").slice(0, 40)}…")`);
|
||||
|
||||
/* Ein ZIP, in dem kein Tabellenblatt steckt. */
|
||||
const c = versuch(zipBauen([["xl/workbook.xml", "<workbook/>"]]));
|
||||
ok(!c.ok && /Tabellenblatt/.test(c.text || ""),
|
||||
`ein ZIP ohne Blatt sagt genau das ("${(c.text || "").slice(0, 40)}…")`);
|
||||
|
||||
/* Ein abgeschnittenes ZIP -- die haeufigste kaputte Datei ueberhaupt. */
|
||||
const heil = xlsxBauen(BACKSTAGE);
|
||||
const d = versuch(heil.subarray(0, Math.floor(heil.length / 2)));
|
||||
ok(!d.ok && d.name === "XlsxFehler",
|
||||
`eine abgeschnittene Datei wird abgewiesen ("${(d.text || "").slice(0, 40)}…")`);
|
||||
|
||||
/* GEGENPROBE: Die heile Datei geht durch. Ohne sie hiesse alles
|
||||
oben nur, dass gar nichts gelesen wird. */
|
||||
ok(versuch(heil).ok, "die heile Datei dagegen wird gelesen");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
8. Die Datumsrechnung, an bekannten Punkten festgenagelt
|
||||
=======================================================================
|
||||
Beim Schreiben dieser Datei habe ich mich selbst vertan: Ich hielt
|
||||
46264 fuer den 06.09.2026, es ist der 30.08. Der Code hatte recht,
|
||||
meine Erwartung war falsch.
|
||||
|
||||
Damit vor allem der NULLPUNKT nicht unbemerkt verrutscht, stehen hier
|
||||
zwei Werte, die jeder nachschlagen kann. Verrutscht die Basis um
|
||||
einen Tag, faellt es hier auf und nicht erst in einer Tabelle.
|
||||
======================================================================= */
|
||||
console.log("");
|
||||
console.log("=== Der Nullpunkt der Excel-Zeitrechnung ===");
|
||||
{
|
||||
const alsDatum = (n) => {
|
||||
const blatt = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
+ '<sheetData><row r="1"><c r="A1" s="1"><v>' + n + '</v></c></row></sheetData></worksheet>';
|
||||
const styles = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
+ '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
+ '<cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="14"/></cellXfs></styleSheet>';
|
||||
const mappe = '<?xml version="1.0"?>'
|
||||
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
+ '<sheets><sheet name="X" sheetId="1" r:id="rId1"/></sheets></workbook>';
|
||||
return xlsxLesen(zipBauen([
|
||||
["xl/workbook.xml", mappe],
|
||||
["xl/_rels/workbook.xml.rels", RELS],
|
||||
["xl/styles.xml", styles],
|
||||
["xl/worksheets/sheet1.xml", blatt],
|
||||
])).zeilen[0][0];
|
||||
};
|
||||
for (const [n, soll] of [[36526, "2000-01-01"], [45292, "2024-01-01"]]) {
|
||||
const ist = alsDatum(n);
|
||||
ok(ist === soll, `Seriennummer ${n} ist der ${soll} (gelesen: ${ist})`);
|
||||
}
|
||||
/* DIE EINE STELLE, AN DER ES NICHT STIMMT -- und sie steht hier,
|
||||
damit niemand sie fuer einen Fehler haelt: Excel haelt 1900
|
||||
faelschlich fuer ein Schaltjahr. Wer den Nullpunkt so legt, dass
|
||||
alle Daten ab dem 01.03.1900 stimmen, liegt davor um einen Tag
|
||||
daneben. In einer Leistungstabelle kommt kein Datum von 1900 vor. */
|
||||
ok(alsDatum(1) === "1899-12-31",
|
||||
"vor dem 01.03.1900 liegt die Rechnung um einen Tag daneben (Excels Schaltjahr-Irrtum)");
|
||||
}
|
||||
|
||||
console.log(`\n${geprueft} Pruefungen`);
|
||||
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -43,6 +43,7 @@
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { xlsxLesen, tabelleAbKopf, alsText } from "./workspace-xlsx.js";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istLeitung,
|
||||
sichtbareCreatorIds, ROLLEN_SORTIERUNG, heuteLokal,
|
||||
@@ -569,6 +570,102 @@ export function zahlLesen(roh) {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Macht aus "86Std. 10Min. 52Sek." Minuten.
|
||||
*
|
||||
* GEMESSEN AN DER ECHTEN BACKSTAGE-AUSGABE (14.09.2026): Die Spalte
|
||||
* "LIVE-Dauer" enthaelt dort woertlich `86Std. 10Min. 52Sek.` -- ohne
|
||||
* Leerzeichen vor der Einheit. `zahlLesen` machte daraus `null`, weil
|
||||
* "86.10.52." keine Zahl ist. Die Dauer fiel also stillschweigend weg;
|
||||
* der Tag wurde mit Diamanten, aber ohne Minuten geschrieben.
|
||||
*
|
||||
* DIE EINHEIT IST DIE FALLE, nicht die Zahl. Haette `zahlLesen` hier
|
||||
* "86" zurueckgegeben -- was bei einem anderen Trennzeichen leicht
|
||||
* passiert waere --, stuenden 86 Minuten statt 5171 in der Datenbank.
|
||||
* Das sieht richtig aus und ist um Faktor 60 daneben.
|
||||
*
|
||||
* Verstanden werden:
|
||||
* "86Std. 10Min. 52Sek." deutsche Backstage-Ausgabe
|
||||
* "86 Std 10 Min" mit Leerzeichen
|
||||
* "3h 20m", "3 hrs 20 min" englische Fassungen
|
||||
* "86:10:52" / "3:20" Doppelpunktform
|
||||
* "184" blosse Zahl -> Minuten, wie bisher
|
||||
*
|
||||
* OHNE EINHEIT BLEIBT ES EINE ZAHL. Eine blosse "184" als Stunden zu
|
||||
* deuten waere geraten; die bestehenden Exporte liefern dort Minuten,
|
||||
* und dabei bleibt es.
|
||||
*/
|
||||
export function dauerLesen(roh) {
|
||||
const t = String(roh ?? "").trim();
|
||||
if (!t) return null;
|
||||
|
||||
/* Doppelpunktform zuerst: "86:10:52" ist eindeutig und wuerde von der
|
||||
Einheitensuche unten nicht erkannt. Zwei Teile heissen Stunden und
|
||||
Minuten -- so schreibt es jede Uhr. */
|
||||
const uhr = /^(\d+):([0-5]?\d)(?::([0-5]?\d))?$/.exec(t);
|
||||
if (uhr) {
|
||||
const std = Number(uhr[1]);
|
||||
const min = Number(uhr[2]);
|
||||
const sek = uhr[3] === undefined ? 0 : Number(uhr[3]);
|
||||
return Math.round(std * 60 + min + sek / 60);
|
||||
}
|
||||
|
||||
/* Einheiten einsammeln. Der Punkt hinter "Std." gehoert nicht dazu,
|
||||
die Reihenfolge im Text ist egal, und jede Einheit zaehlt nur
|
||||
einmal -- "1 Std 2 Std" ist kein gueltiger Wert und soll nicht
|
||||
stillschweigend zu drei Stunden werden. */
|
||||
const einheiten = [
|
||||
["std", /(\d+(?:[.,]\d+)?)\s*(?:std|stunden|stunde|hrs|hours|hour|h)(?![a-z])/i],
|
||||
["min", /(\d+(?:[.,]\d+)?)\s*(?:min|minuten|minute|mins|m)(?![a-z])/i],
|
||||
["sek", /(\d+(?:[.,]\d+)?)\s*(?:sek|sekunden|sekunde|secs|sec|s)(?![a-z])/i],
|
||||
];
|
||||
let minuten = 0;
|
||||
let gefunden = false;
|
||||
for (const [art, muster] of einheiten) {
|
||||
const m = muster.exec(t);
|
||||
if (!m) continue;
|
||||
const n = Number(String(m[1]).replace(",", "."));
|
||||
if (!Number.isFinite(n)) continue;
|
||||
gefunden = true;
|
||||
if (art === "std") minuten += n * 60;
|
||||
else if (art === "min") minuten += n;
|
||||
else minuten += n / 60;
|
||||
}
|
||||
if (gefunden) return Math.round(minuten);
|
||||
|
||||
/* Keine Einheit erkannt -> wie bisher eine blosse Zahl. */
|
||||
return zahlLesen(t);
|
||||
}
|
||||
|
||||
/** Ist das ein ZEITRAUM statt eines Tages?
|
||||
*
|
||||
* Backstage schreibt in "Datenzeitraum" woertlich
|
||||
* `2026-09-01 ~ 2026-09-11`. `datumLesen` griff sich davon die erste
|
||||
* Haelfte und gab "2026-09-01" zurueck -- elf Tage Diamanten waeren
|
||||
* damit auf den 1. September geschrieben worden. Gemessen am echten
|
||||
* Export vom 12.09.2026, nicht vermutet.
|
||||
*
|
||||
* DAS IST DER TEUERSTE FEHLER, den dieser Weg machen kann: Die Zahl
|
||||
* steht da, sie ist gross, sie sieht richtig aus, und niemand kann
|
||||
* spaeter sagen, dass sie elf Tage enthaelt.
|
||||
*
|
||||
* Rueckgabe: {von, bis} bei einem erkannten Zeitraum, sonst null.
|
||||
* Ein Zeitraum von EINEM Tag (von === bis) ist kein Zeitraum -- dann
|
||||
* ist die Datei genau das, was hier gebraucht wird.
|
||||
*/
|
||||
export function zeitraumLesen(roh) {
|
||||
const t = String(roh ?? "").trim();
|
||||
/* Zwei Datumsangaben, getrennt durch ~, -, bis oder to. Das Muster
|
||||
verlangt ZWEI vollstaendige Daten; ein einzelnes "2026-09-06"
|
||||
faellt nicht darauf herein. */
|
||||
const m = /^(\d{4}-\d{2}-\d{2})[^\d]{1,6}(\d{4}-\d{2}-\d{2})$/.exec(t)
|
||||
|| /^(\d{1,2}\.\d{1,2}\.\d{4})[^\d]{1,6}(\d{1,2}\.\d{1,2}\.\d{4})$/.exec(t);
|
||||
if (!m) return null;
|
||||
const von = datumLesen(m[1]);
|
||||
const bis = datumLesen(m[2]);
|
||||
if (!von || !bis) return null;
|
||||
return { von, bis };
|
||||
}
|
||||
|
||||
/** Macht aus einem Textwert ein Datum "JJJJ-MM-TT".
|
||||
* Erkennt 2026-09-06, 06.09.2026 und 09/06/2026. */
|
||||
export function datumLesen(roh) {
|
||||
@@ -589,7 +686,21 @@ export function datumLesen(roh) {
|
||||
* Nur ein Vorschlag -- bestätigt wird er in der Vorschau. */
|
||||
export function spaltenRaten(kopf) {
|
||||
const muster = {
|
||||
tag: /datum|date|tag|day/i,
|
||||
/* "tag" UND "day" NUR ALS GANZES WORT (14.09.2026).
|
||||
|
||||
Gemessen an der echten Backstage-Mitgliederliste: Dort steht
|
||||
"Tage seit dem Beitritt", und das alte Muster erklaerte diese
|
||||
Spalte zur Datumsspalte -- Wert "65". In der Leistungstabelle
|
||||
daneben trifft es "Gueltige LIVE-Gehen-Tage" genauso.
|
||||
|
||||
Aufgefallen ist es nicht beim Lesen, sondern als ich den ganzen
|
||||
Weg mit der echten Datei durchlaufen liess.
|
||||
|
||||
"zeitraum" kam dazu, weil Backstages Spalte "Datenzeitraum"
|
||||
heisst -- sie wurde bisher nur zufaellig gefunden, weil in
|
||||
"Daten" die englische Silbe "date" steckt. Auf Zufall soll das
|
||||
nicht beruhen. */
|
||||
tag: /datum|date|zeitraum|^\s*tage?\s*$|^\s*day\s*$/i,
|
||||
diamanten: /diamant|diamond/i,
|
||||
dauer_min: /dauer|duration|minut|zeit.*live|live.*zeit/i,
|
||||
zuschauer_avg: /(durchschnitt|avg|average|⌀|ø).*(zuschauer|viewer)|(zuschauer|viewer).*(durchschnitt|avg)/i,
|
||||
@@ -749,7 +860,28 @@ function netzwerkLesen(text, person, tagVorgabe) {
|
||||
const rohWer = String(rohHandle).trim() || String(rohName).trim();
|
||||
if (!rohWer) { schlecht.push({ zeile: i + 1, grund: "Keine Person in der Zeile." }); continue; }
|
||||
|
||||
const tag = ausTabelle ? datumLesen(z[zu.tag]) : tagVorgabe;
|
||||
/* EIN ZEITRAUM IST KEIN TAG (14.09.2026).
|
||||
|
||||
Backstage schreibt in "Datenzeitraum" `2026-09-01 ~ 2026-09-11`.
|
||||
`datumLesen` griff sich davon den ersten Tag -- elf Tage
|
||||
Diamanten waeren auf den 1. September geschrieben worden. Die
|
||||
Zahl steht da, sie ist gross, sie sieht richtig aus, und niemand
|
||||
kann spaeter sagen, dass elf Tage darin stecken.
|
||||
|
||||
Gemessen am echten Export vom 12.09.2026, nicht vermutet.
|
||||
|
||||
Ein Zeitraum von EINEM Tag geht durch -- dann ist die Datei
|
||||
genau das, was hier gebraucht wird. */
|
||||
const zeitraum = ausTabelle ? zeitraumLesen(z[zu.tag]) : null;
|
||||
if (zeitraum && zeitraum.von !== zeitraum.bis) {
|
||||
schlecht.push({ zeile: i + 1,
|
||||
grund: `Zeitraum ${zeitraum.von} bis ${zeitraum.bis} – das sind mehrere Tage `
|
||||
+ "auf einmal. Stell in Backstage den Zeitraum auf EINEN Tag." });
|
||||
continue;
|
||||
}
|
||||
const tag = ausTabelle
|
||||
? (zeitraum ? zeitraum.von : datumLesen(z[zu.tag]))
|
||||
: tagVorgabe;
|
||||
if (!tag) { schlecht.push({ zeile: i + 1, grund: `Datum unlesbar: "${z[zu.tag]}"` }); continue; }
|
||||
if (tag > heuteLokal()) { schlecht.push({ zeile: i + 1, grund: `${tag} liegt in der Zukunft` }); continue; }
|
||||
|
||||
@@ -767,7 +899,11 @@ function netzwerkLesen(text, person, tagVorgabe) {
|
||||
const werte = { creator_id: p.id, name: p.name, tag };
|
||||
let hatZahl = false;
|
||||
for (const feld of messwerte) {
|
||||
const w = zahlLesen(z[zu[feld]]);
|
||||
/* Die Dauer hat eine EINHEIT, die anderen Felder nicht.
|
||||
"86Std. 10Min. 52Sek." ergibt sonst nichts (zahlLesen scheitert
|
||||
an "86.10.52.") -- und bei einem anderen Trennzeichen waere es
|
||||
schlimmer: 86 statt 5171, Faktor 60 daneben und plausibel. */
|
||||
const w = feld === "dauer_min" ? dauerLesen(z[zu[feld]]) : zahlLesen(z[zu[feld]]);
|
||||
werte[feld] = w;
|
||||
if (w !== null && w !== undefined) hatZahl = true;
|
||||
}
|
||||
@@ -868,6 +1004,65 @@ leistungRouter.post("/workspace/api/leistung/netzwerk-import", gleicheHerkunft,
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
EXCEL-DATEIEN (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."
|
||||
|
||||
Die Datei kommt roh herein und geht als TEXT wieder hinaus -- genau
|
||||
in der Form, die auch beim Einfuegen aus dem Browser entsteht
|
||||
(TAB-getrennt). Danach laeuft alles durch dieselbe Zerlegung,
|
||||
dieselbe Vorschau und dieselbe Bestaetigung wie bisher.
|
||||
|
||||
WARUM NICHT GLEICH IMPORTIEREN: Eine Datei, die direkt schreibt,
|
||||
nimmt dem Menschen den Blick auf das, was passieren wird. Die
|
||||
Vorschau ist der ganze Schutz dieses Wegs; sie zu umgehen waere
|
||||
bequem und falsch.
|
||||
|
||||
WER DARF: wer auch eintragen darf. Gelesen wird nichts Fremdes --
|
||||
die Antwort enthaelt nur, was in der hochgeladenen Datei stand.
|
||||
===================================================================== */
|
||||
leistungRouter.post("/workspace/api/leistung/tabelle-lesen",
|
||||
gleicheHerkunft, express.raw({ type: "*/*", limit: "12mb" }), (req, res) => {
|
||||
try {
|
||||
if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
if (!Buffer.isBuffer(req.body) || !req.body.length) {
|
||||
return res.status(400).json({ fehler: "Keine Datei empfangen." });
|
||||
}
|
||||
let gelesen;
|
||||
try {
|
||||
gelesen = xlsxLesen(req.body);
|
||||
} catch (fehler) {
|
||||
/* DER DRITTE AUSGANG: nicht "ging nicht", sondern WAS nicht
|
||||
ging. Sonst sucht der Mensch den Fehler bei sich. */
|
||||
if (fehler?.name === "XlsxFehler") {
|
||||
return res.status(415).json({ fehler: fehler.message });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
const t = tabelleAbKopf(gelesen.zeilen);
|
||||
if (!t.zeilen.length) {
|
||||
return res.status(422).json({ fehler: "In der Tabelle steht nichts." });
|
||||
}
|
||||
const text = alsText(t.zeilen);
|
||||
res.json({
|
||||
text,
|
||||
blatt: gelesen.blatt,
|
||||
zeilen: t.zeilen.length,
|
||||
spalten: t.zeilen[0]?.length || 0,
|
||||
/* Wie viele Zeilen oben weggefallen sind. Steht in der Antwort,
|
||||
damit die Oberflaeche es SAGEN kann -- eine Tabelle, die
|
||||
stillschweigend anders anfaengt als die Datei, ist genau die
|
||||
Sorte Hilfsbereitschaft, die spaeter niemand nachvollzieht. */
|
||||
uebersprungen: t.uebersprungen,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[leistung] Tabelle lesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
DER WEG UEBER DIE TIKTOK-DATEI IST ENTFERNT (11.09.2026)
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/* =====================================================================
|
||||
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. `ä` und `ä` 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");
|
||||
}
|
||||
Reference in New Issue
Block a user