diff --git a/server/helfer-xlsx-bauen.mjs b/server/helfer-xlsx-bauen.mjs
new file mode 100644
index 00000000..7b49d7a9
--- /dev/null
+++ b/server/helfer-xlsx-bauen.mjs
@@ -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 = `
+
+
+`;
+
+/** 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 `${nummer(String(w))}`;
+ }).join("");
+ return `${zellen}`;
+ }).join("");
+
+ const blatt = `
+${zeilenXml}`;
+ const strings = `
+`
+ + texte.map((t) => `${t.replace(/&/g, "&").replace(/`).join("")
+ + "";
+ const mappe = `
+`;
+
+ return zipBauen([
+ ["xl/workbook.xml", mappe],
+ ["xl/_rels/workbook.xml.rels", RELS],
+ ["xl/sharedStrings.xml", strings],
+ ["xl/worksheets/sheet1.xml", blatt],
+ ]);
+}
+
diff --git a/server/pruef-backstage-import.mjs b/server/pruef-backstage-import.mjs
index 977c5efb..1175b8d2 100644
--- a/server/pruef-backstage-import.mjs
+++ b/server/pruef-backstage-import.mjs
@@ -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);
diff --git a/server/pruef-xlsx.mjs b/server/pruef-xlsx.mjs
new file mode 100644
index 00000000..f78b11a5
--- /dev/null
+++ b/server/pruef-xlsx.mjs
@@ -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 = `
+
+DatumDiamanten
+4627112480
+462711
+`;
+ const styles = `
+
+`;
+ const mappe = `
+`;
+ 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", ""]]));
+ 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 = ''
+ + ''
+ + '' + n + '';
+ const styles = ''
+ + ''
+ + '';
+ const mappe = ''
+ + ''
+ + '';
+ 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);
diff --git a/server/workspace-leistung.js b/server/workspace-leistung.js
index 2590eabb..7532b32d 100644
--- a/server/workspace-leistung.js
+++ b/server/workspace-leistung.js
@@ -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)
diff --git a/server/workspace-xlsx.js b/server/workspace-xlsx.js
new file mode 100644
index 00000000..70f08d0c
--- /dev/null
+++ b/server/workspace-xlsx.js
@@ -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 (``-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(/]*)?>([\s\S]*?)<\/si>/g)) {
+ let stueck = "";
+ for (const t of eintrag[1].matchAll(/]*)?>([\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(/]*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 = /]*>([\s\S]*?)<\/cellXfs>/.exec(text);
+ if (!block) return vorlagen;
+ let nummer = 0;
+ for (const xf of block[1].matchAll(/]*>/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 = /]*>/.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(`]*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(/]*>([\s\S]*?)<\/row>/g)) {
+ const felder = [];
+ for (const zelle of zeile[1].matchAll(/]*)(?:\/>|>([\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(/]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1] ?? -1);
+ wert = texte[i] ?? "";
+ } else if (typ === "inlineStr") {
+ for (const t of inhalt.matchAll(/]*)?>([\s\S]*?)<\/t>/g)) {
+ wert += entschluesseln(t[1]);
+ }
+ } else {
+ const roh2 = /]*)?>([\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");
+}
diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest
index c628b369..d2b4e034 100644
--- a/workspace/app.webmanifest
+++ b/workspace/app.webmanifest
@@ -13,25 +13,25 @@
"theme_color": "#06090f",
"icons": [
{
- "src": "/assets/img/app-symbole/workspace-192.png?v=202609120025",
+ "src": "/assets/img/app-symbole/workspace-192.png?v=202609141135",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-512.png?v=202609120025",
+ "src": "/assets/img/app-symbole/workspace-512.png?v=202609141135",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609120025",
+ "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609141135",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
- "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609120025",
+ "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609141135",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/workspace/assets/css/leistung.css b/workspace/assets/css/leistung.css
index 7a987f57..e4808aff 100644
--- a/workspace/assets/css/leistung.css
+++ b/workspace/assets/css/leistung.css
@@ -417,6 +417,14 @@
.feld-hinweis {
margin: 5px 0 0; font-size: .76rem; color: var(--text-still);
}
+/* "… oder direkt die Datei": steht UNTER dem Einfuegefeld und gehoert
+ sichtbar dazu, ohne ihm den Rang abzulaufen. Das Dateifeld selbst
+ bleibt klein -- es ist der zweite Weg, nicht der erste. */
+.feld-hinweis--oder {
+ display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
+ margin-top: 8px;
+}
+.feld-datei { flex: 1 1 220px; min-width: 0; font-size: .76rem; }
/* Ein Fenster, das laenger ist als der Bildschirm, versteckt seine
eigenen Knoepfe. Gerollt wird INNEN, damit "Uebernehmen" immer
diff --git a/workspace/assets/js/leistung.js b/workspace/assets/js/leistung.js
index 6acb200c..af2413d6 100644
--- a/workspace/assets/js/leistung.js
+++ b/workspace/assets/js/leistung.js
@@ -476,26 +476,66 @@
gelesen ergibt Zeichensalat -- und der schlimmste Ausgang waere
nicht "geht nicht", sondern eine Vorschau mit Zahlen, die
irgendwo aus dem Dateikopf stammen. */
+ /* WAS IST DAS UEBERHAUPT FUER EINE DATEI? (11.09.2026, erweitert 14.09.2026)
+
+ Filipe zuerst: "meine excel datei kann ich nicht auswaehlen
+ wieso??" -- dann: "mach doch bitte so dass man alle dateien hoch
+ laden könne auch excel dateien."
+
+ Erkannt wird am INHALT, nicht an der Endung: Eine umbenannte
+ Datei ist keine andere Datei. Eine .xlsx ist ein ZIP-Archiv und
+ beginnt mit "PK", die alte .xls mit dem OLE-Kennzeichen
+ D0 CF 11 E0.
+
+ Seit heute wird die .xlsx nicht mehr abgewiesen, sondern
+ GELESEN: Der Server macht daraus eine TAB-getrennte Tabelle --
+ genau die Form, die auch beim Einfuegen aus dem Browser
+ entsteht. Danach laeuft alles durch dieselbe Vorschau wie
+ bisher. */
const anfang = new Uint8Array(await datei.slice(0, 8).arrayBuffer());
const beginnt = (...b) => b.every((x, i) => anfang[i] === x);
- if (beginnt(0x50, 0x4b) || beginnt(0xd0, 0xcf, 0x11, 0xe0)) {
+ let ausExcel = null;
+
+ if (beginnt(0x50, 0x4b)) {
+ $('import-fehler').textContent = 'Excel-Datei wird gelesen …';
+ try {
+ const a = await hole('/workspace/api/leistung/tabelle-lesen', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: await datei.arrayBuffer(),
+ });
+ const d = await a.json().catch(() => ({}));
+ if (!a.ok) {
+ $('import-fehler').textContent = d.fehler || 'Die Excel-Datei ließ sich nicht lesen.';
+ $('datei').value = '';
+ return;
+ }
+ importText = d.text;
+ ausExcel = d;
+ $('import-fehler').textContent = '';
+ } catch {
+ $('import-fehler').textContent = 'Die Excel-Datei konnte nicht gelesen werden.';
+ $('datei').value = '';
+ return;
+ }
+ } else if (beginnt(0xd0, 0xcf, 0x11, 0xe0)) {
+ /* Die ALTE .xls ist kein ZIP, sondern ein ganz anderes Format
+ (BIFF, vor 2007). Sie zu lesen waere ein zweiter Leser fuer
+ einen Fall, den Backstage nicht erzeugt. */
$('import-fehler').innerHTML =
- 'Das ist eine Excel-Datei. Hier passt eine Text-Tabelle '
- + '(CSV) hinein. Zwei Wege, beide dauern keine Minute: '
- + '1. In Excel alles markieren (Strg+A), kopieren (Strg+C) – '
- + 'und statt hier den Knopf „Backstage-Tabelle einfügen" nehmen. '
- + 'Dort einfügen, fertig. '
- + '2. Oder in Excel: Datei → Speichern unter und als '
- + 'Dateityp CSV UTF-8 (durch Trennzeichen getrennt) wählen.';
+ 'Das ist eine alte Excel-Datei (.xls). Speichere sie in Excel '
+ + 'einmal als .xlsx oder als CSV UTF-8 – beides geht dann durch.';
$('datei').value = '';
return;
}
- try {
- importText = await datei.text();
- } catch {
- $('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.';
- return;
+ if (!ausExcel) {
+ try {
+ importText = await datei.text();
+ } catch {
+ $('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.';
+ return;
+ }
}
/* Eine leere Datei ist kein Lesefehler, sieht aber wie einer aus. */
@@ -736,6 +776,57 @@
$('netz-text').focus();
});
$('netz-zu').addEventListener('click', () => $('netz-dialog').close());
+
+ /* DIE BACKSTAGE-AUSGABE ALS DATEI (14.09.2026).
+
+ Sie enthaelt ALLE Creator auf einmal -- deshalb hier und nicht im
+ Einzel-Import daneben. Der Weg ist bewusst derselbe wie beim
+ Einfuegen: Die Datei wird zu Text, der Text landet im Feld, und ab
+ da laeuft die vorhandene Vorschau. Ein eigener Ablauf fuer Dateien
+ waere eine zweite Fassung derselben Regeln. */
+ $('netz-datei').addEventListener('change', async (e) => {
+ const datei = e.target.files?.[0];
+ if (!datei) return;
+ $('netz-fehler').textContent = 'Datei wird gelesen …';
+ try {
+ const roh = new Uint8Array(await datei.slice(0, 8).arrayBuffer());
+ let text = null;
+ let ausExcel = null;
+ if (roh[0] === 0x50 && roh[1] === 0x4b) {
+ const a = await hole('/workspace/api/leistung/tabelle-lesen', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: await datei.arrayBuffer(),
+ });
+ const d = await a.json().catch(() => ({}));
+ if (!a.ok) { $('netz-fehler').textContent = d.fehler || 'Ging nicht.'; return; }
+ text = d.text;
+ ausExcel = d;
+ } else if (roh[0] === 0xd0 && roh[1] === 0xcf) {
+ $('netz-fehler').textContent = 'Das ist eine alte Excel-Datei (.xls). '
+ + 'Speichere sie einmal als .xlsx oder als CSV.';
+ return;
+ } else {
+ text = await datei.text();
+ }
+ if (!text || !text.trim()) { $('netz-fehler').textContent = 'Die Datei ist leer.'; return; }
+ $('netz-text').value = text;
+ $('netz-fehler').textContent = '';
+ /* WAS ICH MIT DER DATEI GEMACHT HABE, STEHT DA. Eine Tabelle, die
+ stillschweigend anders anfaengt als die Datei (Backstage setzt
+ bei einer Ausgabe eine Titelzeile darueber), ist die Sorte
+ Hilfsbereitschaft, die spaeter niemand nachvollzieht. */
+ if (ausExcel) {
+ melde(`Excel gelesen: Blatt „${ausExcel.blatt}", ${ausExcel.zeilen} Zeilen, `
+ + `${ausExcel.spalten} Spalten`
+ + (ausExcel.uebersprungen ? ` (${ausExcel.uebersprungen} Titelzeile übersprungen)` : ''));
+ setTimeout(() => melde(''), 7000);
+ }
+ await netzVorschau();
+ } catch {
+ $('netz-fehler').textContent = 'Die Datei konnte nicht gelesen werden.';
+ }
+ });
/* Beim EINFÜGEN sofort nachsehen -- nicht erst auf einen Knopf warten.
Wer eine Tabelle einfügt, will wissen, ob sie erkannt wurde; ein
zweiter Klick dazwischen ist nur eine Gelegenheit, es nicht zu tun. */
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index 2fc9a68d..14d7df1a 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
-
-
-
-
-
-
+
+
+
+
+
+
-
+
@@ -252,10 +252,10 @@
-
-
-
-
-
+
+
+
+
+