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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index a9d1f8b8..56cb1ed0 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 6d6ad805..56f60377 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index adca2a19..9c117ed6 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 8ac66e22..d7758729 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 04e615b8..c03cc946 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 09254be4..e9c1e200 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 7f256f08..4e0fad29 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609120025", + "src": "/assets/img/app-symbole/crew-192.png?v=202609141135", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609120025", + "src": "/assets/img/app-symbole/crew-512.png?v=202609141135", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609120025", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609141135", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609120025", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609141135", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 06bf8c72..9576ae24 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 4719b819..7d9149db 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index babf3f52..01e9ff99 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index e6ead905..68514a22 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index de1a8448..6ad16ccf 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -288,6 +288,16 @@ + +

+ … oder direkt die Datei aus Backstage: + +

@@ -348,12 +358,13 @@

Aus dem Export einlesen

- Eine Text-Tabelle (CSV) mit einer Zeile je Tag. Erst - siehst du, was passieren würde – geschrieben wird erst, wenn du - bestätigst. Dieselbe Datei zweimal einzulesen richtet keinen Schaden an. -
Eine Excel-Datei (.xlsx) passt hier - nicht hinein – dafür gibt es den Knopf „Backstage-Tabelle einfügen": - in Excel markieren, kopieren, dort einfügen. + Eine Tabelle mit einer Zeile je Tag – Excel (.xlsx) oder + CSV. Erst siehst du, was passieren würde; geschrieben wird + erst, wenn du bestätigst. Dieselbe Datei zweimal einzulesen richtet keinen + Schaden an. +
Für eine Datei mit allen + Creator:innen (die Backstage-Ausgabe) nimm den Knopf „Backstage-Tabelle + einfügen" – dort geht sie auch als Datei.

@@ -382,10 +393,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index ffc6d345..aa3a1d5a 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 7c650533..e2279e40 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 87c9ad03..defb771e 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 5cbe10d7..2efe9703 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 03c00e4f..201ab3d9 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@
- - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 69791afb..f8ef0fdc 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index c57bb304..258e5177 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 594331c6..885b8ddd 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 19348026..220e68f4 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 09dbbc3e..b8999df7 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 34a65d66..a84e75db 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 3fcab583..c32ce1b7 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index ae891cda..f6975416 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,9 +190,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 094071a0..e860d14a 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 3889e10f..980442da 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +