Filipe, 22.09.2026: "ich brauch auch noch eine neue kachel im bereich
community, wo wir als team ... bilder reinschicken koennen, mit text
und wo die leute sei es community oder modis sich die bilder nehmen
koennen zum posten. die community soll keine posten koennen ... sobald
ein bild oder ein video ... runtergeladen wurde, soll direkt blockiert
werden ... damit nie ein bild zwei mal gepostet wird ... und die rechte
hand und dogfather, nur die beiden sollen auch immer sehen koennen wer
das bild oder video runtergeladen hat."
DER KERN IST DIE EINMALIGKEIT, und deshalb sind Nehmen und Laden ZWEI
Schritte. "nehmen" schreibt in EINER Abfrage fest, wer es hat -- mit
`WHERE genommen_von IS NULL` in der Bedingung. Wer zu spaet kommt,
aendert null Zeilen und bekommt 409. Ein einziger Schritt ("laden und
dabei markieren") haette dieselbe Luecke wie ein Pool ohne Sperre:
zwei Anfragen, beide sehen "frei", beide laden.
UND DIE SPERRE GILT AUCH FUER DIE VORSCHAU. Waere sie offen
geblieben, waere sie der Weg, ein vergebenes Bild doch noch zu
bekommen (Rechtsklick, speichern) -- und die ganze Einmaligkeit eine
Behauptung. Ausnahme: wer es selbst genommen hat, sieht es weiter.
WER WEN SIEHT, entscheidet der Server, nicht die Seite. Fuer alle
ausser DogFather und der rechten Hand fehlt das Feld `genommen_von`
ganz -- nicht `null`: Ein Feld, das da ist und leer bleibt, laedt
dazu ein, es spaeter "zu fuellen".
pruef-material.mjs (70 Pruefungen, 0 Fehler) misst den ganzen Weg,
mit Gegenprobe zu jeder Grenze. Die Zahl der vergebenen Stuecke steht
in der BEDINGUNG -- ohne sie waere "keine Namen dabei" trivial wahr.
DREI DINGE HAT ERST DER BLICK MIT ECHTEN AUGEN GEFUNDEN
(tools/material-blick.mjs, drei Rollen, vier Bildschirmbreiten):
* "hat es genommen am 22.09.." -- eine deutsche Datumsangabe endet
selbst auf einen Punkt. Kein Pruefprogramm haette danach gefragt.
* Die Knoepfe standen auf drei verschiedenen Hoehen (1127, 1155,
1176), weil der eine Text zwei Zeilen hatte und der naechste
keine. `margin-top: auto` am Fuss statt einer geratenen
Mindesthoehe.
* "Schon benutzt" nahm 273 px Hoehe je Stueck fuer ein einziges
Zeichen -- das Bild ist dort ohnehin nicht mehr abrufbar. Jetzt
eine Zeile mit 96 px.
Am Handy (412 px) blieb es bei EINER Spalte: 3062 px Seitenhoehe fuer
fuenf Stuecke. Filipe: "es ist alles so lang gezogen, muss ewig
scrollen". Statt einer festen Umbruchschwelle -- die am 06.09. schon
zweimal teuer war -- waechst die Spaltenbreite jetzt mit:
`max(160px, 22%)`. Gemessen 360/412/768/1500 px: 2/2/3/3 Spalten,
nirgends ein Ueberlauf, 412 px jetzt 2014 statt 3062 px.
Dazu neun Saetze in meldung.js. Der wichtigste ist "schon_genommen",
und er ist bewusst kein Fehler: Wer ihn liest, hat nichts falsch
gemacht. pruef-meldungen fand dabei einen Rest aus dem Aufgaben-Block
-- `nur_leitung_legt_an` hatte keinen Satz, ein Modi mit altem Tab
haette rohen Maschinentext gelesen.
pruef-material 70/0 · pruef-meldungen 8/0 · pruef-treff 80/0
pruef-rechtetafel 19/0 · pruef-sackgassen 13/0 · pruef-css-klassen ok
pruef-zwischenspeicher 27/0
Co-Authored-By: Claude Opus 5 <[email protected]>
290 lines
14 KiB
JavaScript
290 lines
14 KiB
JavaScript
/* =====================================================================
|
||
DIE MATERIAL-SEITE MIT DREI PAAR AUGEN
|
||
|
||
Aufruf: node tools/material-blick.mjs
|
||
BREITE=412 node tools/material-blick.mjs (Handy)
|
||
|
||
WARUM DREI SICHTEN UND NICHT EINE. Auf dieser Seite sieht jede
|
||
Rolle etwas anderes, und die Unterschiede sind der ganze Sinn:
|
||
|
||
DogFather sieht den Kasten zum Bereitstellen UND die Namen
|
||
derer, die sich etwas genommen haben.
|
||
ein Modi sieht den Kasten, aber KEINE Namen.
|
||
die Community sieht weder noch -- nur das, was sie holen darf.
|
||
|
||
Wer die Seite nur als DogFather ansieht, beurteilt eine Seite, die
|
||
es fuer die Community nicht gibt. Genau dieser Fehler ist am
|
||
11.09. beim Community-Blick schon einmal passiert.
|
||
|
||
UND ES LIEGT ECHTES MATERIAL DARIN. Ein leeres Raster zu
|
||
fotografieren beantwortet nur die Frage, ob es leer ist -- nicht,
|
||
ob die Vorschau passt, ob die Karten gleich hoch sind oder ob der
|
||
Text unter einem Bild lesbar bleibt.
|
||
===================================================================== */
|
||
import { mkdtempSync, rmSync, readFileSync } 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 { request as anfrage } from "node:http";
|
||
|
||
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const HIER = join(WURZEL, "server");
|
||
const PORT = Number(process.env.PORT_BLICK || 4371);
|
||
const BASIS = `http://127.0.0.1:${PORT}`;
|
||
const CREW = "crew.dogfather-universe.com";
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-mat-blick-"));
|
||
const DB = join(ordner, "workspace.db");
|
||
let kind = null;
|
||
|
||
async function start() {
|
||
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));
|
||
}
|
||
|
||
function personAnlegen(name, rolle, code) {
|
||
const d = new DatabaseSync(DB);
|
||
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)"
|
||
+ " VALUES (?,?,?,?,?,1,?)")
|
||
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
|
||
d.close();
|
||
}
|
||
|
||
await start();
|
||
kind.kill();
|
||
await new Promise((r) => setTimeout(r, 600));
|
||
personAnlegen("Filipe", "admin", "CODE-DOGI-0001");
|
||
personAnlegen("Rieke", "hand", "CODE-HAND-0001");
|
||
personAnlegen("Frida", "modi", "CODE-MODI-0001");
|
||
personAnlegen("Mara", "gast", "CODE-GAST-0001");
|
||
personAnlegen("Jonas", "gast", "CODE-GAST-0002");
|
||
await start();
|
||
|
||
function rohAnfrage(pfad, host, koerper, kopf = {}, methode = "POST") {
|
||
return new Promise((f, s2) => {
|
||
const daten = Buffer.isBuffer(koerper) ? koerper
|
||
: (koerper == null ? null : Buffer.from(JSON.stringify(koerper)));
|
||
const a = anfrage({
|
||
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
|
||
headers: {
|
||
Host: host,
|
||
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
|
||
...kopf,
|
||
},
|
||
}, (an) => {
|
||
let t = "";
|
||
an.on("data", (c) => { t += c; });
|
||
an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
|
||
});
|
||
a.on("error", s2);
|
||
if (daten) a.write(daten);
|
||
a.end();
|
||
});
|
||
}
|
||
|
||
async function anmelden(rolle, code) {
|
||
let a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code });
|
||
if (a.code === 400 && a.text.includes("alter_offen")) {
|
||
a = await rohAnfrage("/workspace/api/anmelden", CREW, { rolle, code, alter_ok: true });
|
||
}
|
||
if (a.code !== 200) throw new Error(`Anmeldung ${rolle} -> ${a.code} ${a.text.slice(0, 160)}`);
|
||
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
|
||
}
|
||
|
||
/* ---- ECHTES MATERIAL HINEINLEGEN -----------------------------------
|
||
|
||
Die Buehnenbilder des Hauses. Sie sind echte Fotos in echter
|
||
Groesse -- ein 1x1-Pixel-Platzhalter wuerde jede Frage nach dem
|
||
Seitenverhaeltnis und der Ladezeit unbeantwortet lassen. */
|
||
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||
const kHand = await anmelden("hand", "CODE-HAND-0001");
|
||
const kModi = await anmelden("modi", "CODE-MODI-0001");
|
||
const kGast = await anmelden("gast", "CODE-GAST-0001");
|
||
const kJonas = await anmelden("gast", "CODE-GAST-0002");
|
||
|
||
const BILDER = [
|
||
["crew-gate-960.webp", "Stream-Ausschnitt gestern Abend.webp",
|
||
"Für TikTok – passt zum Clip von gestern, gern mit Musik drunter."],
|
||
["crew-gate-hoch-580.webp", "Hochformat für Insta Story.webp",
|
||
"Schon im 9:16-Format, geht direkt als Story raus."],
|
||
["gate-1280.webp", "Ankündigung Freitag.webp", ""],
|
||
["crew-gate-1280.webp", "Dogi im Studio.webp",
|
||
"Bitte nur mit dem Text „Freitag 20 Uhr“ posten."],
|
||
["gate-960.webp", "Thumbnail Variante B.webp", "Falls A nicht zieht."],
|
||
];
|
||
console.log("=== Material einstellen ===");
|
||
let nr = 0;
|
||
for (const [datei, name, text] of BILDER) {
|
||
const rohBild = readFileSync(join(WURZEL, "workspace/assets/img/buehne", datei));
|
||
const wer = nr % 2 === 0 ? kDogi : kHand;
|
||
const a = await rohAnfrage("/workspace/api/material", CREW, rohBild, {
|
||
Cookie: wer, "Content-Type": "image/webp",
|
||
"Content-Length": rohBild.length,
|
||
"X-Datei-Name": encodeURIComponent(name),
|
||
"X-Text": encodeURIComponent(text),
|
||
Origin: `https://${CREW}`,
|
||
});
|
||
console.log(` ${String(a.code).padEnd(4)} ${name} (${Math.round(rohBild.length / 1024)} KB)`);
|
||
nr++;
|
||
}
|
||
|
||
/* ZWEI STUECKE SIND SCHON WEG. Ohne sie waere „Schon benutzt" leer,
|
||
und genau dieser Block ist der, den Filipe sehen will. */
|
||
const alle = JSON.parse((await rohAnfrage("/workspace/api/material", CREW, null,
|
||
{ Cookie: kDogi }, "GET")).text).material;
|
||
for (const [i, keks] of [[3, kGast], [4, kJonas]]) {
|
||
const a = await rohAnfrage(`/workspace/api/material/${alle[i].id}/nehmen`, CREW, null,
|
||
{ Cookie: keks, Origin: `https://${CREW}` });
|
||
console.log(` genommen: ${alle[i].name} -> ${a.code}`);
|
||
}
|
||
|
||
/* ---- Und jetzt hinsehen -------------------------------------------- */
|
||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await pw.chromium.launch();
|
||
const BREITE = Number(process.env.BREITE || 1500);
|
||
const ANHANG = BREITE < 700 ? "-handy" : "";
|
||
|
||
async function hinsehen(titel, keks, marke) {
|
||
const ctx = await browser.newContext({
|
||
viewport: { width: BREITE, height: BREITE < 700 ? 915 : 1100 },
|
||
deviceScaleFactor: 1, isMobile: BREITE < 700, hasTouch: BREITE < 700 });
|
||
await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
|
||
const [name, ...rest] = z.split("=");
|
||
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
|
||
}));
|
||
const seite = await ctx.newPage();
|
||
const schlimm = [];
|
||
seite.on("console", (m) => { if (m.type() === "error") schlimm.push(m.text().slice(0, 120)); });
|
||
seite.on("requestfailed", (r) => schlimm.push(`${r.url().slice(-50)} (${r.failure()?.errorText})`));
|
||
seite.on("response", (r) => { if (r.status() >= 400) schlimm.push(`${r.url().slice(-50)} -> ${r.status()}`); });
|
||
|
||
await seite.goto(`${BASIS}/workspace/material.html`, { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(1800);
|
||
|
||
const z = await seite.evaluate(() => {
|
||
const karten = [...document.querySelectorAll(".m-karte")];
|
||
const bilder = [...document.querySelectorAll(".m-karte__bild")];
|
||
const kasten = document.getElementById("neu-block");
|
||
const wegBlock = document.getElementById("weg-block");
|
||
/* Kontrast messen, wo es zaehlt: der Text unter dem Bild. */
|
||
const probe = document.querySelector(".m-karte__text, .m-karte__name");
|
||
const farbe = probe ? getComputedStyle(probe).color : "";
|
||
let hinter = "";
|
||
for (let e = probe; e; e = e.parentElement) {
|
||
const h = getComputedStyle(e).backgroundColor;
|
||
if (h && !/rgba\(0, 0, 0, 0\)/.test(h)) { hinter = h; break; }
|
||
}
|
||
return {
|
||
titel: document.querySelector("h1")?.textContent?.trim() || "",
|
||
unterzeile: (document.querySelector(".unterzeile")?.textContent || "")
|
||
.replace(/\s+/g, " ").trim(),
|
||
karten: karten.length,
|
||
frei: document.querySelectorAll("#frei .m-karte").length,
|
||
weg: document.querySelectorAll("#weg .m-karte").length,
|
||
wegBlockDa: !!wegBlock && !wegBlock.hidden,
|
||
kastenDa: !!kasten && !kasten.hidden,
|
||
/* Wie viele Karten nennen einen NAMEN dessen, der es hat? */
|
||
mitNamen: [...document.querySelectorAll(".m-karte__stand")]
|
||
.filter((e) => /hat es genommen/.test(e.textContent)).length,
|
||
staende: [...new Set([...document.querySelectorAll(".m-karte__stand")]
|
||
.map((e) => e.textContent.replace(/\s+/g, " ").trim()))],
|
||
knoepfe: [...new Set([...document.querySelectorAll(".m-karte button")]
|
||
.map((b) => b.textContent.trim()))],
|
||
/* Sind die Karten gleich hoch? Verschiedene Hoehen in einem
|
||
Raster sehen nach Versehen aus. */
|
||
hoehen: [...new Set(karten.map((k) => Math.round(k.getBoundingClientRect().height)))],
|
||
/* UND STEHEN DIE KNOEPFE AUF EINER LINIE? Gleich hohe Karten
|
||
genuegen dafuer nicht -- bei unterschiedlich langem Text
|
||
bleibt unten unterschiedlich viel Luft, und die Knoepfe
|
||
rutschen auseinander. Genau das war am 22.09. der Fall. */
|
||
knopfLinien: (() => {
|
||
const erste = [...document.querySelectorAll("#frei .m-karte")]
|
||
.map((k) => k.getBoundingClientRect().top);
|
||
const oben = erste.length ? erste[0] : 0;
|
||
return [...new Set([...document.querySelectorAll("#frei .m-karte")]
|
||
.filter((k) => Math.abs(k.getBoundingClientRect().top - oben) < 2)
|
||
.map((k) => {
|
||
const b = k.querySelector("button, a.knopf");
|
||
return b ? Math.round(b.getBoundingClientRect().top) : null;
|
||
}).filter((v) => v !== null))];
|
||
})(),
|
||
wegHoehen: [...new Set([...document.querySelectorAll("#weg .m-karte")]
|
||
.map((k) => Math.round(k.getBoundingClientRect().height)))],
|
||
bilderGeladen: bilder.filter((b) => b.naturalWidth > 0).length,
|
||
bilderGesamt: bilder.length,
|
||
/* Laeuft etwas ueber den Rand hinaus? */
|
||
zuBreit: karten.filter((k) => k.getBoundingClientRect().right
|
||
> document.documentElement.clientWidth + 1).length,
|
||
spalten: (() => {
|
||
const oben = karten.map((k) => Math.round(k.getBoundingClientRect().top));
|
||
return oben.length ? oben.filter((t) => t === oben[0]).length : 0;
|
||
})(),
|
||
seitenhoehe: document.body.scrollHeight,
|
||
farbe, hinter,
|
||
kopfSatz: (document.getElementById("frei-satz")?.textContent || "").trim(),
|
||
wegSatz: (document.getElementById("weg-satz")?.textContent || "").trim(),
|
||
};
|
||
});
|
||
|
||
console.log("");
|
||
console.log(`=== ${titel} ===`);
|
||
console.log(` „${z.titel}" · ${z.unterzeile.slice(0, 90)}`);
|
||
console.log(` Kasten zum Bereitstellen: ${z.kastenDa ? "DA" : "weg"}`);
|
||
console.log(` ${z.frei} frei, ${z.weg} vergeben (Block „Schon benutzt": ${z.wegBlockDa ? "da" : "weg"})`);
|
||
console.log(` Karten mit Namen: ${z.mitNamen} ${marke}`);
|
||
console.log(` Zustaende: ${z.staende.join(" | ") || "(keine)"}`);
|
||
console.log(` Knoepfe: ${z.knoepfe.join(" | ") || "(keine)"}`);
|
||
console.log(` Vorschau geladen: ${z.bilderGeladen}/${z.bilderGesamt}`);
|
||
console.log(` Raster: ${z.spalten} Spalten, ${z.hoehen.length} verschiedene Hoehen (${z.hoehen.join(", ")})`);
|
||
console.log(` Knoepfe der ersten Zeile auf ${z.knopfLinien.length} Hoehe(n): ${z.knopfLinien.join(", ")}`
|
||
+ (z.knopfLinien.length > 1 ? " << stehen nicht auf einer Linie" : ""));
|
||
console.log(` „Schon benutzt": Kartenhoehe ${z.wegHoehen.join(", ") || "(keine)"} px`);
|
||
console.log(` ueber den Rand: ${z.zuBreit} · Seitenhoehe ${z.seitenhoehe} px`);
|
||
console.log(` Text ${z.farbe} auf ${z.hinter}`);
|
||
console.log(` oben: „${z.kopfSatz}"`);
|
||
console.log(` unten: „${z.wegSatz}"`);
|
||
if (schlimm.length) console.log(` !! ${[...new Set(schlimm)].join(" · ")}`);
|
||
|
||
const pfad = `C:/Users/qciga/AppData/Local/Temp/material-${titel.toLowerCase()
|
||
.replace(/[^a-z]+/g, "-").replace(/^-|-$/g, "")}${ANHANG}.png`;
|
||
await seite.screenshot({ path: pfad, fullPage: true });
|
||
console.log(` -> ${pfad}`);
|
||
await ctx.close();
|
||
return z;
|
||
}
|
||
|
||
const a1 = await hinsehen("DogFather", kDogi, "(erwartet: 2)");
|
||
const a2 = await hinsehen("Modi", kModi, "(erwartet: 0)");
|
||
const a3 = await hinsehen("Community", kGast, "(erwartet: 0)");
|
||
|
||
console.log("");
|
||
console.log("=== Was sich unterscheidet ===");
|
||
console.log(` Kasten: DogFather ${a1.kastenDa} · Modi ${a2.kastenDa} · Community ${a3.kastenDa}`);
|
||
console.log(` Namen: DogFather ${a1.mitNamen} · Modi ${a2.mitNamen} · Community ${a3.mitNamen}`);
|
||
console.log(` frei: ${a1.frei} / ${a2.frei} / ${a3.frei}`);
|
||
|
||
await browser.close();
|
||
kind.kill();
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|