diff --git a/server/pruef-backstage-import.mjs b/server/pruef-backstage-import.mjs new file mode 100644 index 00000000..4f939f62 --- /dev/null +++ b/server/pruef-backstage-import.mjs @@ -0,0 +1,316 @@ +/* ===================================================================== + DER BACKSTAGE-IMPORT — eine Tabelle, alle Creator auf einmal. + + Filipe (10.09.2026): "wie sehr wir diese seite hier mit dem backstage + verbinden könnten von tiktok. ohne probleme und ganz schnell." + + Die Recherche ergab: Backstage hat keine Schnittstelle und keinen + Export. Was es hat, ist eine Tabelle im Browser. Also: markieren, + kopieren, einfügen -- und der Workspace ordnet die Zeilen den + Creators zu. + + DIE ZUORDNUNG IST DIE GANZE ARBEIT, und sie ist auch die ganze + Gefahr: Eine falsch zugeordnete Zahl ist schlimmer als eine + fehlende, weil die fehlende auffällt. Deshalb prüft diese Datei vor + allem, was NICHT passiert: + + - eine unbekannte Person wird gemeldet, nicht geraten + - eine Zeile ohne Zahlen wird übersprungen, nicht als Null + geschrieben (sie würde sonst einen echten Tag überschreiben) + - ein Datum in der Zukunft wird abgelehnt + - ein Scout kommt gar nicht erst an diesen Weg + + Aufruf: node server/pruef-backstage-import.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { scryptSync, randomBytes } from "node:crypto"; +import { notbremse } from "./helfer-notbremse.mjs"; + +notbremse(240_000, "pruef-backstage-import"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const PORT = 4389; +const BASIS = `http://127.0.0.1:${PORT}`; +const ordner = mkdtempSync(join(tmpdir(), "ws-backstage-")); +const DB = join(ordner, "workspace.db"); +const jetzt = new Date().toISOString(); +const heute = new Date().toISOString().slice(0, 10); +const gestern = new Date(Date.now() - 86400000).toISOString().slice(0, 10); +const morgen = new Date(Date.now() + 86400000).toISOString().slice(0, 10); + +let gemacht = 0, fehler = 0; +const ok = (b, text) => { + gemacht++; + if (b) console.log(` ok ${text}`); + else { fehler++; console.log(` FEHL ${text}`); } +}; + +let kind = null; +async function starten() { + kind = spawn(process.execPath, [join(HIER, "index.js")], { + env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT), + SITE_ACCESS_SECRET: "lokaler-test", + SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" }, + stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + for (let i = 0; i < 80; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (kind.exitCode !== null) break; + try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); + if (a.status < 500) return; } catch { /* noch nicht oben */ } + } + throw new Error("Server startete nicht:\n" + aus.slice(-1500)); +} +await starten(); +kind.kill(); +await new Promise((r) => setTimeout(r, 600)); + +let idNova, idKiro, idLumi; +{ + const d = new DatabaseSync(DB); + const anlegen = (name, rolle, code, tiktok) => { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, tiktok) VALUES (?,?,?,?,?,1,?,?)") + .run(name, rolle, hash, salz, 32768, jetzt, tiktok ?? null); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + anlegen("Filipe", "admin", "CODE-DOGI-0001"); + anlegen("Sam", "scout", "CODE-SAM-00001"); + /* Nova hat ein Handle, Kiro NICHT -- er wird ueber den Namen + gefunden. Lumi hat ein Handle mit anderer Schreibweise als in der + Tabelle: Genau daran zeigt sich, ob normalisiert wird. */ + idNova = anlegen("Nova", "creator", "CODE-NOVA-0001", "nova_live"); + idKiro = anlegen("Kiro", "creator", "CODE-KIRO-0001", null); + idLumi = anlegen("Lumi", "creator", "CODE-LUMI-0001", "LumiStreamt"); + d.close(); +} +await starten(); + +const anmelden = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`); + return keks; +}; +const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); +const keksScout = await anmelden("scout", "CODE-SAM-00001"); + +const ruf = async (weg, keks, koerper) => { + const a = await fetch(BASIS + weg, { method: "POST", + headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify(koerper) }); + let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ } + return { status: a.status, daten }; +}; + +/* EINE TABELLE, WIE SIE AUS DEM BROWSER KOMMT: mit TABULATOREN. + Genau das ist der Fall, den der alte Zerleger nicht kannte -- er + suchte nur Komma und Semikolon. */ +const TABELLE = [ + "Creator\tHandle\tDiamanten\tLIVE-Dauer (Minuten)\tØ Zuschauer\tPeak Zuschauer\tNeue Follower", + `Nova\t@Nova_Live\t12.480\t184\t42\t310\t23`, + `Kiro\t\t3.900\t95\t18\t74\t6`, + `Lumi\t@lumistreamt\t22.010\t240\t88\t512\t41`, + `Fremde Person\t@niemand_hier\t500\t30\t5\t12\t1`, +].join("\n"); + +/* ======================================================================= + 1. Die Vorschau ordnet zu -- und raet nicht + ======================================================================= */ +console.log("=== Die Vorschau ==="); +{ + const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: gestern }); + ok(status === 200, `die Tabelle wird gelesen (HTTP ${status})`); + /* DER TABULATOR IST DER PUNKT: Ohne ihn stuende alles in Spalte 1 + und es waere nichts erkannt worden. */ + ok(daten?.zugeordnet === 3, `drei von vier Zeilen zugeordnet (${daten?.zugeordnet})`); + ok(daten?.unbekannt?.length === 1 && /niemand_hier/.test(daten.unbekannt[0]), + `die vierte wird beim Namen genannt (${(daten?.unbekannt || []).join(", ")})`); + ok(daten?.neu === 3 && daten?.ersetzt === 0, `drei neu, keine ersetzt (${daten?.neu}/${daten?.ersetzt})`); + const sp = Object.keys(daten?.spalten || {}); + ok(sp.includes("diamanten") && sp.includes("dauer_min") && sp.includes("zuschauer_avg") + && sp.includes("zuschauer_max") && sp.includes("follower_neu"), + `fuenf Zahlenspalten erkannt (${sp.join(", ")})`); + /* "12.480" ist deutsch geschrieben und muss 12480 sein, nicht 12,48. */ + const nova = (daten?.beispiele || []).find((b) => b.name === "Nova"); + ok(nova?.diamanten === 12480, `"12.480" wurde zu 12480 (${nova?.diamanten})`); +} + +/* ======================================================================= + 2. Ohne Datum in der Tabelle UND ohne Vorgabe: eine Frage, kein Unsinn + ======================================================================= */ +console.log("\n=== Wenn der Tag fehlt ==="); +{ + const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE }); + ok(status === 400 && daten?.braucht_tag === true, + `es wird nach dem Tag gefragt statt geraten (HTTP ${status})`); +} + +/* ======================================================================= + 3. Uebernehmen -- und was dabei NICHT passiert + ======================================================================= */ +console.log("\n=== Übernehmen ==="); +{ + const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import", + keksDogi, { text: TABELLE, tag: gestern }); + ok(status === 200, `die Übernahme läuft (HTTP ${status})`); + ok(daten?.geschrieben === 3, `drei Zeilen geschrieben (${daten?.geschrieben})`); + + const d = new DatabaseSync(DB); + const z = d.prepare("SELECT creator_id, diamanten, dauer_min, gueltiger_tag, quelle FROM leistung WHERE tag = ? ORDER BY creator_id").all(gestern); + ok(z.length === 3, `drei Zeilen stehen in der Datenbank (${z.length})`); + ok(z.every((x) => x.quelle === "import"), + "alle mit Quelle 'import' -- von Hand getippte sind unterscheidbar"); + ok(z.every((x) => x.gueltiger_tag === 1), + "alle als gültiger Tag gezählt, weil LIVE-Dauer vorhanden ist"); + const lumi = z.find((x) => x.creator_id === idLumi); + ok(lumi?.diamanten === 22010, + `Lumi wurde über "@lumistreamt" gegen "LumiStreamt" gefunden (${lumi?.diamanten})`); + const kiro = z.find((x) => x.creator_id === idKiro); + ok(kiro?.diamanten === 3900, `Kiro ohne Handle über den Namen (${kiro?.diamanten})`); + /* DIE FREMDE PERSON DARF NICHT ENTSTANDEN SEIN. Ein Import, der + nebenbei Leute anlegt, waere die schlimmste Variante von + "hilfreich". */ + const wieViele = d.prepare("SELECT COUNT(*) AS n FROM personen WHERE rolle = 'creator'").get().n; + ok(wieViele === 3, `es wurde niemand neu angelegt (${wieViele} Creator)`); + d.close(); +} + +/* ======================================================================= + 4. Noch einmal dieselbe Tabelle: ersetzen statt verdoppeln + ======================================================================= */ +console.log("\n=== Dieselbe Tabelle ein zweites Mal ==="); +{ + const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: gestern }); + ok(daten?.ersetzt === 3 && daten?.neu === 0, + `jetzt drei ersetzt, keine neu (${daten?.neu}/${daten?.ersetzt})`); + const { status } = await ruf("/workspace/api/leistung/netzwerk-import", + keksDogi, { text: TABELLE, tag: gestern }); + ok(status === 200, "und sie laeuft anstandslos durch"); + const d = new DatabaseSync(DB); + const n = d.prepare("SELECT COUNT(*) AS n FROM leistung WHERE tag = ?").get(gestern).n; + ok(n === 3, `es sind immer noch drei Zeilen, keine sechs (${n})`); + d.close(); +} + +/* ======================================================================= + 5. Eine von Hand geschriebene Notiz ueberlebt + ======================================================================= */ +console.log("\n=== Was von Hand kam, bleibt ==="); +{ + const d = new DatabaseSync(DB); + d.prepare("UPDATE leistung SET notiz = ? WHERE creator_id = ? AND tag = ?") + .run("Technikprobleme ab 22 Uhr", idNova, gestern); + d.close(); + await ruf("/workspace/api/leistung/netzwerk-import", keksDogi, { text: TABELLE, tag: gestern }); + const d2 = new DatabaseSync(DB); + const n = d2.prepare("SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?") + .get(idNova, gestern); + ok(n?.notiz === "Technikprobleme ab 22 Uhr", + `die Notiz steht nach dem Import noch da ("${n?.notiz}")`); + d2.close(); +} + +/* ======================================================================= + 6. Gegenproben -- was abgelehnt wird + ======================================================================= */ +console.log("\n=== Gegenproben ==="); +{ + const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: morgen }); + /* Ein Tag in der Zukunft laesst die Zeilen durchfallen -- die + Vorschau antwortet zwar 200, ordnet aber nichts zu. */ + const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: morgen }); + ok(status === 200 && daten?.zugeordnet === 0 && daten?.probleme_gesamt >= 3, + `ein Datum in der Zukunft wird abgelehnt (${daten?.zugeordnet} zugeordnet, ` + + `${daten?.probleme_gesamt} Probleme)`); +} +{ + const ohneZahlen = ["Creator\tHandle\tDiamanten", "Nova\t@Nova_Live\t"].join("\n"); + const { daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: ohneZahlen, tag: gestern }); + ok(daten?.zugeordnet === 0, + "eine Zeile ganz ohne Zahl wird uebersprungen statt als Null geschrieben"); +} +{ + const ohneNamen = ["Datum\tDiamanten", `${gestern}\t100`].join("\n"); + const { status, daten } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: ohneNamen }); + ok(status === 400 && /Name oder Handle/.test(daten?.fehler || ""), + `ohne Personenspalte wird es abgelehnt ("${daten?.fehler}")`); +} +{ + const { status } = await ruf("/workspace/api/leistung/netzwerk-import", + keksScout, { text: TABELLE, tag: gestern }); + ok(status === 403, `ein Scout kommt an diesen Weg nicht (HTTP ${status})`); +} +{ + /* GEGENPROBE ZUR GEGENPROBE: Das Richtige geht weiterhin durch. + Ohne sie hiesse "alles wird abgelehnt" auch "alles wird + abgelehnt". */ + const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: heute }); + ok(status === 200, `heute als Tag geht dagegen durch (HTTP ${status})`); +} + +/* ======================================================================= + 7. Und die Seite selbst + ======================================================================= */ +console.log("\n=== Die Seite ==="); +{ + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } }); + const seite = await ctx.newPage(); + const skriptfehler = []; + seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(BASIS + "/workspace/leistung.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1800); + + ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`); + const sichtbar = await seite.$eval("#netz-oeffnen", (e) => !e.hidden).catch(() => false); + ok(sichtbar === true, "DogFather sieht den Knopf „Backstage-Tabelle einfügen\""); + + /* Einfuegen, Vorschau abwarten, Meldung lesen -- der ganze Weg. */ + await seite.click("#netz-oeffnen"); + await seite.waitForTimeout(400); + await seite.fill("#netz-tag", gestern); + await seite.fill("#netz-text", TABELLE); + await seite.dispatchEvent("#netz-text", "change"); + await seite.waitForTimeout(1200); + const vorschau = await seite.$eval("#netz-vorschau", (e) => e.textContent).catch(() => ""); + ok(/3 von 4 Zeilen zugeordnet/.test(vorschau), + `die Vorschau nennt die Zahl (${vorschau.slice(0, 60)}…)`); + ok(/niemand_hier/.test(vorschau), "und den Namen, der nicht zugeordnet wurde"); + const losFrei = await seite.$eval("#netz-los", (e) => !e.disabled); + ok(losFrei === true, "der Übernehmen-Knopf ist danach frei"); + await browser.close(); +} + +kind.kill(); +await new Promise((r) => setTimeout(r, 400)); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } + +console.log(`\n${gemacht} 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 240b42d8..7fe9e6c4 100644 --- a/server/workspace-leistung.js +++ b/server/workspace-leistung.js @@ -45,7 +45,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, istSpicy, - sichtbareCreatorIds, ROLLEN_SORTIERUNG, + sichtbareCreatorIds, ROLLEN_SORTIERUNG, heuteLokal, } from "./workspace.js"; export const leistungRouter = express.Router(); @@ -473,9 +473,26 @@ export function csvZerlegen(text) { /* Trennzeichen erkennen: Deutsche Exporte nutzen oft Semikolon, englische Komma. Geraten wird an der ERSTEN Zeile -- dort, wo die - Überschriften stehen. */ + Überschriften stehen. + + TABULATOR SEIT DEM 11.09.2026, und er ist hier der wichtigste Fall. + Wer eine Tabelle im Browser markiert und kopiert -- genau das ist + der Weg aus TikTok LIVE Backstage, das keinen Export hat --, hat + TAB-getrennte Spalten in der Zwischenablage, nicht Komma oder + Semikolon. Ohne diese Zeile landete die ganze Zeile in der ersten + Spalte, und die Vorschau haette gemeldet "keine Datumsspalte" -- + eine richtige Meldung auf eine falsche Fährte. + + Gewählt wird das HÄUFIGSTE der drei, nicht das erste gefundene: + In "Nickname,ID\tDiamanten" kommen zwei davon vor, und nur die + Anzahl sagt, welches die Spalten trennt. */ const kopf = roh.split(/\r?\n/)[0] || ""; - const trenner = (kopf.match(/;/g) || []).length > (kopf.match(/,/g) || []).length ? ";" : ","; + const zaehl = (z) => (kopf.split(z).length - 1); + const trenner = [["\t", zaehl("\t")], [";", zaehl(";")], [",", zaehl(",")]] + .sort((a, b) => b[1] - a[1])[0][1] > 0 + ? [["\t", zaehl("\t")], [";", zaehl(";")], [",", zaehl(",")]] + .sort((a, b) => b[1] - a[1])[0][0] + : ","; for (let i = 0; i < roh.length; i++) { const z = roh[i]; @@ -589,6 +606,267 @@ export function spaltenRaten(kopf) { return zu; } +/* ===================================================================== + DER NETZWERK-IMPORT — eine Tabelle, alle Creator auf einmal + (11.09.2026) + + Filipe: "wie sehr wir diese seite hier mit dem backstage verbinden + könnten von tiktok. ohne probleme und ganz schnell." + + Die Recherche vom 10.09. hat ergeben: TikTok LIVE Backstage hat + KEINE Schnittstelle und KEINEN Export -- weder die offizielle Seite + noch ein Agenturleitfaden nennen einen, und in den offiziellen + TikTok-Scopes gibt es nichts zu LIVE, Diamanten oder Netzwerkdaten. + Was es gibt, ist eine Tabelle im Browser, die man markieren kann. + + ALSO IST DER SCHNELLSTE EHRLICHE WEG: markieren, kopieren, einfügen. + Kein Zugangsdatum, kein Token, keine Erweiterung, die Backstages + interne Abfragen mitliest -- letzteres wäre ein Verstoß gegen die + Nutzungsbedingungen und riskiert ausgerechnet das Netzwerkkonto, an + dem die Einnahmen hängen. + + WAS ES VOM VORHANDENEN IMPORT UNTERSCHEIDET: Der bestehende Weg + (/leistung/:creatorId/import) nimmt eine Datei FÜR EINEN Creator. + Backstage zeigt aber ALLE auf einmal. Der Unterschied ist genau eine + Frage: Zu wem gehört diese Zeile? + + DIE ANTWORT KOMMT AUS `personen.tiktok` -- dem öffentlichen Namen + ohne @, den es im Steckbrief längst gibt. Gesucht wird ohne @, ohne + Groß-/Kleinschreibung und ohne Leerzeichen; fällt das aus, wird der + ANGEZEIGTE Name verglichen. Beides ergebnislos -> die Zeile wird + gemeldet, nicht geraten. Eine falsch zugeordnete Zahl ist schlimmer + als eine fehlende: Die fehlende sieht man. + ===================================================================== */ + +/** Wer darf eine GANZE Tabelle einspielen? + * + * Nur die Leitung -- enger als `darfEintragen`, das auch Scouts + * einschliesst. Ein Scout traegt Zahlen fuer die Creator ein, die ihm + * zugeteilt sind; dieser Weg schreibt dagegen fuer ALLE auf einmal, + * und das ist eine Handlung der Leitung. + * + * GEMESSEN, NICHT AUSGEDACHT: Der erste Entwurf nahm `darfEintragen`, + * und die Pruefung meldete fuer einen Scout HTTP 400 statt 403 -- er + * kam durch die Rechtepruefung und scheiterte erst daran, dass er + * keinen der Creator sehen darf. Das ist die richtige Antwort aus dem + * falschen Grund, und der Knopf auf der Seite war ohnehin schon der + * Leitung vorbehalten. Jetzt sagen beide dasselbe. */ +function netzDarf(person) { + return istLeitung(person); +} + +/** Aus "@Nova_Live " wird "nova_live" -- damit sich zwei Schreibweisen + * derselben Person auch dann treffen, wenn eine davon kopiert wurde. */ +function handleSchluessel(roh) { + return String(roh ?? "").trim().toLowerCase() + .replace(/^@+/, "") + /* Unsichtbare Zeichen aus der Zwischenablage: geschütztes + Leerzeichen und die Richtungsmarken, die TikTok um Namen setzt. + Sie sind nicht zu sehen und verhindern jeden Treffer. */ + .replace(/[- ]/g, "") + .replace(/\s+/g, ""); +} + +/** Welche Spalten nennen die Person -- Handle UND Name. + * + * BEIDE, nicht eine (11.09.2026). Der erste Entwurf nahm die erste + * passende Spalte: das Handle, wenn es eines gab. Eine Backstage- + * Tabelle hat aber beides nebeneinander, und nicht jeder Creator hat + * sein Handle im Steckbrief stehen. Ergebnis in der Messung: Die Zeile + * mit leerer Handle-Zelle fiel als "keine Person in der Zeile" durch, + * obwohl der Name danebenstand. + * + * Jetzt werden beide gemerkt und je Zeile nacheinander versucht: + * erst das Handle (eindeutig), dann der Name (mehrdeutig, aber + * besser als nichts). */ +function personSpaltenRaten(kopf) { + const handle = kopf.findIndex((n) => /handle|username|user.?name|tiktok.?id|@/i.test(String(n))); + const name = kopf.findIndex((n, i) => i !== handle + && /nick|anzeige|display|name|creator/i.test(String(n))); + return { handle, name }; +} + +/** Die Creator, auf die diese Person Zahlen schreiben darf -- + * als zwei Nachschlagewerke: nach Handle und nach Name. */ +function creatorNachschlagen(person) { + const ids = sichtbareCreatorIds(person); + const wo = ids === null ? "" : ` AND p.id IN (${ids.map(() => "?").join(",")})`; + if (ids !== null && !ids.length) return { handle: new Map(), name: new Map(), anzahl: 0 }; + const zeilen = db().prepare( + `SELECT p.id, p.name, p.tiktok FROM personen p + WHERE p.rolle = 'creator' AND p.aktiv = 1${wo}`).all(...(ids || [])); + const handle = new Map(), name = new Map(); + for (const z of zeilen) { + if (z.tiktok) handle.set(handleSchluessel(z.tiktok), z); + name.set(handleSchluessel(z.name), z); + } + return { handle, name, anzahl: zeilen.length }; +} + +/** Die gemeinsame Auswertung -- von Vorschau UND Übernahme benutzt. + * + * EINE Funktion für beide, weil sonst die Vorschau etwas anderes + * berechnen könnte als die Übernahme danach schreibt. Genau das ist + * die Sorte Unterschied, die niemand bemerkt, bis die Zahlen falsch + * sind. */ +function netzwerkLesen(text, person, tagVorgabe) { + const zeilen = csvZerlegen(String(text || "")); + if (zeilen.length < 2) return { fehler: "Die Tabelle hat keine Datenzeilen." }; + + const kopf = zeilen[0]; + const zu = spaltenRaten(kopf); + const wer = personSpaltenRaten(kopf); + if (wer.handle < 0 && wer.name < 0) { + return { fehler: "Keine Spalte mit Name oder Handle gefunden. " + + "Die erste Zeile muss die Überschriften enthalten." }; + } + /* Ohne Datum in der Tabelle muss eines mitgegeben werden -- sonst + wüsste niemand, auf welchen Tag die Zahlen gehören. Raten wäre + hier besonders teuer: Ein falscher Tag überschreibt einen echten. */ + const ausTabelle = zu.tag !== undefined; + if (!ausTabelle && !tagVorgabe) { + return { fehler: "Die Tabelle hat keine Datumsspalte. Bitte den Tag angeben, " + + "für den diese Zahlen gelten.", braucht_tag: true }; + } + const messwerte = Object.keys(zu).filter((f) => f !== "tag"); + if (!messwerte.length) { + return { fehler: "Keine Zahlenspalte erkannt (Diamanten, Dauer, Zuschauer …)." }; + } + + const nach = creatorNachschlagen(person); + const treffer = []; + const unbekannt = []; + const schlecht = []; + + for (let i = 1; i < zeilen.length; i++) { + const z = zeilen[i]; + /* Erst das Handle, dann der Name -- je Zeile, nicht je Tabelle. + Eine leere Handle-Zelle darf die Zeile nicht kosten, solange der + Name danebensteht. */ + const rohHandle = wer.handle >= 0 ? z[wer.handle] : ""; + const rohName = wer.name >= 0 ? z[wer.name] : ""; + 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; + 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; } + + /* Vier Versuche, in dieser Reihenfolge: Handle gegen Handle, Handle + gegen Name, Name gegen Handle, Name gegen Name. Das Eindeutige + zuerst. */ + const sHandle = handleSchluessel(rohHandle); + const sName = handleSchluessel(rohName); + const p = (sHandle && nach.handle.get(sHandle)) + || (sHandle && nach.name.get(sHandle)) + || (sName && nach.handle.get(sName)) + || (sName && nach.name.get(sName)); + if (!p) { unbekannt.push(rohWer.slice(0, 60)); continue; } + + const werte = { creator_id: p.id, name: p.name, tag }; + let hatZahl = false; + for (const feld of messwerte) { + const w = zahlLesen(z[zu[feld]]); + werte[feld] = w; + if (w !== null && w !== undefined) hatZahl = true; + } + /* Eine Zeile ohne eine einzige Zahl ist keine Messung, sondern eine + Leerzeile mit Namen. Sie zu schreiben würde einen vorhandenen Tag + mit Nullen überschreiben. */ + if (!hatZahl) { schlecht.push({ zeile: i + 1, grund: `${p.name}: keine einzige Zahl in der Zeile` }); continue; } + treffer.push(werte); + } + return { + kopf, spalten: zu, person_spalten: wer, aus_tabelle: ausTabelle, + treffer, unbekannt: [...new Set(unbekannt)], schlecht, + creator_gesamt: nach.anzahl, + }; +} + +/** Vorschau des Netzwerk-Imports. Schreibt nichts. */ +leistungRouter.post("/workspace/api/leistung/netzwerk-import/vorschau", gleicheHerkunft, + express.json({ limit: "4mb" }), (req, res) => { + try { + if (!netzDarf(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + const tag = req.body?.tag ? datumLesen(req.body.tag) : null; + const e = netzwerkLesen(req.body?.text, req.person, tag); + if (e.fehler) return res.status(400).json(e); + + /* Was ist neu, was wird ersetzt? Wer eine Tabelle zweimal + einfügt, soll sehen, dass es dieselbe ist -- und nicht + hinterher raten, ob er etwas überschrieben hat. */ + const vorhanden = db().prepare( + "SELECT 1 FROM leistung WHERE creator_id = ? AND tag = ?"); + let neu = 0, ersetzt = 0; + for (const t of e.treffer) (vorhanden.get(t.creator_id, t.tag) ? ersetzt++ : neu++); + + res.json({ + spalten: e.spalten, person_spalten: e.person_spalten, aus_tabelle: e.aus_tabelle, + kopf: e.kopf, + zugeordnet: e.treffer.length, neu, ersetzt, + unbekannt: e.unbekannt, probleme: e.schlecht.slice(0, 12), + probleme_gesamt: e.schlecht.length, + creator_gesamt: e.creator_gesamt, + beispiele: e.treffer.slice(0, 6), + }); + } catch (fehler) { + console.error("[leistung] Netzwerk-Vorschau:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Übernehmen. Erst jetzt wird geschrieben. */ +leistungRouter.post("/workspace/api/leistung/netzwerk-import", gleicheHerkunft, + express.json({ limit: "4mb" }), (req, res) => { + try { + if (!netzDarf(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + const tag = req.body?.tag ? datumLesen(req.body.tag) : null; + const e = netzwerkLesen(req.body?.text, req.person, tag); + if (e.fehler) return res.status(400).json(e); + if (!e.treffer.length) return res.status(400).json({ fehler: "keine_zuordnung" }); + + const d = db(); + const schreiben = d.prepare(` + INSERT OR REPLACE INTO leistung + (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_avg, + zuschauer_max, verweildauer_s, schenker, follower_neu, notiz, + erfasst, erfasst_von, quelle) + VALUES (?,?,?,?,?,?,?,?,?,?, + /* Eine von Hand geschriebene Notiz überlebt den Import -- + sie steht in keiner Backstage-Tabelle. */ + (SELECT notiz FROM leistung WHERE creator_id = ? AND tag = ?), + ?,?, 'import')`); + + let geschrieben = 0; + d.exec("BEGIN"); + try { + for (const t of e.treffer) { + schreiben.run(t.creator_id, t.tag, + t.diamanten ?? null, t.dauer_min ?? null, + /* Ein gültiger Tag im Sinne von TikTok ist einer mit + LIVE-Zeit. Steht keine Dauer in der Tabelle, bleibt es + bei 0 -- geraten wird nichts. */ + (t.dauer_min ?? 0) > 0 ? 1 : 0, + t.zuschauer_avg ?? null, t.zuschauer_max ?? null, + t.verweildauer_s ?? null, t.schenker ?? null, t.follower_neu ?? null, + t.creator_id, t.tag, + new Date().toISOString(), req.person.id); + geschrieben++; + } + d.exec("COMMIT"); + } catch (f) { d.exec("ROLLBACK"); throw f; } + + protokolliere("leistung_netzwerk_import", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${geschrieben} Zeilen, ${e.unbekannt.length} unbekannt`.slice(0, 120), + }); + res.json({ geschrieben, unbekannt: e.unbekannt, probleme: e.schlecht.length }); + } catch (fehler) { + console.error("[leistung] Netzwerk-Import:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /** Vorschau: Was WÜRDE passieren? Schreibt nichts. */ leistungRouter.post("/workspace/api/leistung/:creatorId/import/vorschau", gleicheHerkunft, express.json({ limit: "2mb" }), (req, res) => { diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 87b8eb15..75b7980c 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=202609111117", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609111126", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111117", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609111126", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111117", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111126", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111117", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111126", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/leistung.css b/workspace/assets/css/leistung.css index 767696f7..deea4d8b 100644 --- a/workspace/assets/css/leistung.css +++ b/workspace/assets/css/leistung.css @@ -282,3 +282,37 @@ .import-zahl__wert { font-size: 23px; } } + +/* ===================================================================== + DIE BACKSTAGE-TABELLE (11.09.2026) + + Ein Textfeld, in das eine ganze Tabelle passt -- und eine Vorschau, + die nicht nur zählt, sondern NAMEN nennt. Wer eine Zeile übersieht, + hat stillschweigend jemanden ausgelassen; das ist die Sorte Fehler, + die erst Wochen später auffällt. + ===================================================================== */ +.netz__feld { + width: 100%; + min-height: 120px; + font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; + font-size: .82rem; line-height: 1.5; + /* Umbrechen AUS: Eine Tabellenzeile soll eine Zeile bleiben, sonst + sieht man nicht, ob die Spalten sauber getrennt wurden. */ + white-space: pre; overflow-x: auto; +} +.import-vorschau__zeile { + margin: 0 0 4px; + font-size: .84rem; line-height: 1.5; + color: var(--text-leise); +} +.import-vorschau__zeile[data-art="gut"] { + color: color-mix(in srgb, var(--k-gut, #5fc99a) 45%, #ffffff); + font-weight: 600; +} +.import-vorschau__zeile[data-art="warn"] { + color: color-mix(in srgb, var(--k-warn, #e0736b) 42%, #ffffff); + font-weight: 600; +} +.import-vorschau__zeile[data-art="still"] { + color: var(--text-still); font-size: .8rem; +} diff --git a/workspace/assets/js/leistung.js b/workspace/assets/js/leistung.js index 7138239b..d5ee30ee 100644 --- a/workspace/assets/js/leistung.js +++ b/workspace/assets/js/leistung.js @@ -175,6 +175,12 @@ ziel.textContent = ''; const z = stand?.ziele; $('ziele-aendern').hidden = !darfEintragen(); + /* Der Backstage-Weg gilt fuer ALLE Creator auf einmal -- deshalb + nur fuer die Leitung, nicht fuer einen Scout, der ohnehin nur + seine eigenen sieht. Der Server prueft dasselbe; hier geht es + darum, niemandem einen Knopf hinzustellen, der eine Absage + bringt. */ + $('netz-oeffnen').hidden = !LEITUNG.has(ich?.rolle); if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) { ziel.append(el('p', 'leistung__leer', @@ -542,6 +548,95 @@ $('tag-form').addEventListener('submit', tagSpeichern); $('tag-zu').addEventListener('click', () => $('tag-dialog').close()); $('tag-loeschen').addEventListener('click', tagLoeschen); + /* ===================================================================== + DIE BACKSTAGE-TABELLE — Vorschau und Übernahme + ===================================================================== */ + async function netzVorschau() { + const text = $('netz-text').value.trim(); + $('netz-fehler').textContent = ''; + if (!text) { $('netz-vorschau').hidden = true; $('netz-los').disabled = true; return; } + + const a = await hole('/workspace/api/leistung/netzwerk-import/vorschau', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, tag: $('netz-tag').value || null }), + }).catch(() => null); + if (!a) return; + const d = await a.json().catch(() => ({})); + + if (!a.ok) { + $('netz-vorschau').hidden = true; + $('netz-los').disabled = true; + $('netz-fehler').textContent = d.fehler || 'Die Tabelle konnte nicht gelesen werden.'; + /* Fehlt nur der Tag, ist das kein Fehler, sondern eine Frage -- + also wird das Feld hervorgehoben statt nur gemeckert. */ + if (d.braucht_tag) $('netz-tag').focus(); + return; + } + + const v = $('netz-vorschau'); + v.textContent = ''; + v.hidden = false; + + const zeile = (text2, art) => { + const p = document.createElement('p'); + p.className = 'import-vorschau__zeile'; + if (art) p.dataset.art = art; + p.textContent = text2; + v.append(p); + }; + + zeile(`${d.zugeordnet} von ${d.zugeordnet + d.unbekannt.length} Zeilen zugeordnet` + + ` · ${d.neu} neu, ${d.ersetzt} werden ersetzt`, d.zugeordnet ? 'gut' : 'warn'); + zeile(`Erkannte Spalten: ${Object.keys(d.spalten).join(', ') || 'keine'}` + + (d.aus_tabelle ? ' · Datum aus der Tabelle' : ' · Datum aus dem Feld oben')); + + /* DIE UNBEKANNTEN SIND DER WICHTIGE TEIL. Wer sie übersieht, hat + stillschweigend Leute ausgelassen -- und merkt es erst, wenn die + Zahlen nicht stimmen. Deshalb stehen sie mit Namen da und nicht + als Zahl. */ + if (d.unbekannt.length) { + zeile(`Nicht zugeordnet (${d.unbekannt.length}): ${d.unbekannt.slice(0, 8).join(', ')}` + + (d.unbekannt.length > 8 ? ' …' : ''), 'warn'); + zeile('Diese Namen stehen bei keinem Creator im Feld „TikTok" im Steckbrief. ' + + 'Dort eintragen – danach werden sie beim nächsten Mal gefunden.', 'still'); + } + if (d.probleme_gesamt) { + zeile(`${d.probleme_gesamt} Zeile(n) übersprungen:`, 'warn'); + for (const p of d.probleme) zeile(`Zeile ${p.zeile}: ${p.grund}`, 'still'); + } + for (const b of d.beispiele) { + const zahlen = Object.entries(b) + .filter(([k]) => !['creator_id', 'name', 'tag'].includes(k)) + .filter(([, w]) => w !== null && w !== undefined) + .map(([k, w]) => `${k} ${w}`).join(' · '); + zeile(`${b.name} · ${b.tag} · ${zahlen}`, 'still'); + } + + $('netz-los').disabled = d.zugeordnet === 0; + } + + async function netzUebernehmen(ereignis) { + ereignis.preventDefault(); + $('netz-los').disabled = true; + const a = await hole('/workspace/api/leistung/netzwerk-import', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: $('netz-text').value, tag: $('netz-tag').value || null, + }), + }).catch(() => null); + if (!a) { $('netz-los').disabled = false; return; } + const d = await a.json().catch(() => ({})); + if (!a.ok) { + $('netz-fehler').textContent = d.fehler || 'Übernehmen ging nicht.'; + $('netz-los').disabled = false; + return; + } + $('netz-dialog').close(); + melde(`${d.geschrieben} Zeile(n) übernommen` + + (d.unbekannt?.length ? ` · ${d.unbekannt.length} nicht zugeordnet` : '')); + await laden(); + } + $('ziele-aendern').addEventListener('click', zieleOeffnen); $('ziele-form').addEventListener('submit', zieleSpeichern); $('ziele-zu').addEventListener('click', () => $('ziele-dialog').close()); @@ -555,6 +650,24 @@ $('datei').addEventListener('change', dateiGewaehlt); $('import-form').addEventListener('submit', importUebernehmen); + /* ---------- Die Backstage-Tabelle ------------------------------------ */ + $('netz-oeffnen').addEventListener('click', () => { + $('netz-vorschau').hidden = true; + $('netz-los').disabled = true; + $('netz-fehler').textContent = ''; + $('netz-text').value = ''; + $('netz-dialog').showModal(); + $('netz-text').focus(); + }); + $('netz-zu').addEventListener('click', () => $('netz-dialog').close()); + /* 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. */ + $('netz-text').addEventListener('paste', () => setTimeout(netzVorschau, 60)); + $('netz-text').addEventListener('change', netzVorschau); + $('netz-tag').addEventListener('change', netzVorschau); + $('netz-form').addEventListener('submit', netzUebernehmen); + (async () => { try { const a = await hole('/workspace/api/ich'); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index af70c295..4c7b1d3a 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 @@ - - - - - + + + + +