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 @@ - - - - - - + + + + +