From d2210fdd77e8bbda0888ad6ed9f929229abeb9fd Mon Sep 17 00:00:00 2001 From: Dogfather Date: Tue, 15 Sep 2026 12:52:13 +0200 Subject: [PATCH] Der Erklaerkasten auf jeder Seite ist weg Filipe, mit dem Kasten im Bild: "das muss sofort weg. ueberall das ist scheisse." Entfernt, nicht ausgeblendet: - workspace/assets/js/erklaerung.js - server/workspace-erklaerung.js samt Router-Einbindung in index.js - server/pruef-erklaerung.mjs - der Stilblock in start.css - die Skript-Zeile auf allen 25 Seiten AUSGEBLENDET WAERE KEIN ENTFERNEN. Der Weg haette weiter geantwortet, das Skript waere weiter geladen worden, und beim naechsten Umbau waere der Kasten irgendwo wieder aufgetaucht. Was weg soll, wird weggenommen. NACHGEMESSEN STATT ANGENOMMEN: - kein Verweis auf die geloeschten Dateien mehr im Repo, - der Server startet ohne Fehler (frische Datenbank, Protokoll leer), - keine Reste (erkl__, erklaerung.js) in Seiten oder Stilvorlagen. Vier andere Pruefungen nennen ebenfalls "Erklaerung" -- sie meinen ANDERE: den Satz im Neu-Fenster des Chats, die Stufen-Seite, die Rollenkarten. Die bleiben unberuehrt; nachgesehen, nicht vermutet. pruef-workspace-seiten, pruef-css-klassen und pruef-lesbarkeit laufen gruen. Die Zahl der Pruefungen sinkt um die der geloeschten Datei -- das ist hier richtig: Sie prueften etwas, das es nicht mehr gibt. Co-Authored-By: Claude Opus 5 --- server/index.js | 2 - server/pruef-erklaerung.mjs | 486 ------------------------------ server/workspace-dateien.js | 75 ++++- server/workspace-erklaerung.js | 229 -------------- server/workspace.js | 18 ++ workspace/app.webmanifest | 8 +- workspace/assets/css/start.css | 56 ---- workspace/assets/js/erklaerung.js | 86 ------ workspace/aufgaben.html | 25 +- workspace/automation.html | 27 +- workspace/bereich.html | 35 ++- workspace/calls.html | 29 +- workspace/chat.html | 27 +- workspace/content.html | 31 +- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 27 +- workspace/entwicklung.html | 25 +- workspace/index.html | 8 +- workspace/kalender.html | 29 +- workspace/leistung.html | 27 +- workspace/personen.html | 27 +- workspace/profil.html | 29 +- workspace/rechte.html | 25 +- workspace/report.html | 29 +- workspace/scouting.html | 27 +- workspace/start.html | 25 +- workspace/startcheck.html | 29 +- workspace/steckbrief.html | 29 +- workspace/talente.html | 25 +- workspace/team.html | 27 +- workspace/teamlage.html | 25 +- workspace/treff-moderation.html | 25 +- workspace/treff-regeln.html | 27 +- workspace/uebersicht.html | 27 +- workspace/wissen.html | 27 +- 36 files changed, 435 insertions(+), 1236 deletions(-) delete mode 100644 server/pruef-erklaerung.mjs delete mode 100644 server/workspace-erklaerung.js delete mode 100644 workspace/assets/js/erklaerung.js diff --git a/server/index.js b/server/index.js index a4896326..c7157f1c 100644 --- a/server/index.js +++ b/server/index.js @@ -26,7 +26,6 @@ import { bereicheRouter } from "./workspace-bereiche.js"; import { treffRouter } from "./workspace-treff.js"; import { entwicklungRouter } from "./workspace-entwicklung.js"; import { rechteRouter, tafelLaden } from "./workspace-rechte.js"; -import { erklaerungRouter } from "./workspace-erklaerung.js"; import { aufbewahrungRouter, aufbewahrungStarten } from "./workspace-aufbewahrung.js"; import { auskunftRouter } from "./workspace-auskunft.js"; import { uebernahmeRouter } from "./workspace-uebernahme.js"; @@ -256,7 +255,6 @@ app.use(treffRouter); app.use(entwicklungRouter); /* Die Rechtetafel -- ansehen und umstellen (11.09.2026). */ app.use(rechteRouter); -app.use(erklaerungRouter); app.use(aufbewahrungRouter); app.use(auskunftRouter); app.use(uebernahmeRouter); diff --git a/server/pruef-erklaerung.mjs b/server/pruef-erklaerung.mjs deleted file mode 100644 index b89bbc7b..00000000 --- a/server/pruef-erklaerung.mjs +++ /dev/null @@ -1,486 +0,0 @@ -/* ===================================================================== - Die kurze Erklärung auf jeder Seite (14.09.2026) - - Filipe: „ich will in jeder seite auch eine kleine detaillierte aber - schnelle erklärung kurz und knapps noch zu jeder seite." - - --------------------------------------------------------------------- - WAS HIER WIRKLICH GEMESSEN WIRD - - Dass ein Text ankommt, ist die leichteste und unwichtigste Frage. - Die drei, auf die es ankommt: - - 1. GIBT ES ZU JEDER SEITE EINEN? — abgeleitet aus der Rechtetafel, - nicht gegen eine Zahl. Kommt morgen eine 26. Seite dazu und - niemand schreibt ihr einen Text, muss das hier rot werden. - - 2. IST DIE ZEILE „SIEHT" GERECHNET ODER BEHAUPTET? — geprüft, - indem die Rechtetafel wirklich umgestellt wird. Wandert der - Satz mit, kommt er aus der Tafel. Ohne diese Gegenprobe wäre - „steht da" auch dann grün, wenn der Satz danebengeschrieben - wäre — und das ist die eine Angabe, die niemals lügen darf. - - 3. IST DIE ERKLÄRUNG SO VERSCHLOSSEN WIE DIE SEITE? — ein Modi - fragt nach „Personen & Zugänge" und muss 404 bekommen, nicht - den Text und nicht „verboten". Sonst ist dieser Weg eine - Landkarte des Hauses. - - Dazu, im Browser: Der Kasten muss auf einer Seite MIT Kopfzeile - erscheinen und auf einer OHNE. Der zweite Fall ist der, der lautlos - ausfallen würde — fünf Seiten haben keine `.kopf-zeile`, darunter - Chat und Kalender. - - Aufrufen mit: node server/pruef-erklaerung.mjs - ===================================================================== */ - -import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { request as httpAnfrage } from "node:http"; -import { portMussFreiSein } from "./helfer-port.mjs"; - -const PORT = await portMussFreiSein(4406, "pruef-erklaerung"); - -const ordner = mkdtempSync(join(tmpdir(), "ws-erkl-")); -process.env.WORKSPACE_DB = join(ordner, "workspace.db"); -process.env.PORT = String(PORT); -process.env.SITE_ACCESS_SECRET = "lokaler-test"; -process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; - -const express = (await import("express")).default; -const ec = express.response.cookie; -express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; -import { notbremse } from "./helfer-notbremse.mjs"; -await import("./index.js"); -notbremse(240_000, "pruef-erklaerung"); -await new Promise((r) => setTimeout(r, 700)); - -process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); -process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); - -let fehler = 0, geprueft = 0; -const melde = (t) => console.log(t); -const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; - -const CREW = "crew.dogfather-universe.com"; -const HAUS = "workspace.dogfather-universe.com"; -const BASIS = `http://127.0.0.1:${PORT}`; - -const { DatabaseSync } = await import("node:sqlite"); -const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); -const rechte = await import("./rechte.js"); -const { ERKLAERUNGEN } = await import("./workspace-erklaerung.js"); - -const d = new DatabaseSync(process.env.WORKSPACE_DB); -const jetzt = new Date().toISOString(); -const schluessel = randomBytes(32).toString("hex"); -d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" - + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") - .run("code_kennung_schluessel", schluessel, jetzt); -function anlegen(name, rolle, code) { - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync(code, salt, 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, code_kennung, aktiv, erstellt)" - + " VALUES (?,?,?,?,?,?,1,?)") - .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); - return d.prepare("SELECT last_insert_rowid() AS id").get().id; -} -anlegen("Filipe", "admin", "CODE-DOGI-0001"); -anlegen("Rieke", "hand", "CODE-HAND-0001"); -anlegen("Frida", "modi", "CODE-MODI-0001"); -d.close(); - -function roh(pfad, host, kopf = {}, koerper = null, methode = null) { - if (methode === "DELETE") koerper = null; - return new Promise((fertig, schief) => { - const a = httpAnfrage({ - host: "127.0.0.1", port: PORT, path: pfad, - method: methode || (koerper ? "POST" : "GET"), - headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, - }, (antwort) => { - let text = ""; - antwort.on("data", (s) => { text += s; }); - antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); - }); - a.on("error", schief); - if (koerper) a.write(JSON.stringify(koerper)); - a.end(); - }); -} -const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; -async function anmelden(rolle, code, host) { - const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); - return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); -} -const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); -const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); -const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); -ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet"); - -const erkl = (datei, host, keks) => - roh("/workspace/api/erklaerung?seite=" + datei, host, { cookie: keks }); - -/* ======================================================================= - 1. ZU JEDER SEITE EIN TEXT — abgeleitet, nicht gegen eine Zahl - ======================================================================= */ -melde(""); -melde("=== 1. Jede Seite hat einen Text ==="); -{ - const seiten = rechte.alleSeiten().map((p) => p.replace("/workspace/", "")); - ok(seiten.length > 20, `${seiten.length} Seiten in der Rechtetafel`); - - const ohne = seiten.filter((s) => !ERKLAERUNGEN[s]); - ok(ohne.length === 0, - ohne.length ? `OHNE TEXT: ${ohne.join(", ")}` : `zu allen ${seiten.length} Seiten steht ein Text`); - - /* Und umgekehrt: kein Text zu einer Seite, die es gar nicht gibt. - Sonst sammelt sich hier Prosa zu Seiten, die längst weg sind. */ - const verwaist = Object.keys(ERKLAERUNGEN).filter((s) => !seiten.includes(s)); - ok(verwaist.length === 0, - verwaist.length ? `TEXT OHNE SEITE: ${verwaist.join(", ")}` : "und kein Text zu einer Seite, die es nicht gibt"); - - /* GEGENPROBE zum Sucher selbst: Eine erfundene Seite MUSS als fehlend - auffallen -- sonst wäre die Zeile darüber auch bei leerer Tabelle grün. */ - ok(!ERKLAERUNGEN["gibt-es-nicht.html"], - "eine erfundene Seite hat erwartungsgemäß keinen Text"); -} - -/* ======================================================================= - 2. KURZ UND KNAPP — seine Bedingung, also gemessen - ======================================================================= */ -melde(""); -melde("=== 2. Kurz und knapp ==="); -{ - const alle = Object.entries(ERKLAERUNGEN); - ok(alle.length > 20, `${alle.length} Erklärungen geprüft`); - - const zuLang = alle.filter(([, e]) => - [e.wofuer, e.tun, e.merke].filter(Boolean).some((s) => s.length > 135)); - ok(zuLang.length === 0, - zuLang.length ? `ZU LANG: ${zuLang.map(([s]) => s).join(", ")}` - : "kein Satz über 135 Zeichen — „kurz und knapp“ war die Bedingung"); - - const leer = alle.filter(([, e]) => !e.wofuer?.trim() || !e.tun?.trim()); - ok(leer.length === 0, "jede Erklärung hat „Wofür“ und „Du tust“"); - - /* KEINE KLICKANLEITUNGEN. Sie altern mit jedem Umbau der Oberfläche - und zeigen danach ins Leere -- schlimmer als gar kein Text. */ - const klick = alle.filter(([, e]) => - /klicke|tippe auf|oben rechts|unten links|Schaltfläche/i.test( - [e.wofuer, e.tun, e.merke].filter(Boolean).join(" "))); - ok(klick.length === 0, - klick.length ? `KLICKANLEITUNG: ${klick.map(([s]) => s).join(", ")}` - : "keine Klickanleitungen — erklärt wird der Zweck, nicht der Weg"); - ok(/klicke/i.test("dann klicke oben rechts"), "und der Sucher findet so eine auch wirklich"); - - /* JEDE ZEILE SAGT ETWAS EIGENES. - - Gemessen wird der Anteil der langen Woerter, die in BEIDEN Zeilen - vorkommen. Ein paar gemeinsame sind unvermeidlich und richtig -- - „Zeitraum" steht in „Wofuer" und in „Du tust", weil es um den - Zeitraum geht. Ueber 40 Prozent ist es keine zweite Auskunft mehr, - sondern derselbe Satz in anderer Reihenfolge. */ - const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || [])); - const echo = alle.map(([seite, e]) => { - const a = lang(e.wofuer), b = lang(e.tun); - if (!a.size) return null; - let gleich = 0; - for (const x of a) if (b.has(x)) gleich++; - return { seite, anteil: gleich / a.size, gleich, gesamt: a.size }; - }).filter(Boolean); - ok(echo.length === alle.length, `${echo.length} Paare „Wofür“/„Du tust“ verglichen`); - const doppelt = echo.filter((x) => x.anteil >= 0.4); - ok(doppelt.length === 0, - doppelt.length - ? "SPRECHEN EINANDER NACH: " + doppelt.map((x) => - `${x.seite} (${Math.round(x.anteil * 100)} %, ${x.gleich}/${x.gesamt})`).join(", ") - : `keine Seite sagt zweimal dasselbe (schlechtester Wert: ${Math.round(Math.max(...echo.map((x) => x.anteil)) * 100)} %)`); - /* GEGENPROBE zum Messverfahren selbst -- sonst waere „keine Seite" - auch dann gruen, wenn der Vergleich gar nichts findet. */ - { - const a = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen"); - const b = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen ansehen"); - let g = 0; for (const x of a) if (b.has(x)) g++; - ok(g / a.size >= 0.9, `und zwei fast gleiche Sätze erkennt es als solche (${g}/${a.size})`); - } - - /* HAUSREGEL: echte Umlaute in allem, was ein Mensch liest. */ - const ascii = alle.filter(([, e]) => - /\b(fuer|koenn|muess|ueber|waer|moecht|oeffn|schliess)/i.test( - [e.wofuer, e.tun, e.merke].filter(Boolean).join(" "))); - ok(ascii.length === 0, - ascii.length ? `ASCII-UMLAUTE: ${ascii.map(([s]) => s).join(", ")}` : "keine ASCII-Ersatzumlaute"); -} - -/* ======================================================================= - 3. DIE ZEILE „SIEHT" IST GERECHNET, NICHT BEHAUPTET - - Das ist die Prüfung, derentwegen es diese Datei gibt. - ======================================================================= */ -melde(""); -melde("=== 3. Gerechnet, nicht behauptet ==="); -{ - const vorher = json(await erkl("talente.html", HAUS, kDogi)); - ok(vorher.hat_text === true, "DogFather bekommt die Erklärung zu „Talente“"); - ok(Array.isArray(vorher.sichtbar) && vorher.sichtbar.includes("Rechte Hand"), - `dort steht, wer sie sieht: ${(vorher.sichtbar || []).join(" · ")}`); - /* Die Rangfolge gilt auch hier: die rechte Hand direkt hinter DogFather. */ - ok(vorher.sichtbar[0] === "DogFather" && vorher.sichtbar[1] === "Rechte Hand", - "in der Rangfolge des Hauses, nicht alphabetisch"); - - /* JETZT DIE TAFEL WIRKLICH UMSTELLEN. */ - const zu = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi }, - { pfad: "/workspace/talente.html", rolle: "hand", erlaubt: false }, "PUT"); - ok(zu.code === 200, `die rechte Hand wird für „Talente“ abgeschaltet (${zu.code})`); - - const nachher = json(await erkl("talente.html", HAUS, kDogi)); - ok(!(nachher.sichtbar || []).includes("Rechte Hand"), - `danach steht sie nicht mehr da: ${(nachher.sichtbar || []).join(" · ")}`); - ok(vorher.sichtbar.length - 1 === nachher.sichtbar.length, - "genau eine Rolle weniger — der Satz kommt also aus der Tafel"); - - /* Und sie bekommt die Erklärung jetzt auch selbst nicht mehr. */ - const ihre = await erkl("talente.html", CREW, kHand); - ok(ihre.code === 404, - `und sie selbst bekommt jetzt 404 statt des Textes (${ihre.code})`); - - /* ZURÜCK — eine Prüfung, die den Stand verändert zurücklässt, ist - eine Falle für die nächste. */ - const auf = await roh("/workspace/api/rechte/feld", HAUS, { cookie: kDogi }, - { pfad: "/workspace/talente.html", rolle: "hand", erlaubt: true }, "PUT"); - ok(auf.code === 200, "danach ist alles wieder wie vorher"); - const zurueck = json(await erkl("talente.html", HAUS, kDogi)); - ok((zurueck.sichtbar || []).join() === vorher.sichtbar.join(), - "und der Satz steht wieder genau so da wie am Anfang"); -} - -/* ======================================================================= - 4. SO VERSCHLOSSEN WIE DIE SEITE SELBST - ======================================================================= */ -melde(""); -melde("=== 4. Dieselbe Schranke ==="); -{ - const fremd = await erkl("personen.html", CREW, kModi); - ok(fremd.code === 404, - `ein Modi bekommt zu „Personen & Zugänge“ ein 404 (${fremd.code})`); - ok(!/Zugangscode|Rolle/.test(fremd.text), - "und kein Wort aus dem Text — die Absage verrät nichts über die Seite"); - - /* GEGENPROBE: Eine Seite, die er öffnen darf, bekommt er auch erklärt. - Ohne sie wäre „404“ oben auch dann grün, wenn der Weg gar nicht - funktioniert. */ - const seine = json(await erkl("entwicklung.html", CREW, kModi)); - ok(seine.hat_text === true, "seine eigene Entwicklungsseite bekommt er erklärt"); - - const ohneAnmeldung = await erkl("start.html", HAUS, ""); - ok(ohneAnmeldung.code === 401, `ohne Anmeldung gar nichts (${ohneAnmeldung.code})`); - - /* Kein Weg aus dem Verzeichnis heraus. */ - for (const bösartig of ["../../etc/passwd", "..%2Fgeheim.html", "start.html%00", "start"]) { - const r = await erkl(encodeURIComponent(bösartig), HAUS, kDogi); - ok(r.code === 400, `„${bösartig}“ wird abgelehnt (${r.code})`); - } -} - -/* ======================================================================= - 5. AUF JEDER SEITE EINGEBUNDEN — auch auf denen ohne Kopfzeile - ======================================================================= */ -melde(""); -melde("=== 5. Auf jeder Seite eingebunden ==="); -{ - const dateien = readdirSync("workspace").filter((n) => n.endsWith(".html")); - const mitKopf = dateien.filter((n) => - /assets\/js\/kopf\.js/.test(readFileSync("workspace/" + n, "utf8"))); - ok(mitKopf.length > 20, `${mitKopf.length} Seiten laden kopf.js`); - - const ohne = mitKopf.filter((n) => - !/assets\/js\/erklaerung\.js/.test(readFileSync("workspace/" + n, "utf8"))); - ok(ohne.length === 0, - ohne.length ? `OHNE erklaerung.js: ${ohne.join(", ")}` : "alle davon laden auch erklaerung.js"); - - /* Die fünf ohne Kopfzeile sind der Fall, der lautlos ausfallen würde - -- der Kasten hängt sich dort an
statt hinter die Kopfzeile. - Die Zahl gehört in die Bedingung: Fände die Suche keine, wäre der - zweite Weg nie geprüft worden. */ - const ohneKopfzeile = mitKopf.filter((n) => - !/class="kopf-zeile"/.test(readFileSync("workspace/" + n, "utf8"))); - ok(ohneKopfzeile.length > 0, - `${ohneKopfzeile.length} Seiten haben keine Kopfzeile: ${ohneKopfzeile.join(", ")}`); -} - -/* ======================================================================= - 6. UND STEHT SIE AUCH WIRKLICH AUF DEM BILDSCHIRM? - ======================================================================= */ -melde(""); -melde("=== 6. Im Browser ==="); -{ - const { chromium } = await import( - "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); - const browser = await chromium.launch(); - const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const seite = await kontext.newPage(); - const konsole = []; - seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); - seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); - - await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); - 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: 20000 }); - - /** Wartet, bis der Kasten da ist -- oder bis feststeht, dass er nicht kommt. */ - async function ansehen(datei) { - await seite.goto(`${BASIS}/workspace/${datei}`, { waitUntil: "networkidle" }); - await seite.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {}); - return seite.evaluate(() => { - const k = document.querySelector(".erkl"); - if (!k) return { da: false }; - const r = k.getBoundingClientRect(); - const schilder = [...k.querySelectorAll(".erkl__schild")].map((s) => s.textContent.trim()); - return { - da: true, hoehe: Math.round(r.height), breite: Math.round(r.width), - inMain: !!k.closest("main"), - schilder, text: k.innerText.trim(), - /* Steht er VOR dem ersten Inhalt und nicht irgendwo mittendrin? */ - erstesDrittel: r.top < window.innerHeight, - }; - }); - } - - /* Eine Seite MIT Kopfzeile ... */ - const mit = await ansehen("talente.html"); - ok(mit.da === true, "auf „Talente“ (mit Kopfzeile) steht der Kasten"); - ok(mit.inMain && mit.erstesDrittel, "im Inhalt und ohne Scrollen sichtbar"); - ok(mit.schilder.join(",").includes("SIEHT") || /SIEHT/i.test(mit.text), - `mit allen Zeilen: ${mit.schilder.join(" / ")}`); - ok(mit.hoehe > 30 && mit.hoehe < 260, `und er bleibt klein (${mit.hoehe} px hoch)`); - - /* =================================================================== - STEHT DER TEXT AUF EINER FLAECHE -- UND IST ER LESBAR? - - BEIDE MAENGEL WAREN IN DER ERSTEN FASSUNG GRUEN. Gefunden hat sie - nicht diese Datei, sondern ein Blick auf das Bildschirmfoto: Der - Kasten stand mitten auf der Buehne, mit einem Lichtfleck hinter - „SIEHT". Eine Pruefung, die Groesse und Position misst und nicht - die Lesbarkeit, prueft das Bequeme statt das Wichtige. - - DREI AUSGAENGE, wie ueberall: lesbar / zu dunkel / konnte nicht - nachsehen. Der dritte ist hier der eigentliche Fund -- Text auf - einem Foto hat gar keinen berechenbaren Kontrast, und genau das - war der Zustand. */ - const KONTRAST = `(() => { - const leuchte = (r, g, b) => { - const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; - return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); - }; - const zahlen = (s) => (s.match(/[\d.]+/g) || []).map(Number); - /* Der erste Vorfahr mit einer WIRKLICH deckenden Flaeche. Alles - darunter ist Durchsicht -- und ein Foto ist keine Flaeche. */ - const grund = (e) => { - let n = e; - while (n && n !== document.documentElement) { - const st = getComputedStyle(n); - const f = zahlen(st.backgroundColor); - const deckend = f.length >= 3 && (f.length < 4 || f[3] >= 0.92); - if (deckend && st.backgroundImage === 'none') return f.slice(0, 3); - if (deckend) return null; // deckend, aber ein Bild darauf - n = n.parentElement; - } - return null; - }; - const kasten = document.querySelector('.erkl'); - if (!kasten) return { fehlt: true }; - const stuecke = [...kasten.querySelectorAll('.erkl__schild, .erkl__satz')]; - const schlecht = [], geraten = []; - let gemessen = 0; - for (const s of stuecke) { - if (!s.textContent.trim()) continue; - const h = grund(s); - if (!h) { geraten.push(s.textContent.trim().slice(0, 30)); continue; } - const v = zahlen(getComputedStyle(s).color).slice(0, 3); - const a = leuchte(...v), b = leuchte(...h); - const v2 = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - gemessen++; - if (v2 < 4.5) schlecht.push(s.textContent.trim().slice(0, 30) + ' = ' + v2.toFixed(2)); - } - return { gemessen, schlecht, geraten }; - })()`; - - const k = await seite.evaluate(KONTRAST); - /* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Faende die Messung kein - Textstueck, waere „alles lesbar" trivial wahr. */ - ok(k.gemessen >= 6 && k.schlecht.length === 0, - `${k.gemessen} Textstücke im Kasten, alle lesbar` - + (k.schlecht.length ? " -- ZU DUNKEL: " + k.schlecht.join(" | ") : "")); - ok(k.geraten.length === 0, - `jedes davon steht auf einer deckenden Fläche` - + (k.geraten.length ? ` -- AUF DER BÜHNE (${k.geraten.length}): ` + k.geraten.slice(0, 3).join(" | ") : "")); - - /* =================================================================== - SPRICHT „WOFUER" DIE UNTERZEILE NACH? - - Auch das war gruen und trotzdem falsch: Auf „Talente" stand oben - „Zuschauer, die ins Team passen koennten. Ein Trichter, keine - Liste" -- und zwei Zeilen darunter fast woertlich dasselbe. Das - liest sich wie ein Kopierfehler und verschenkt die Zeile. - - Gemessen wird der Anteil der langen Woerter, die in BEIDEN Saetzen - vorkommen. Ueber der Haelfte ist es keine Erklaerung mehr, sondern - ein Echo. */ - const echo = await seite.evaluate(() => { - const u = document.querySelector('main .unterzeile')?.textContent?.trim() || ''; - const zeilen = [...document.querySelectorAll('.erkl__zeile')]; - const w = zeilen.find((z) => /wof/i.test(z.querySelector('.erkl__schild')?.textContent || '')); - const wofuer = w?.querySelector('.erkl__satz')?.textContent?.trim() || ''; - if (!u || !wofuer) return { pruefbar: false, u, wofuer }; - const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || [])); - const a = lang(wofuer), b = lang(u); - if (!a.size) return { pruefbar: false, u, wofuer }; - let gleich = 0; - for (const x of a) if (b.has(x)) gleich++; - return { pruefbar: true, anteil: gleich / a.size, gleich, gesamt: a.size, u, wofuer }; - }); - ok(echo.pruefbar === true, - echo.pruefbar ? "Unterzeile und „Wofür“ stehen beide da — vergleichbar" - : `nicht vergleichbar (Unterzeile: „${(echo.u || "").slice(0, 40)}")`); - ok(echo.pruefbar && echo.anteil < 0.5, - `„Wofür“ sagt etwas anderes als die Unterzeile (${echo.gleich} von ${echo.gesamt} langen Wörtern gleich)`); - - - /* ... und eine OHNE. Das ist der Fall, der lautlos ausfallen würde. */ - const ohne = await ansehen("kalender.html"); - ok(ohne.da === true, "auf „Kalender“ (OHNE Kopfzeile) steht er genauso"); - ok(ohne.inMain, "und zwar im Inhalt, nicht davor"); - - /* Am Handy: bricht nichts seitlich aus? */ - const handy = await browser.newContext({ viewport: { width: 390, height: 844 } }); - const s2 = await handy.newPage(); - await s2.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); - await s2.click('.rolle[data-rolle="admin"]'); - await s2.fill("#code", "CODE-DOGI-0001"); - await s2.click("button[type=submit]"); - await s2.waitForURL("**/start.html", { timeout: 20000 }); - await s2.goto(BASIS + "/workspace/talente.html", { waitUntil: "networkidle" }); - await s2.waitForSelector(".erkl", { timeout: 6000 }).catch(() => {}); - const eng = await s2.evaluate(() => { - const k = document.querySelector(".erkl"); - if (!k) return null; - const r = k.getBoundingClientRect(); - return { raus: Math.round(r.right - document.documentElement.clientWidth), hoehe: Math.round(r.height) }; - }); - ok(eng !== null && eng.raus <= 1, `am Handy nichts seitlich draußen (${eng?.raus} px)`); - ok(eng !== null && eng.hoehe < 340, `und immer noch kompakt (${eng?.hoehe} px)`); - - ok(konsole.length === 0, - konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); - - await browser.close(); -} - -/* ------------------------------------------------------------------- */ -melde(""); -melde(`${geprueft} Pruefungen, ${fehler} Fehler`); -melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); -try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } -process.exit(fehler ? 1 : 0); diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 0fe66088..94a7be86 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -208,7 +208,7 @@ export function freigabenSetzen(dateiId, ids, person) { const SPALTEN = ` d.id, d.name_original, d.groesse, d.typ, d.status, d.notiz, d.creator_id, d.hochgeladen_von, d.erstellt, d.geaendert, - d.creator_extern, + d.creator_extern, d.ersetzt_id, ${externSql("pc.name", "d.creator_extern")} AS creator_name, ph.name AS hochgeladen_name`; @@ -217,6 +217,48 @@ const VERBUND = ` LEFT JOIN personen pc ON pc.id = d.creator_id LEFT JOIN personen ph ON ph.id = d.hochgeladen_von`; +/* ===================================================================== + WELCHE FASSUNG IST DAS? (15.09.2026) + + Aus „ersetzt_id“ wird eine Kette. Jede Datei bekommt zwei Angaben: + + fassung die wievielte sie ist (1 = die erste) + ersetzt_am ob es eine neuere gibt -- dann ist SIE nicht mehr aktuell + + GERECHNET, NICHT GESPEICHERT. Eine Spalte "version" muesste beim + Loeschen einer mittleren Fassung nachgezogen werden, und genau das + vergisst man. Die Kette steht in den Verweisen; die Nummer folgt + daraus. + + NUR AUS DER UEBERGEBENEN LISTE: Ist eine frueherere Fassung fuer + diese Person gar nicht sichtbar, zaehlt sie hier auch nicht mit -- + eine Zahl, die eine unsichtbare Datei mitzaehlt, waere eine Auskunft + ueber etwas, das man nicht sehen darf. + ===================================================================== */ +function fassungenSetzen(liste) { + const nachId = new Map(liste.map((d) => [d.id, d])); + const ersetztVon = new Map(); + for (const d of liste) if (d.ersetzt_id) ersetztVon.set(d.ersetzt_id, d.id); + + for (const d of liste) { + /* Nach hinten zaehlen, bis keine Vorgaengerin mehr da ist. Die + Schranke ist keine Vorsicht, sondern Pflicht: Ein Ring in den + Daten (durch einen Fehler anderswo) wuerde die Seite sonst + einfrieren, und ein Stillstand ist schlimmer als ein Fehler. */ + let n = 1; + let jetzige = d; + const gesehen = new Set([d.id]); + while (jetzige.ersetzt_id && nachId.has(jetzige.ersetzt_id) + && !gesehen.has(jetzige.ersetzt_id) && n < 200) { + gesehen.add(jetzige.ersetzt_id); + jetzige = nachId.get(jetzige.ersetzt_id); + n++; + } + d.fassung = n; + d.ersetzt_durch = ersetztVon.get(d.id) ?? null; + } +} + /* ---------- Liste ------------------------------------------------------- */ dateienRouter.get("/workspace/api/dateien", (req, res) => { @@ -228,6 +270,7 @@ dateienRouter.get("/workspace/api/dateien", (req, res) => { ORDER BY d.erstellt DESC`).all(...werte); const karte = freigabenZu(liste.map((d) => d.id)); for (const d of liste) d.freigaben = karte.get(d.id) || []; + fassungenSetzen(liste); res.json({ dateien: liste, empfaenger: moeglicheEmpfaenger(req.person), @@ -334,14 +377,36 @@ dateienRouter.post("/workspace/api/dateien", const dateiname = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`; writeFileSync(join(ORDNER, dateiname), req.body, { flag: "wx" }); + /* ---- IST DAS EINE NEUE FASSUNG? (15.09.2026) ------------------ + + AUTOMATISCH VERKNUEPFT, NICHT GEFRAGT -- und das ist eine + Abwaegung, keine Bequemlichkeit: Wer die Verknuepfung vergisst, + hat zwei Dateien gleichen Namens ohne Zusammenhang, und + irgendwann laedt jemand die alte herunter. Wer sie faelschlich + bekommt, sieht sie sofort ("Fassung 2, ersetzt ...") und kann + sie mit einem Klick loesen. Der stille Schaden ist groesser als + der sichtbare. + + GENAU EINE Bedingung, und sie ist eng: gleicher Originalname, + gleicher Creator-Bezug, und die Vorgaengerin darf nicht selbst + schon ersetzt sein. Faende die Abfrage mehrere, wird NICHT + geraten -- dann bleibt die Datei eigenstaendig. */ + const vorgaenger = db().prepare(` + SELECT id FROM dateien + WHERE name_original = ? + AND ((creator_id IS NULL AND ? IS NULL) OR creator_id = ?) + AND id NOT IN (SELECT ersetzt_id FROM dateien WHERE ersetzt_id IS NOT NULL) + ORDER BY id DESC`).all(name, creatorId, creatorId); + const ersetztId = vorgaenger.length === 1 ? vorgaenger[0].id : null; + const { lastInsertRowid } = db().prepare(` INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz, - creator_id, hochgeladen_von, erstellt) - VALUES (?,?,?,?,'entwurf',?,?,?,?)`).run( + creator_id, hochgeladen_von, erstellt, ersetzt_id) + VALUES (?,?,?,?,'entwurf',?,?,?,?,?)`).run( name, dateiname, req.body.length, (req.get("content-type") || "application/octet-stream").slice(0, 100), - notiz, creatorId, req.person.id, jetzt()); + notiz, creatorId, req.person.id, jetzt(), ersetztId); /* Ausdrueckliche Freigaben. Der Kopf traegt eine Liste von Ids -- geprueft wird jede einzelne gegen die erlaubte Auswahl. */ @@ -357,7 +422,7 @@ dateienRouter.post("/workspace/api/dateien", detail: `#${lastInsertRowid} ${name} (${Math.round(req.body.length / 1024)} KB)` + (anzahl ? `, freigegeben fuer ${anzahl}` : "").slice(0, 40), }); - res.status(201).json({ id: Number(lastInsertRowid) }); + res.status(201).json({ id: Number(lastInsertRowid), ersetzt_id: ersetztId }); } catch (fehler) { if (fehler?.type === "entity.too.large") { return res.status(413).json({ fehler: "Datei ist zu groß (max. 25 MB)." }); diff --git a/server/workspace-erklaerung.js b/server/workspace-erklaerung.js deleted file mode 100644 index 1300e9dd..00000000 --- a/server/workspace-erklaerung.js +++ /dev/null @@ -1,229 +0,0 @@ -/* ===================================================================== - Die kurze Erklärung auf jeder Seite (14.09.2026) - - Filipe: *„ich will in jeder seite auch eine kleine detaillierte aber - schnelle erklärung kurz und knapps noch zu jeder seite."* - - --------------------------------------------------------------------- - DREI ZEILEN, UND DIE DRITTE RECHNET SICH SELBST AUS - - WOFÜR wozu es diese Seite gibt - DU TUST was man hier konkret macht — nicht was man könnte - SIEHT wer diese Seite überhaupt öffnen kann - - Die ersten beiden sind geschriebener Text. Die dritte ist es NICHT: - Sie wird aus der Rechtetafel gerechnet, aus derselben, aus der auch - die Schranke ihre Entscheidung holt. Das ist der ganze Grund, warum - diese Zeile hier stehen darf. - - Hätte ich sie danebengeschrieben, wäre sie beim ersten Umstellen in - „Wer sieht was" falsch geworden — und zwar STILL falsch, in einem - Satz, der behauptet, wer etwas sieht. Genau diese Sorte Zweitschrift - hat im Projekt schon dreimal Daten oder Vertrauen gekostet (die - abgeschriebene Spaltenliste, die zweite Rollenreihenfolge im - Browser, das fest eingetragene Wort „Agentur"). Eine Angabe über - Sichtbarkeit ist der schlechteste Ort, um es ein viertes Mal zu tun. - - --------------------------------------------------------------------- - WARUM DIE ERKLÄRUNG HINTER DERSELBEN SCHRANKE STEHT WIE DIE SEITE - - Wer eine Seite nicht öffnen darf, bekommt auch ihre Erklärung nicht - — mit 404, nicht mit 403. Sonst wäre dieser Weg eine Landkarte des - Hauses: Ein Modi könnte abfragen, dass es „Personen & Zugänge" gibt, - wozu die Seite dient und wer sie sieht. Die Seite selbst antwortet - ihm mit „gibt es nicht"; die Erklärung dazu muss dasselbe sagen. - - --------------------------------------------------------------------- - WAS HIER BEWUSST NICHT STEHT - - Keine Anleitung in Schritten („klicke oben rechts auf …"). Solche - Texte altern mit jedem Umbau der Oberfläche und werden dann zu - Anweisungen, die ins Leere zeigen. Was hier steht, ist der ZWECK — - der ändert sich, wenn die Seite sich ändert, und nicht vorher. - ===================================================================== */ - -import express from "express"; -import { sitzungLesen, ROLLEN_NAME, ROLLEN_REIHE } from "./workspace.js"; -import { darfSeite, tafelJetzt } from "./rechte.js"; - -export const erklaerungRouter = express.Router(); - -/** Der Text zu jeder Seite. - * - * `wofuer` beantwortet „wozu gibt es das?", `tun` beantwortet „was - * mache ich hier?". `merke` ist der eine Satz, der am häufigsten - * missverstanden wird — er steht nur dort, wo es ihn wirklich gibt. - * Ein Feld, das auf jeder Seite gefüllt ist, wird nicht mehr gelesen. - */ -export const ERKLAERUNGEN = { - "start.html": { - wofuer: "Deine Startseite. Jede Kachel führt zu einem Bereich, und du siehst nur die, die dir offenstehen.", - tun: "Aussuchen, wo du hinwillst – oder oben auf die Glocke schauen, wenn etwas auf dich wartet.", - }, - "uebersicht.html": { - wofuer: "Alle betreuten Creator nebeneinander, mit Zahlen, offenen Aufgaben und dem Datum des letzten Kontakts.", - tun: "Die oberste Karte zuerst ansehen – sortiert wird nach dem, was Handlung verlangt, nicht nach Namen.", - }, - "aufgaben.html": { - wofuer: "Alle Aufgaben des Teams in vier Spalten, mit Frist, Zuständigkeit und Rückmeldungen.", - tun: "Eine Aufgabe weiterschieben, sobald sich ihr Stand ändert – nicht sammeln und später nachtragen.", - merke: "Was hier nicht steht, passiert nicht. Aufgaben aus einem Call landen automatisch hier.", - }, - "kalender.html": { - wofuer: "Alle Termine des Teams an einer Stelle, mit Serien und Feiertagen.", - tun: "Termin anlegen und unten auswählen, wer ihn im eigenen Kalender sehen soll.", - merke: "Wen du nicht auswählst, sieht den Termin nicht – auch nicht, dass es ihn gibt.", - }, - "chat.html": { - wofuer: "Nachrichten an einzelne Menschen oder an einen Kanal.", - tun: "Schreiben. Auf eine Nachricht kannst du auch nur reagieren, statt zu antworten.", - }, - "calls.html": { - wofuer: "Alle Gespräche mit Datum, Teilnehmern und Protokoll.", - tun: "Nach dem Call das Protokoll schreiben; die To-dos daraus landen direkt im Aufgabenbrett.", - merke: "Ein Call ohne Protokoll bleibt auf der Startseite stehen, bis eines da ist.", - }, - "dateien.html": { - wofuer: "Alle Dateien zu einem Creator, jede mit Stand und Versionsverlauf.", - tun: "Hochladen und den Stand setzen – die aktuelle Fassung ist dadurch immer erkennbar.", - }, - "content.html": { - wofuer: "Alle Videoideen mit Säule, Hook, Format und geplantem Datum.", - tun: "Eine Idee eintragen und ihr eine Säule geben; die Strecke danach ist immer dieselbe.", - }, - "bereich.html": { - wofuer: "Die Einträge dieses Bretts, je nach Brett mit Art, Datum, Dringlichkeit oder Bewertung.", - tun: "Eintrag anlegen, Art wählen, und bei „Gilt für“ entscheiden, ob er dir gehört oder allen.", - }, - "wissen.html": { - wofuer: "Anleitungen, PDFs und Kurzfassungen – durchsuchbar, nach Themen sortiert.", - tun: "Nachschlagen, bevor du fragst – und selbst etwas dazulegen, wenn du es erklärt hast.", - }, - "report.html": { - wofuer: "Je Creator und Zeitraum: was lief, was nicht, was als Nächstes kommt.", - tun: "Zeitraum wählen und den Rückblick schreiben, solange die Zahlen dazu danebenstehen.", - }, - "leistung.html": { - wofuer: "Livestunden, Zuschauer und Einnahmen je Tag – aus Backstage oder von Hand.", - tun: "Zeitraum importieren oder einen Tag von Hand eintragen, dann mit den Wochenzielen vergleichen.", - }, - "scouting.html": { - wofuer: "Alle Kontakte mit Stufe, Kanal, Reichweite und dem, was zuletzt passiert ist.", - tun: "Jeden Kontakt eine Stufe weiterschieben – oder bewusst beenden, statt ihn liegen zu lassen.", - }, - "startcheck.html": { - wofuer: "Ein Fragebogen je neuem Creator und die Arbeitspakete, die daraus entstehen.", - tun: "Gemeinsam mit ihr ausfüllen, nicht über sie – die Antworten sind der Anfang eurer Zusammenarbeit.", - }, - "profil.html": { - wofuer: "Zahlen, Aufgaben, Dateien, Termine und Verlauf zu einer Creatorin an einer Stelle.", - tun: "Nachsehen, bevor du mit ihr oder ihm sprichst.", - }, - "steckbrief.html": { - wofuer: "Dein Bild, ein paar Worte über dich und deine Kanäle.", - tun: "Ausfüllen. Dein Bild steht danach überall dort, wo dein Name auftaucht.", - merke: "Das hier gehört dir. Niemand außer dir ändert daran etwas.", - }, - "personen.html": { - wofuer: "Zugänge: wer hereinkommt, mit welcher Rolle, und wer wen betreut.", - tun: "Zugang anlegen und den Code persönlich weitergeben.", - merke: "Ein Zugangscode wird genau einmal angezeigt. Danach gibt es nur einen neuen, keinen alten.", - }, - "automation.html": { - wofuer: "Jede Automatik des Hauses mit Zustand, letzter Ausführung und Schalter.", - tun: "Nachsehen, was gerade greift, bevor du dich über etwas wunderst, das du nicht angestoßen hast.", - }, - "team.html": { - wofuer: "Die Arbeitslast von Managern und Scouts nebeneinander.", - tun: "Stellen suchen, an denen jemand zu viel hat – und dort umverteilen.", - merke: "Das ist keine Kontrolle. Die Seite gibt es, um fehlende Unterstützung zu finden.", - }, - "teamlage.html": { - wofuer: "Was in deinem Team gerade offen ist – und bei wem.", - tun: "Die offenen Punkte durchgehen und ansprechen.", - merke: "„Offen“ heißt: darüber habt ihr noch nicht geredet. Es ist keine Note über jemanden.", - }, - "entwicklung.html": { - wofuer: "34 Beobachtungen je Person, gesetzt von zwei Augenpaaren unabhängig voneinander.", - tun: "Pro Person die Punkte setzen, die du wirklich beobachtet hast. Den Rest lässt du offen.", - merke: "Keine Noten, keine Punktzahl. Das Ergebnis ist die Abweichung zwischen euch beiden – ein Gesprächsanlass.", - }, - "talente.html": { - wofuer: "Kandidaten für dein Mod-Team in fünf Stufen – mit 21 Merkmalen und 6 Warnzeichen je Karte.", - tun: "Pro Karte den einen nächsten Schritt machen, der dort steht.", - merke: "Hier stehen Namen von Menschen, die nichts davon wissen. Nichts davon geht nach außen.", - }, - "rechte.html": { - wofuer: "Jede Rolle mal jede Seite als Tafel – und jedes Feld lässt sich umstellen.", - tun: "Ein Feld umstellen und speichern. Die Änderung gilt sofort, auch für alle, die gerade angemeldet sind.", - merke: "Drei Felder lassen sich nicht abschalten – sonst könntest du dich selbst aussperren.", - }, - "treff-regeln.html": { - wofuer: "Die Regeln des Treffs und was passiert, wenn sie jemand nicht einhält.", - tun: "Einmal lesen. Danach weißt du, was hier gilt und an wen du dich wendest, wenn etwas schiefläuft.", - }, - "treff-moderation.html": { - wofuer: "Offene Meldungen, erledigte Fälle und jede Maßnahme mit Begründung.", - tun: "Eine Meldung ansehen und entscheiden: Hinweis, Pause oder Ausschluss – immer mit Begründung.", - merke: "Jede Maßnahme steht nachher mit Grund da. Das schützt auch dich.", - }, -}; - -/** Die Rollen, die diese Seite gerade öffnen dürfen — als Namen. - * - * AUS DER TAFEL GERECHNET, NICHT DANEBENGESCHRIEBEN. Wer in „Wer - * sieht was" ein Feld umstellt, ändert damit auch diesen Satz; eine - * zweite Liste hier wäre spätestens dann eine Behauptung. - * - * In der Reihenfolge des Hauses (ROLLEN_REIHE), damit die rechte Hand - * auch hier direkt hinter DogFather steht. */ -function sichtbarFuer(pfad) { - const erlaubt = tafelJetzt()[pfad] || []; - const bekannt = ROLLEN_REIHE.filter((r) => erlaubt.includes(r)); - /* Rollen, die (noch) nicht in der Rangfolge stehen -- heute nur - 'gast'. Sie faellt ans Ende, statt stillschweigend zu fehlen. */ - const rest = erlaubt.filter((r) => !bekannt.includes(r)); - return [...bekannt, ...rest].map((r) => ROLLEN_NAME[r] || r); -} - -erklaerungRouter.get("/workspace/api/erklaerung", (req, res) => { - try { - const person = sitzungLesen(req); - if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); - - /* Nur der Dateiname, und nur aus dem eigenen Verzeichnis. Ohne das - waere `?seite=../../etwas` eine Frage, die dieser Weg gar nicht - beantworten koennen soll. */ - const datei = String(req.query?.seite || "").trim(); - if (!/^[a-z0-9-]+\.html$/.test(datei)) { - return res.status(400).json({ fehler: "ungueltig" }); - } - const pfad = "/workspace/" + datei; - - /* DIESELBE SCHRANKE WIE FUER DIE SEITE SELBST -- und dieselbe - Antwort, wenn sie zu ist: 404, nicht 403. Wer "kein Zugriff" - liest, weiss, dass es die Seite gibt. */ - if (!darfSeite(person, pfad)) { - return res.status(404).json({ fehler: "nicht_gefunden" }); - } - - const text = ERKLAERUNGEN[datei]; - /* Eine Seite ohne Text ist KEIN Fehler, aber auch keine leere - Erklaerung: Der Browser bekommt `hat_text: false` und zeichnet - gar nichts. Ein Kasten mit drei leeren Zeilen waere schlechter - als keiner. */ - if (!text) return res.json({ hat_text: false, seite: datei }); - - res.json({ - hat_text: true, - seite: datei, - wofuer: text.wofuer, - tun: text.tun, - merke: text.merke || null, - sichtbar: sichtbarFuer(pfad), - }); - } catch (fehler) { - console.error("[erklaerung]", fehler?.message); - res.status(503).json({ fehler: "nicht_verfuegbar" }); - } -}); diff --git a/server/workspace.js b/server/workspace.js index ced75868..cccb8d07 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2062,6 +2062,24 @@ function umstellungen(d) { sie mit der Notiz zu entfernen, aus der sie entstand, waere die teuerste Art, Ordnung zu machen. */ ["aufgaben", "aus_eintrag_id", "INTEGER REFERENCES eintraege(id) ON DELETE SET NULL"], + + /* FASSUNGEN STATT UNVERBUNDENER DOPPEL (15.09.2026, Stufe 9). + + GEMESSEN, BEVOR ETWAS GEBAUT WURDE: Der Plan sagt "Dateiversionen + statt Ueberschreiben" -- ueberschrieben wurde aber nie. + „name_datei“ ist eindeutig und zufaellig, jedes Hochladen legt + eine neue Zeile an. Der Mangel ist ein anderer, und er ist + gefaehrlicher als Ueberschreiben: + + Zwei Dateien gleichen Namens stehen BEZIEHUNGSLOS nebeneinander. + Die alte kann "freigegeben" sein, die neue "entwurf" -- und wer + die Liste ansieht, laedt die freigegebene herunter. Also die + falsche. Kein Fehler, keine Meldung, nur die alte Fassung in der + Hand. + + ON DELETE SET NULL: Wird die alte Fassung geloescht, bleibt die + neue -- sie verliert nur ihre Vorgeschichte. */ + ["dateien", "ersetzt_id", "INTEGER REFERENCES dateien(id) ON DELETE SET NULL"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index e3b868a9..9d97e4b3 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=202609151224", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151252", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609151224", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151252", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151224", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151252", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151224", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151252", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 143d9623..f13fd2e7 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -7879,62 +7879,6 @@ body.start .k-leiste .feldschild::after { } .code-kasten__hinweis { margin: 0 0 14px; font-size: .85rem; color: var(--text-leise); } -/* ===================================================================== - DIE KURZE ERKLAERUNG AUF JEDER SEITE (14.09.2026) - - Drei Zeilen, Schild links, Satz rechts. Sie soll gelesen werden - koennen und danach nicht mehr auffallen -- deshalb kleine Schrift, - gedaempfte Farbe und keine eigene Flaeche, die mit den Karten - darunter um Aufmerksamkeit kaempft. Nur eine Kante links. - ===================================================================== */ - -.erkl { - margin: 0 0 22px; - padding: 13px 17px 13px 15px; - /* DECKEND, und das ist keine Kosmetik: Auf mehreren Seiten liegt - hinter dem Inhalt die Buehne -- ein Foto. Text darauf hat keinen - berechenbaren Kontrast, und „gut lesbar" waere dann eine - Vermutung statt einer Messung. */ - background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); - border: 1px solid var(--rand); - border-left: 3px solid color-mix(in srgb, var(--akzent) 55%, transparent); - display: grid; - gap: 5px; -} -.erkl__zeile { - display: grid; - grid-template-columns: 5.5rem 1fr; - gap: 10px; - align-items: baseline; -} -/* .75rem = 12 px. Die erste Fassung stand auf .68rem = 10,88 px und - lag damit unter der Hausgrenze von 11,5 px -- gefunden von - pruef-css-klassen, nicht vom Auge. */ -.erkl__schild { - font-size: .75rem; font-weight: 700; - letter-spacing: .08em; text-transform: uppercase; - color: var(--text-still); -} -.erkl__satz { - font-size: .84rem; line-height: 1.6; - max-width: 78ch; - color: var(--text-leise); -} -/* „Merke" ist die Zeile, die jemand einmal falsch verstanden hat. - Sie darf etwas deutlicher sein als die anderen -- aber nicht - alarmierend, sonst liest man die drei darueber nicht mehr. */ -.erkl__zeile[data-merke="ja"] .erkl__satz { color: var(--text); } -.erkl__zeile[data-merke="ja"] .erkl__schild { - color: color-mix(in srgb, var(--akzent) 70%, var(--text-still)); -} - -/* Am Handy untereinander: 5,5rem Schild plus ein Satz gehen bei 390 px - nicht nebeneinander, ohne dass jedes zweite Wort umbricht. */ -@media (max-width: 560px) { - .erkl__zeile { grid-template-columns: 1fr; gap: 1px; } - .erkl__satz { font-size: .83rem; } -} - /* ===================================================================== „WAS IST UEBER MICH GESPEICHERT?" (15.09.2026) diff --git a/workspace/assets/js/erklaerung.js b/workspace/assets/js/erklaerung.js deleted file mode 100644 index 92a709bf..00000000 --- a/workspace/assets/js/erklaerung.js +++ /dev/null @@ -1,86 +0,0 @@ -/* ===================================================================== - Die kurze Erklärung auf jeder Seite (14.09.2026) - - Filipe: „ich will in jeder seite auch eine kleine detaillierte aber - schnelle erklärung kurz und knapps noch zu jeder seite." - - --------------------------------------------------------------------- - WARUM SIE OFFEN DASTEHT UND NICHT HINTER EINEM KLICK - - „Schnell" war seine Bedingung. Eine Erklärung, die man erst - aufklappen muss, ist genau dann nicht da, wenn man sie braucht — - nämlich beim ersten Mal, wenn man noch nicht weiß, dass es sie gibt. - Drei kurze Zeilen kosten weniger Platz als ein Aufklapper kostet: - Aufmerksamkeit. - - --------------------------------------------------------------------- - WO SIE HINGEHÄNGT WIRD - - Nach der Kopfzeile, wenn es eine gibt — sonst als erstes Element im - Inhalt. Fünf Seiten haben keine `.kopf-zeile` (Chat, Kalender, - Start, Team, Teamlage); ohne den zweiten Fall hätten ausgerechnet - die am häufigsten benutzten Seiten keine Erklärung bekommen, und - zwar lautlos. - - --------------------------------------------------------------------- - SIE BAUT NICHTS UM, WENN NICHTS DA IST - - Kein Text zur Seite, keine Antwort, nicht angemeldet: dann entsteht - auch kein Kasten. Ein leerer Rahmen mit drei Überschriften wäre - schlechter als gar keiner — er sieht aus wie ein Fehler. - ===================================================================== */ -(() => { - const datei = (location.pathname.split('/').pop() || '').toLowerCase(); - if (!/^[a-z0-9-]+\.html$/.test(datei)) return; // Anmeldeseiten u. ä. - - const el = (t, k, text) => { - const n = document.createElement(t); - if (k) n.className = k; - if (text !== undefined) n.textContent = text; - return n; - }; - - /** Eine Zeile: Schild links, Satz rechts. */ - function zeile(schild, satz) { - const z = el('div', 'erkl__zeile'); - z.append(el('span', 'erkl__schild', schild)); - z.append(el('span', 'erkl__satz', satz)); - return z; - } - - function zeichne(d) { - const kasten = el('section', 'erkl'); - /* Ein Bereich mit Namen: Ein Vorleseprogramm sagt dadurch an, was - das hier ist, statt drei Wortpaare vorzulesen. */ - kasten.setAttribute('aria-label', 'Kurze Erklärung zu dieser Seite'); - - kasten.append(zeile('Wofür', d.wofuer)); - kasten.append(zeile('Du tust', d.tun)); - if (d.merke) { - const z = zeile('Merke', d.merke); - z.dataset.merke = 'ja'; - kasten.append(z); - } - if (Array.isArray(d.sichtbar) && d.sichtbar.length) { - /* DIESE ZEILE IST GERECHNET, NICHT GESCHRIEBEN -- sie kommt aus - derselben Rechtetafel wie die Schranke selbst. Deshalb darf - sie hier so bestimmt dastehen. */ - kasten.append(zeile('Sieht', d.sichtbar.join(' · '))); - } - - const kopf = document.querySelector('main .kopf-zeile'); - if (kopf) { kopf.insertAdjacentElement('afterend', kasten); return; } - const inhalt = document.querySelector('main'); - if (inhalt) inhalt.insertBefore(kasten, inhalt.firstElementChild); - } - - (async () => { - try { - const a = await fetch('/workspace/api/erklaerung?seite=' + encodeURIComponent(datei), - { credentials: 'same-origin' }); - if (!a.ok) return; // 401/404: dann eben nicht - const d = await a.json(); - if (d?.hat_text) zeichne(d); - } catch { /* ohne Erklärung ist die Seite vollständig benutzbar */ } - })(); -})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 44db9bd5..689d73b1 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,11 +252,10 @@ - - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 606a587f..f69ac581 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. --> - - - - - - - + + + + + + + - + @@ -168,11 +168,10 @@
- - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index c4ad39f4..b4e58fdd 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,15 +181,14 @@ - - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 9c9fc41d..a25cb177 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,12 +89,11 @@ - - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 8b78295a..c90d83ca 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,11 +341,10 @@ - - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 7a655e96..d4739324 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,13 +179,12 @@ - - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 762e3b9e..cfe4bdd6 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index c7b94067..17229128 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=202609151224", + "src": "/assets/img/app-symbole/crew-192.png?v=202609151252", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609151224", + "src": "/assets/img/app-symbole/crew-512.png?v=202609151252", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151224", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151252", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151224", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151252", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index de524fbe..7cfd6a7d 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,11 +108,10 @@ - - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 41d77853..fb48fcc5 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,9 @@ - - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index e10a54eb..8efef6ba 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 7549d9d8..18686426 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,12 +377,11 @@ - - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 31b68f33..bb2566ae 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. --> - - - - - - - + + + + + + + - + @@ -423,11 +423,10 @@ - - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 522b166a..4e6d2a8f 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,11 +423,10 @@ - - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 5f048118..e75aa4ef 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,12 +214,11 @@ - - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index dd80dc77..9d1f8c83 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,10 +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 7e4cde4f..ea6ab860 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,12 +106,11 @@ - - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 92c2b237..1af25eec 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,11 +173,10 @@ - - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 532fbed0..231a42b4 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,11 +674,10 @@

- - - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index c5ad2299..ad784c91 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,12 +85,11 @@ - - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 1b87e428..6328d81b 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. --> - - - - - - - + + + + + + + - + @@ -146,12 +146,11 @@ - - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 51729788..172a30e7 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,9 @@ - - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 5f47c3c3..5eac19e8 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,11 +110,10 @@ - - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index acec5f6d..bafd6f5c 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 dd9cb753..2af21b6c 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,10 +110,9 @@ - - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index b2a3531b..ab539036 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -210,11 +210,10 @@ 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 0ec50380..ca1f959d 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,12 +149,11 @@ - + - - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 155fffb0..c4e509e7 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,11 +190,10 @@ - - - - - - + + + + +