diff --git a/server/crew-adresse.js b/server/crew-adresse.js index 1abb1f05..238c5015 100644 --- a/server/crew-adresse.js +++ b/server/crew-adresse.js @@ -376,6 +376,16 @@ export function istWorkspacePfad(pfad) { export const GEHOERT_ZU_ADRESSE = [ { datei: "/workspace/crew-index.html", zuhause: istCrewAdresse }, { datei: "/workspace/assets/css/crew-haus.css", zuhause: istCrewAdresse }, + /* DER NOTIZBLOCK (26.09.2026). Er gehoert zum Haus von Team Dogi, + und zwar vollstaendig: Auf der Agenturadresse gibt es die Seite + nicht -- auch nicht fuer DogFather, der in beiden Haeusern + arbeitet. Das ist dieselbe Trennung wie bei Chat, Kalender und + Aufgaben; ohne diesen Eintrag waere die Seite dort einfach + abrufbar, weil express.static jede Datei ausliefert, die man + beim Namen nennt. Die Schnittstelle prueft es ein zweites Mal + (nurTeamDogi in workspace-notizen.js) -- die Seite ist nur HTML, + die Daten sind die Sache. */ + { datei: "/workspace/notizen.html", zuhause: istCrewAdresse }, ]; /** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */ diff --git a/server/index.js b/server/index.js index 9ba234b7..74297970 100644 --- a/server/index.js +++ b/server/index.js @@ -38,6 +38,7 @@ import { uebernahmeRouter } from "./workspace-uebernahme.js"; import { befindenRouter } from "./workspace-befinden.js"; import { hilfeRouter } from "./workspace-hilfe.js"; import { supportRouter } from "./workspace-support.js"; +import { notizenRouter } from "./workspace-notizen.js"; import { unterstuetzungRouter } from "./workspace-unterstuetzung.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; @@ -312,6 +313,10 @@ app.use(uebernahmeRouter); app.use(befindenRouter); app.use(hilfeRouter); app.use(supportRouter); +/* Der Notizblock (26.09.2026): vier Rollen des Teamhauses, und jeder + sieht nur seinen eigenen -- auch DogFather. Begruendung im Kopf von + notiz-tabellen.js. */ +app.use(notizenRouter); /* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas beitragen kann – lesen alle, ändern nur DogFather. */ app.use(unterstuetzungRouter); @@ -569,3 +574,17 @@ process.on("uncaughtException", (fehler) => console.error("[uncaughtException]", app.listen(PORT, "127.0.0.1", () => { console.log(`DogFather Universe – Server läuft auf http://127.0.0.1:${PORT}`); }); + +/* ===================================================================== + DIE ANWENDUNG NACH AUSSEN (26.09.2026) + + Nur fuer Pruefungen. `pruef-notizen` geht damit den Routenbaum von + express durch und sucht Wege, die zweimal vergeben sind -- genau so + ein Fall hat beim Bau des Notizblocks eine Stunde gekostet + (DELETE /workspace/api/notizen/:id gab es schon in + workspace-aufgaben.js, und express nimmt schweigend die erste). + + Der Export aendert am Betrieb nichts: Wer diese Datei importiert, + startet den Server ohnehin -- das tut sie beim Laden. Er macht nur + sichtbar, was vorher nur im Speicher stand. */ +export default app; diff --git a/server/mess-notizblock.mjs b/server/mess-notizblock.mjs new file mode 100644 index 00000000..05cc7005 --- /dev/null +++ b/server/mess-notizblock.mjs @@ -0,0 +1,241 @@ +/* ===================================================================== + WIE SIEHT DER NOTIZBLOCK AUS? (26.09.2026) + + Ein Messwerkzeug, keine Pruefung: Es meldet nichts, es macht + Bilder. Rechner und Handy, leeres Blatt und beschriebenes. + + node server/mess-notizblock.mjs + + Filipe: „für die modis, rechte und linke hand und dogfather eine + kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das + auch wie ein notizblock erstellst." + + --------------------------------------------------------------------- + DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT + + 1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst — + auch nicht über die Schnittstelle, auch nicht von der anderen + Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben, + sondern aus dem Programm gelesen und gegen die Rechtetafel + gehalten: Zwei Listen für dieselbe Frage laufen auseinander. + + 2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht. + Das ist die Zusage, die auf der Kachel steht („nur du siehst + ihn"), und die einzige, deren Bruch man nicht bemerken würde: + Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber + genauso aus wie eine, die niemand liest. + + 3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht + aus einem unsichtbaren Schreibfeld und einer sichtbaren + Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines + Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen + beide Schichten an verschiedenen Stellen um — und der Cursor + steht neben dem Text. Das sieht aus wie ein Browserfehler und + wäre einer in notizen.css. Gemessen wird deshalb am echten + Umbruch, nicht an der Absicht. + + --------------------------------------------------------------------- + WARUM DIESE PRÜFUNG ÜBER https SPRICHT + + Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com` + steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die + Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse + (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block + antwortet zu Recht mit 404. + + Aufrufen mit: node server/pruef-notizen.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request } from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; + +/* Eine feste Nummer -- `eigenerPort` leitet seine aus der Stelle im + Alphabet der PRUEFdateien ab, und dieses Werkzeug ist keine. + Geprueft wird trotzdem, ob sie frei ist: Sonst misst man an einem + fremden Server und bekommt ein Bild von einem anderen Stand. */ +const PORT = 5461; +{ + const netz = await import("node:net"); + const frei = await new Promise((fertig) => { + const s = netz.createServer(); + s.once("error", () => fertig(false)); + s.once("listening", () => s.close(() => fertig(true))); + s.listen(PORT, "127.0.0.1"); + }); + if (!frei) { console.error(`Port ${PORT} ist belegt. Abgebrochen.`); process.exit(3); } +} + +process.env.PORT = `${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 }); }; +await import("./index.js"); +notbremse(200_000, "mess-notizblock"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); +process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); + +const CREW = "crew.dogfather-universe.com"; +const AGENTUR = "workspace.dogfather-universe.com"; + +/* ---------- Zugänge anlegen -------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, nun); +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", kennSchluessel).update(code).digest("hex"), nun); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], + ["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]]; +const CODES = {}; +const IDS = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`; + IDS[rolle] = anlegen(name, rolle, CODES[rolle]); +} +d.close(); + +/* ---------- Sprechen mit Hausadresse ------------------------------------ + `fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile), + und an ihr entscheidet das Haus alles. Deshalb node:http. */ +function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: host, + Origin: origin === undefined ? `https://${host}` : origin, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmelden(rolle, host) { + const a = await roh(host, "/workspace/api/anmelden", { + method: "POST", rumpf: { rolle, code: CODES[rolle] }, + }); + const keks = [] + .concat(a.kopf["set-cookie"] || []) + .map((z) => z.split(";")[0]).join("; "); + return a.code === 200 && keks ? keks : null; +} + +const kekse = { modi: await anmelden("modi", CREW) }; +if (!kekse.modi) { console.error("Anmeldung ging nicht"); process.exit(1); } + +const PROBE = [ + "# Sendung am Freitag", + "", + "[x] Gaesteliste an VanVan", + "[ ] Technik durchgehen -- Ton am Mischpult", + "[ ] Ansage schreiben", + "", + "- Idee: Zuschauerfragen in der letzten halben Stunde", + "- Idee: Gewinnspiel, aber ohne Punkte", + "", + "> Nicht vergessen: Casper braucht um 19 Uhr seine Runde.", + "", + "Unterlagen: https://crew.dogfather-universe.com/workspace/dateien.html", +].join(String.fromCharCode(10)); + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const { execFileSync } = await import("node:child_process"); +const { createServer: httpsServer } = await import("node:https"); +const { request: httpAnfrage } = await import("node:http"); +const schluesselDatei = join(ordner, "m.key"); +const zertDatei = join(ordner, "m.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); +const HTTPS_PORT = PORT + 1; +const vorbau = httpsServer({ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei) }, + (von, nach) => { + const weiter = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url, + method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` } }, + (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); + }); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +/* Drei Blaetter, damit der Stapel etwas zeigt. */ +const BR = String.fromCharCode(10); +await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi, + rumpf: { text: ["Einkauf fuers Studio", "[ ] Kabel", "[ ] Kaffee"].join(BR) } }); +await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi, + rumpf: { text: ["Woerter, die ich nicht mehr sagen will", "- eigentlich", "- quasi"].join(BR), + farbe: "rosa" } }); +const haupt = await roh(CREW, "/workspace/api/notizblock", { method: "POST", keks: kekse.modi, + rumpf: { text: PROBE, farbe: "sonne" } }); +await roh(CREW, `/workspace/api/notizblock/${haupt.j.notiz.id}`, { method: "PATCH", + keks: kekse.modi, rumpf: { angeheftet: true } }); + +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"] }); +const kontext = await browser.newContext({ viewport: { width: 1440, height: 980 }, + ignoreHTTPSErrors: true, deviceScaleFactor: 2 }); +await kontext.addCookies(kekse.modi.split("; ").filter(Boolean).map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; +})); + +const seite = await kontext.newPage(); +await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(1200); +await seite.screenshot({ path: "server/mess-notizblock-rechner.png" }); +console.log("server/mess-notizblock-rechner.png"); + +const handy = await kontext.newPage(); +await handy.setViewportSize({ width: 390, height: 844 }); +await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" }); +await handy.waitForTimeout(900); +await handy.screenshot({ path: "server/mess-notizblock-handy-liste.png" }); +await handy.locator(".n-zettel").first().click(); +await handy.waitForTimeout(600); +await handy.screenshot({ path: "server/mess-notizblock-handy-blatt.png" }); +console.log("server/mess-notizblock-handy-liste.png"); +console.log("server/mess-notizblock-handy-blatt.png"); + +await browser.close(); +await new Promise((r) => vorbau.close(r)); +process.exit(0); diff --git a/server/notiz-tabellen.js b/server/notiz-tabellen.js new file mode 100644 index 00000000..f47d5b66 --- /dev/null +++ b/server/notiz-tabellen.js @@ -0,0 +1,157 @@ +/* ===================================================================== + DER NOTIZBLOCK — die Tabelle und die Regeln dahinter (26.09.2026) + ===================================================================== + + Filipe: „für die modis, rechte und linke hand und dogfather eine + kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das + auch wie ein notizblock erstellst." + + --------------------------------------------------------------------- + DIE EINE ENTSCHEIDUNG, DIE ALLES ANDERE NACH SICH ZIEHT + + EIN NOTIZBLOCK GEHÖRT EINEM MENSCHEN. Nicht dem Team, nicht dem + Haus, nicht der Leitung. Wer seine Gedanken aufschreibt, schreibt + anders, wenn jemand mitlesen könnte — und dann ist es kein Block + mehr, sondern ein Bericht. + + Deshalb gibt es hier KEINE Rolle, die mehr sieht. `siehtAlles()` + wird in diesem Modul nicht gefragt, und das ist Absicht: DogFather + sieht die Notizen eines Modis so wenig wie umgekehrt. Jede Abfrage + hat `person_id = ?` fest eingebaut, nicht als Bedingung, die man + weglassen könnte. + + Das ist dieselbe Linie wie bei `befinden.html` („Ein paar Fragen + über dich, nur für dich. Niemand aus dem Team sieht deine + Antworten") — dort steht sie schon im Haus, hier wird sie + fortgesetzt. + + --------------------------------------------------------------------- + WARUM ON DELETE CASCADE UND NICHT SET NULL + + Bei Aufgaben ist es richtig, den Namen zu entfernen und die Sache + stehen zu lassen: Sie gehört dem Projekt. Eine Notiz gehört + niemandem sonst. Wird ein Zugang gelöscht, hat die Zeile keinen + Leser mehr — sie wäre ein Text ohne Menschen, den irgendwann jemand + in der Datenbank findet. Also geht sie mit. + + --------------------------------------------------------------------- + DER PAPIERKORB IST EIN DATUM, KEINE ZWEITE TABELLE + + `weg_am` gesetzt heißt „liegt im Papierkorb, seit …". Eine zweite + Tabelle wäre dieselbe Struktur noch einmal, und beim nächsten neuen + Feld hätte eine davon es nicht. Geräumt wird über die + Aufbewahrungsliste (workspace-aufbewahrung.js) — dort steht die + Frist, dort wird sie eingelöst. Ein Versprechen ohne Eintrag dort + ist kein Versprechen; genau das ist dem Support am 24.09. passiert. + ===================================================================== */ + +/** Wie lange etwas im Papierkorb liegt, bevor es wirklich weg ist. + * + * Dreißig Tage. Kürzer wäre eine Falle für den, der drei Wochen im + * Urlaub war; länger würde aus dem Papierkorb ein zweites Archiv, + * und dann räumt ihn niemand mehr aus. */ +export const PAPIERKORB_TAGE = 30; + +/** Wie lang eine einzelne Notiz werden darf. + * + * Zwanzigtausend Zeichen sind rund acht eng beschriebene Seiten. + * Die Zahl steht nicht gegen den Benutzer, sondern gegen ein + * hängendes Skript: Ohne Grenze schreibt ein Fehler in einer Schleife + * die Platte voll, und das merkt man erst, wenn nichts mehr geht. */ +export const TEXT_MAX = 20000; + +/** Wie viele Blätter ein Block hat. + * + * Dreihundert. Wer so viele hat, sucht ohnehin über die Suche und + * nicht über die Liste — und auch diese Zahl steht gegen ein Skript, + * nicht gegen einen Menschen. */ +export const BLAETTER_MAX = 300; + +/* ===================================================================== + DIE PAPIERFARBEN + + Sechs, und keine davon ist ein Papierweiß. Auf einer dunklen Wand + wäre ein cremefarbenes Blatt eine Lampe — die Hausregel heißt + augenschonend, und sie gilt auch für ein Blatt Papier. + + Die Farbe ist deshalb die TINTE: Rand, Linien und Kante des Blattes + tragen sie, die Fläche bleibt dunkel. Das ist näher an einer + schwarzen Kladde mit farbigem Lesebändchen als an einem Klebezettel + — und man kann sechs Blätter nebeneinander auseinanderhalten, ohne + dass eines blendet. + + ALLE SECHS HALTEN 4,5:1 GEGEN DEN DUNKLEN GRUND (#0a121e) — + gemessen, nicht geschätzt, und nachgeprüft in pruef-notizen. Sie + tragen ja auch Text. + + WARUM NICHT DIE 46 KACHELFARBEN: Die sind gegeneinander + auseinandergerechnet, damit man 46 Kacheln unterscheidet. Hier + müssen sechs auseinandergehen, und dafür sind größere, ruhigere + Abstände besser als sechs Nachbarn aus einer vollen Palette. */ +export const FARBEN = [ + { name: "block", wort: "Block", hex: "#9cde90" }, + { name: "sonne", wort: "Sonne", hex: "#e8bd63" }, + { name: "rosa", wort: "Rosa", hex: "#f2a0c0" }, + { name: "himmel", wort: "Himmel", hex: "#86c5f5" }, + { name: "flieder", wort: "Flieder", hex: "#c3a6f2" }, + { name: "minze", wort: "Minze", hex: "#7ee0c4" }, +]; +export const FARB_NAMEN = FARBEN.map((f) => f.name); + +/** + * Legt die Tabelle an. Wird aus workspace.js gerufen, nachdem + * `personen` steht — die Notiz hängt daran. + * + * ACHTUNG, EIN GEGEN-APOSTROPH IN DIESEM TEXT BEENDET DAS + * TEMPLATE-LITERAL. Das hat in diesem Haus schon zweimal den Start + * gekostet (support-tabellen.js, workspace.js). Deshalb steht in den + * Kommentaren hier drin kein einziger. + */ +export function notizTabellen(d) { + d.exec(` + CREATE TABLE IF NOT EXISTS notizen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + /* CASCADE: Eine Notiz ohne ihren Menschen ist ein Text, den + niemand lesen darf und niemand vermisst. */ + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + /* DER GANZE TEXT IN EINEM FELD -- Titel, Haken, Absaetze. + + Eine eigene Titelspalte waere die naheliegende Loesung und + die falsche: Dann muesste die Oberflaeche zwei Eingabefelder + haben, und ein Notizblock mit Formular ist kein Notizblock + mehr. Die erste Zeile IST der Titel, und zwar dort, wo sie + hingehoert -- im Text. */ + text TEXT NOT NULL DEFAULT '', + farbe TEXT NOT NULL DEFAULT 'block', + /* Angeheftet: bleibt oben. Als Zahl und nicht als Datum -- hier + geht es um ein Ja oder Nein, und ein Datum waere ein Feld, + das etwas anderes verspricht, als es haelt. */ + angeheftet INTEGER NOT NULL DEFAULT 0, + erstellt TEXT NOT NULL, + geaendert TEXT NOT NULL, + /* Im Papierkorb seit. NULL heisst: auf dem Block. */ + weg_am TEXT + ); + + /* Die eine Frage, die dieses Modul stellt: „meine Blaetter, die + angehefteten zuerst, danach das zuletzt Angefasste". Der Index + beantwortet sie in genau dieser Reihenfolge. */ + CREATE INDEX IF NOT EXISTS idx_notizen_person + ON notizen (person_id, weg_am, angeheftet DESC, geaendert DESC); + /* Und die Frage des Aufraeumers: „was liegt lange genug im + Papierkorb?" */ + CREATE INDEX IF NOT EXISTS idx_notizen_weg + ON notizen (weg_am); + `); + + /* DIE FARBPRUEFUNG STEHT NICHT IN DER TABELLE. + + Ein CHECK (farbe IN (...)) waere die naheliegende Stelle -- und + die, an der die Liste ein zweites Mal steht. Waechst FARBEN oben + um eine Farbe, muesste die Tabelle umgebaut werden, damit ein + Wert erlaubt wird, der im Programm laengst gilt. Genau diese + Sorte Abschrift hat am 11.09. drei Spalten gekostet. + + Geprueft wird deshalb an der Route, gegen FARB_NAMEN -- eine + Liste, die niemand pflegen muss, weil sie abgeleitet ist. */ +} diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs index 91e40c3e..5d3b3240 100644 --- a/server/pruef-buehne.mjs +++ b/server/pruef-buehne.mjs @@ -73,6 +73,10 @@ const ALLE_SEITEN = [ /* Neu am 21.09.2026 -- sie traegt eine Buehne und viel Text. */ "willkommen.html", "unsere-seiten.html", + /* Neu am 26.09.2026: der Notizblock. Er traegt eine Buehne, und + auf ihm steht die leiseste Schrift des Hauses direkt ueber + einem Foto -- genau der Fall, fuer den diese Datei gebaut ist. */ + "notizen.html", ]; const SEITEN = process.env.BUEHNE_SEITEN ? process.env.BUEHNE_SEITEN.split(",").map((s) => s.trim()).filter(Boolean) diff --git a/server/pruef-crew-adresse.mjs b/server/pruef-crew-adresse.mjs index fa5c0041..6f8cfbf1 100644 --- a/server/pruef-crew-adresse.mjs +++ b/server/pruef-crew-adresse.mjs @@ -645,11 +645,36 @@ for (const eintrag of GEHOERT_ZU_ADRESSE) { ok((draussen.text || "").length < 200, `workspace.: und es kommt auch kein Inhalt mit (${(draussen.text || "").length} Zeichen)`); + /* ==== DRINNEN HEISST NICHT IMMER 200 (26.09.2026) ============== + + Hier stand „muss 200 sein, und mehr als 200 Zeichen lang". + Das stimmte, solange in der Tafel nur OEFFENTLICHE Dateien + standen -- die Eingangswand und eine Stilvorlage. + + Mit notizen.html steht dort zum ersten Mal eine Seite HINTER + der Anmeldung. Ohne Sitzung antwortet sie mit 302 auf die + Anmeldung, und das ist richtig so. Die Pruefung meldete + trotzdem einen Fehler -- nicht am Haus, sondern an ihrer + eigenen, zu engen Erwartung. + + Die Frage dieser Tafel ist nicht „liefert es Inhalt aus?", + sondern „GIBT es das hier?". Genau das wird jetzt gemessen: + drinnen alles ausser 404/410, draussen nur 404/410. Beide + Richtungen bleiben, nur die Innenseite ist nicht mehr auf eine + einzige Zahl festgelegt. */ const drinnen = await hole(weg, { host: CREW }); - ok(drinnen.status === 200, - `crew.: ${weg} kommt dort an (${drinnen.status})`); - ok((drinnen.text || "").length > 200, - `crew.: und zwar mit Inhalt (${(drinnen.text || "").length} Zeichen)`); + ok(drinnen.status !== 404 && drinnen.status !== 410, + `crew.: ${weg} gibt es dort (${drinnen.status})`); + /* Und eine offene Datei kommt dort auch wirklich an. Bei einer + Seite hinter der Anmeldung waere „mehr als 200 Zeichen" eine + Aussage ueber die Weiterleitung, nicht ueber die Seite. */ + if (drinnen.status === 200) { + ok((drinnen.text || "").length > 200, + `crew.: und zwar mit Inhalt (${(drinnen.text || "").length} Zeichen)`); + } else { + ok(drinnen.status === 302 || drinnen.status === 401 || drinnen.status === 403, + `crew.: und schickt ohne Anmeldung zur Anmeldung (${drinnen.status})`); + } } /* DIE UMBIEGUNG DARF DIE SPERRE NICHT MITNEHMEN. diff --git a/server/pruef-kachelfarben.mjs b/server/pruef-kachelfarben.mjs index f9e7c754..f383f75a 100644 --- a/server/pruef-kachelfarben.mjs +++ b/server/pruef-kachelfarben.mjs @@ -133,6 +133,63 @@ for (const r of rollen) { /* DIE ZAHL GEHÖRT IN DIE BEDINGUNG. Fände diese Prüfung null benutzte Töne, wäre alles Folgende trivial grün — und bliebe es für immer. */ +/* ===================================================================== + DIE ZWEITE QUELLE -- UND DER BLINDE FLECK, DEN SIE SCHLIESST + ===================================================================== (26.09.2026) + + `benutzt` oben entsteht aus bereicheFuer() je Rolle. Das ist die + richtige Quelle fuer die Frage „welche Kachel traegt welchen Ton + in welcher Ansicht" -- und es ist die falsche fuer die Frage + „welche Toene sind ueberhaupt in Gebrauch". + + Denn fuer die fuenf Rollen des AGENTURHAUSES gibt bereicheFuer() + `null` zurueck. Das heisst „nimm die Liste aus dem Browser", und + die steht in workspace/assets/js/bereiche.js. Dort stehen die + Toene 1 bis 22 und 44 -- zweiundzwanzig Stueck, und keiner davon + ist hier je angekommen. + + WAS DAS GEKOSTET HAT: Diese Pruefung meldete „33 benutzte Toene" + und war gruen. Am 25.09. hat ein Farblauf daraufhin zwoelf + vermeintlich freie Toene verschoben und drei davon unter die + Buntheitsgrenze gedrueckt -- darunter „Dashboard", „Zahlen" und + „Scout-Pipeline", Kacheln, die jeden Tag jemand ansieht. Die + Pruefung blieb gruen. Ausserdem lagen Ton 7, 16 und 22 SEIT JEHER + unter der Grenze, ohne dass es je gemeldet wurde. + + Das ist die Sorte gruener Haken, vor der die Hausregel warnt: Er + sagt nur, dass die Bedingung erfuellt war -- nicht, dass sie das + Richtige angesehen hat. + + Ab hier gelten die REGELN (Kontrast, Buntheit) fuer jeden Ton, der + irgendwo an einer Kachel steht. Gelesen werden dieselben fuenf + Quellen wie in pruef-kachel-universum. Die Fragen nach ANSICHTEN + (zwei gleiche nebeneinander, die Willkommenskachel) bleiben bei + `benutzt` -- dort geht es wirklich um das, was der Server + ausgibt. */ +const QUELLEN = ["workspace.js", "../workspace/assets/js/bereiche.js", + "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js", + "../workspace/assets/js/befinden.js"]; +const IN_GEBRAUCH = new Set(); +/* Und WELCHE Kachel einen Ton traegt -- auch das aus den Quellen, + nicht nur aus dem, was der Server ausgibt. Gesucht wird der Name + VOR der Tonnummer im selben Objekt; `[^{}]*?` sorgt dafuer, dass + die Suche an der naechsten geschweiften Klammer endet und nicht + den Namen der vorigen Kachel einsammelt. */ +const TRAEGT = new Map(); +for (const q of QUELLEN) { + const text = readFileSync(join(HIER, q), "utf8"); + for (const m of text.matchAll(/ton:\s*(\d+)/g)) IN_GEBRAUCH.add(Number(m[1])); + for (const m of text.matchAll(/name:\s*["']([^"']+)["'][^{}]*?ton:\s*(\d+)/g)) { + const n = Number(m[2]); + if (!TRAEGT.has(n)) TRAEGT.set(n, new Set()); + TRAEGT.get(n).add(m[1]); + } +} +pruef(`${IN_GEBRAUCH.size} Toene stehen an einer Kachel -- gelesen aus ` + + `${QUELLEN.length} Dateien, nicht nur aus dem, was der Server ausgibt`, + IN_GEBRAUCH.size >= benutzt.size, + `nur ${IN_GEBRAUCH.size} gefunden, der Server gibt schon ${benutzt.size} aus`); + pruef(`es gibt benutzte Kachelfarben (${benutzt.size} Toene, ${ansichten.length} Ansichten)`, benutzt.size >= 20 && ansichten.length >= 5, `nur ${benutzt.size} Toene in ${ansichten.length} Ansichten gefunden`); @@ -243,7 +300,13 @@ console.log("\n=== Keine Ansicht zeigt zwei gleiche ==="); /* ---- 3. Festgelegt -------------------------------------------------- */ console.log("\n=== Was festgelegt ist, bleibt ==="); for (const f of FESTGELEGT) { - const traeger = [...(benutzt.get(f.ton) || [])]; + /* AUS BEIDEN QUELLEN. Bis zum 26.09. stand hier nur `benutzt` -- + die Kacheln, die der Server ausgibt. Ton 1 traegt „Dashboard" + im Agenturhaus, und das steht im Browser; die Zeile meldete + deshalb „traegt stattdessen: nichts" fuer eine Kachel, die es + gibt. */ + const traeger = [...new Set([...(benutzt.get(f.ton) || []), + ...(TRAEGT.get(f.ton) || [])])]; pruef(`Ton ${f.ton} traegt "${f.kachel}" (${f.warum})`, traeger.includes(f.kachel), `Ton ${f.ton} traegt stattdessen: ${traeger.join(", ") || "nichts"}`); @@ -263,15 +326,23 @@ console.log("\n=== Lesbar und kraeftig ==="); beim naechsten Mal vergessen wird. */ const darfBlassSein = new Map( FESTGELEGT.filter((f) => f.blassErlaubt).map((f) => [f.ton, f.blassErlaubt])); - for (const n of benutzt.keys()) { + /* WIE BLASS GENAU -- eine Ausnahme mit Zahl statt eines Freibriefs + (26.09.2026). Vorher hiess `blassErlaubt` schlicht „diese + Pruefung sieht hier weg"; ein Ton haette danach beliebig + verblassen koennen, ohne dass es auffaellt. Jetzt steht neben + jeder Ausnahme, wie weit sie reicht. */ + const blassBis = new Map( + FESTGELEGT.filter((f) => f.blassBis).map((f) => [f.ton, f.blassBis])); + for (const n of IN_GEBRAUCH) { const hex = farben.get(n); if (!hex) continue; if (kontrast(hex) < 4.5) dunkel.push(`Ton ${n} ${hex} (${kontrast(hex).toFixed(2)}:1)`); - if (buntheit(hex) < MINDEST_BUNTHEIT && !darfBlassSein.has(n)) { - blass.push(`Ton ${n} ${hex} (${buntheit(hex).toFixed(3)})`); + const grenze = blassBis.get(n) ?? MINDEST_BUNTHEIT; + if (buntheit(hex) < grenze) { + blass.push(`Ton ${n} ${hex} (${buntheit(hex).toFixed(3)}, erlaubt ab ${grenze})`); } } - pruef(`alle ${benutzt.size} benutzten Toene erreichen 4,5:1 gegen den Grund`, + pruef(`alle ${IN_GEBRAUCH.size} Toene an Kacheln erreichen 4,5:1 gegen den Grund`, dunkel.length === 0, dunkel.join(", ")); pruef(`keiner ist blasser als ${MINDEST_BUNTHEIT}` + (darfBlassSein.size ? ` (ausser ${darfBlassSein.size} benannten)` : ""), @@ -287,7 +358,7 @@ console.log("\n=== Lesbar und kraeftig ==="); if (!hex) { pruef(`Ton ${ton} ist festgelegt, steht aber nicht in start.css`, false); continue; } let naechste = Infinity, wer = "-"; for (const [m, h2] of farben) { - if (m === ton || !benutzt.has(m)) continue; + if (m === ton || !IN_GEBRAUCH.has(m)) continue; const d = abstand(oklab(hex), oklab(h2)); if (d < naechste) { naechste = d; wer = `Ton ${m}`; } } diff --git a/server/pruef-notizen.mjs b/server/pruef-notizen.mjs new file mode 100644 index 00000000..a3af8149 --- /dev/null +++ b/server/pruef-notizen.mjs @@ -0,0 +1,690 @@ +/* ===================================================================== + DER NOTIZBLOCK (26.09.2026) + + Filipe: „für die modis, rechte und linke hand und dogfather eine + kachel hinzufügst, sie soll: Notizen, heißen. ich will dass du das + auch wie ein notizblock erstellst." + + --------------------------------------------------------------------- + DREI DINGE WERDEN GEPRÜFT, UND ZWEI DAVON HAT NIEMAND BESTELLT + + 1. WER DARF ÜBERHAUPT. Genau die vier Rollen, und niemand sonst — + auch nicht über die Schnittstelle, auch nicht von der anderen + Adresse aus. Die Rollenmenge wird dabei nicht abgeschrieben, + sondern aus dem Programm gelesen und gegen die Rechtetafel + gehalten: Zwei Listen für dieselbe Frage laufen auseinander. + + 2. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN. Auch DogFather nicht. + Das ist die Zusage, die auf der Kachel steht („nur du siehst + ihn"), und die einzige, deren Bruch man nicht bemerken würde: + Eine Notiz, die jemand Fremdes liest, sieht für den Schreiber + genauso aus wie eine, die niemand liest. + + 3. DIE DECKSCHICHT LIEGT AUF DEN PIXEL GENAU. Das Blatt besteht + aus einem unsichtbaren Schreibfeld und einer sichtbaren + Deckschicht darüber. Ändert eine Auszeichnung die BREITE eines + Zeichens (Fettdruck, andere Schriftgröße, Sperrung), brechen + beide Schichten an verschiedenen Stellen um — und der Cursor + steht neben dem Text. Das sieht aus wie ein Browserfehler und + wäre einer in notizen.css. Gemessen wird deshalb am echten + Umbruch, nicht an der Absicht. + + --------------------------------------------------------------------- + WARUM DIESE PRÜFUNG ÜBER https SPRICHT + + Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com` + steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die + Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse + (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block + antwortet zu Recht mit 404. + + Aufrufen mit: node server/pruef-notizen.mjs + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request } from "node:http"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-notiz-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Notizblockpruefung"); + +process.env.PORT = `${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 }); }; +await import("./index.js"); +notbremse(260_000, "pruef-notizen"); +await new Promise((r) => setTimeout(r, 900)); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); +process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } }); + +const CREW = "crew.dogfather-universe.com"; +const AGENTUR = "workspace.dogfather-universe.com"; + +/* ---------- Zugänge anlegen -------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, nun); +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", kennSchluessel).update(code).digest("hex"), nun); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], + ["modi", "Miss"], ["gast", "Zuschauerin"], ["creator", "Luna"]]; +const CODES = {}; +const IDS = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-N${String(i).padStart(2, "0")}-000001`; + IDS[rolle] = anlegen(name, rolle, CODES[rolle]); +} +d.close(); + +/* ---------- Sprechen mit Hausadresse ------------------------------------ + `fetch` kann die Host-Kopfzeile nicht setzen (verbotene Kopfzeile), + und an ihr entscheidet das Haus alles. Deshalb node:http. */ +function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method, + headers: { + Host: host, + Origin: origin === undefined ? `https://${host}` : origin, + ...(keks ? { Cookie: keks } : {}), + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (s) => teile.push(s)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmelden(rolle, host) { + const a = await roh(host, "/workspace/api/anmelden", { + method: "POST", rumpf: { rolle, code: CODES[rolle] }, + }); + const keks = [] + .concat(a.kopf["set-cookie"] || []) + .map((z) => z.split(";")[0]).join("; "); + return a.code === 200 && keks ? keks : null; +} + +/* ======================================================================= + 1. WER DARF — und woher die Liste kommt + ======================================================================= */ +melde("=== 1. Wer einen Block hat ==="); + +const { ROLLEN_MIT_BLOCK } = await import("./workspace-notizen.js"); +const { SEITEN } = await import("./rechte.js"); +{ + const ausTafel = [...(SEITEN["/workspace/notizen.html"] || [])].sort(); + const ausProgramm = [...ROLLEN_MIT_BLOCK].sort(); + ok(ausTafel.length === 4, + `die Rechtetafel nennt vier Rollen (${ausTafel.join(", ") || "keine"})`); + /* BEIDE SEITEN GEGENEINANDER. Die Tafel bewacht die Seite, die + Menge im Modul die Daten. Wer nur eine pflegt, baut entweder eine + Tuer ohne Raum oder einen Raum ohne Tuer -- und beides faellt + niemandem auf. */ + ok(JSON.stringify(ausTafel) === JSON.stringify(ausProgramm), + `Tafel und Schnittstelle nennen dieselben Rollen (${ausProgramm.join(", ")})`, + ); + ok(ausTafel.includes("modi") && ausTafel.includes("hand") + && ausTafel.includes("linke") && ausTafel.includes("admin"), + "und es sind genau die vier aus dem Auftrag"); +} + +const kekse = {}; +for (const [rolle] of ROLLEN) kekse[rolle] = await anmelden(rolle, CREW); +ok(["admin", "hand", "linke", "modi"].every((r) => kekse[r]), + "die vier melden sich auf der Team-Adresse an"); + +melde(""); +melde("=== 2. Und wer nicht ==="); +for (const rolle of ["gast", "creator"]) { + if (!kekse[rolle]) { ok(true, `${rolle}: kommt auf dieser Adresse gar nicht herein`); continue; } + const a = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] }); + ok(a.code === 404, `${rolle}: bekommt 404 an der Schnittstelle (${a.code})`); +} +{ + /* DIE ANDERE ADRESSE. DogFather arbeitet in beiden Haeusern -- und + genau deshalb ist er hier der richtige Pruefstein: Bei ihm + scheitert es nicht an der Rolle, sondern an der Adresse. */ + const keksAgentur = await anmelden("admin", AGENTUR); + ok(!!keksAgentur, "DogFather meldet sich auch auf der Agenturadresse an"); + const a = await roh(AGENTUR, "/workspace/api/notizblock", { keks: keksAgentur }); + ok(a.code === 404, `und bekommt dort 404 auf seinen Block (${a.code})`); + const s = await roh(AGENTUR, "/workspace/notizen.html", { keks: keksAgentur }); + ok(s.code === 404, `auch die Seite selbst gibt es dort nicht (${s.code})`); + const s2 = await roh(CREW, "/workspace/notizen.html", { keks: kekse.admin }); + ok(s2.code === 200, `auf der Team-Adresse schon (${s2.code})`); +} +{ + const ohne = await roh(CREW, "/workspace/api/notizblock"); + ok(ohne.code === 401, `ohne Anmeldung: 401 (${ohne.code})`); +} + +/* ======================================================================= + 3. SCHREIBEN, LESEN, ÄNDERN + ======================================================================= */ +melde(""); +melde("=== 3. Ein Blatt ==="); +let blattId = null; +{ + const leer = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + ok(leer.code === 200 && Array.isArray(leer.j?.notizen) && leer.j.notizen.length === 0, + `am Anfang ist der Block leer (${leer.j?.notizen?.length})`); + ok(Array.isArray(leer.j?.farben) && leer.j.farben.length >= 4, + `die Papierfarben kommen vom Server (${leer.j?.farben?.length})`); + + const neu = await roh(CREW, "/workspace/api/notizblock", { + method: "POST", keks: kekse.modi, rumpf: { text: "Erste Zeile\nZweite Zeile" }, + }); + blattId = neu.j?.notiz?.id; + ok(neu.code === 201 && blattId > 0, `ein Blatt anlegen geht (${neu.code})`); + ok(neu.j?.notiz?.titel === "Erste Zeile", + `der Titel wird aus der ersten Zeile abgeleitet ("${neu.j?.notiz?.titel}")`); + + const aend = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" }, + }); + ok(aend.code === 200 && typeof aend.j?.geaendert === "string", + `aendern geht und gibt den Zeitstempel zurueck (${aend.code})`); + + const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + ok(nach.j?.notizen?.[0]?.text?.includes("[x] Brot"), "und der Text steht drin"); + ok(nach.j?.notizen?.[0]?.titel === "Einkauf", + `die Raute zaehlt nicht zum Titel ("${nach.j?.notizen?.[0]?.titel}")`); +} +{ + const falsch = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { farbe: "knallorange" }, + }); + ok(falsch.code === 400, `eine erfundene Papierfarbe wird abgelehnt (${falsch.code})`); + const echt = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { farbe: "rosa" }, + }); + ok(echt.code === 200, `eine echte nicht (${echt.code})`); +} +{ + const lang = "x".repeat(30000); + const a = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { text: lang }, + }); + const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + const drin = nach.j?.notizen?.find((n) => n.id === blattId)?.text || ""; + /* ABGESCHNITTEN UND NICHT ABGELEHNT. Wer zwanzigtausend Zeichen + getippt hat, soll nicht alles verlieren, weil er hundert zu viel + hatte -- er soll merken, dass hier Schluss ist. */ + ok(a.code === 200 && drin.length === nach.j.text_max, + `zu langer Text wird gekuerzt statt abgelehnt (${drin.length} von ${nach.j?.text_max})`); + await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { text: "# Einkauf\n[ ] Milch\n[x] Brot" }, + }); +} +{ + const fremd = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { text: "x" }, origin: "https://boese.example", + }); + ok(fremd.code === 403, `eine Anfrage von einer fremden Seite wird abgewiesen (${fremd.code})`); +} + +/* ======================================================================= + 4. NIEMAND SIEHT DIE NOTIZEN EINES ANDEREN + + Die Zusage steht auf der Kachel. Sie ist auch die einzige, deren + Bruch der Schreiber nicht bemerken würde. + ======================================================================= */ +melde(""); +melde("=== 4. Niemand sonst ==="); +for (const rolle of ["admin", "hand", "linke"]) { + const liste = await roh(CREW, "/workspace/api/notizblock", { keks: kekse[rolle] }); + const fremdeDrin = (liste.j?.notizen || []).some((n) => n.id === blattId); + ok(liste.code === 200 && !fremdeDrin, + `${rolle}: sieht das Blatt des Modis nicht in seiner Liste`); + const holen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse[rolle], rumpf: { text: "hier war ich" }, + }); + ok(holen.code === 404, `${rolle}: kann es auch nicht aendern (${holen.code})`); + const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "DELETE", keks: kekse[rolle], + }); + ok(weg.code === 404, `${rolle}: und nicht wegwerfen (${weg.code})`); +} +{ + /* GEGENPROBE. Ohne sie wuerde die Messung oben auch dann gruen, wenn + das Blatt gar nicht existiert. */ + const eigen = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "PATCH", keks: kekse.modi, rumpf: { angeheftet: true }, + }); + ok(eigen.code === 200, `Gegenprobe: dem Modi selbst gelingt genau dasselbe (${eigen.code})`); + const nach = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + ok(nach.j?.notizen?.[0]?.angeheftet === true, "und das Anheften kommt an"); +} + +/* ======================================================================= + 5. DER PAPIERKORB + ======================================================================= */ +melde(""); +melde("=== 5. Der Papierkorb ==="); +{ + const weg = await roh(CREW, `/workspace/api/notizblock/${blattId}`, { + method: "DELETE", keks: kekse.modi, + }); + ok(weg.code === 200, `wegtun geht (${weg.code})`); + const block = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + ok(!(block.j?.notizen || []).some((n) => n.id === blattId), "und es ist vom Block verschwunden"); + ok(block.j?.papierkorb === 1, `der Papierkorb zaehlt es (${block.j?.papierkorb})`); + + const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi }); + const drin = (korb.j?.notizen || []).find((n) => n.id === blattId); + ok(!!drin, "es liegt im Papierkorb"); + ok(typeof drin?.endgueltig_am === "string" + && Date.parse(drin.endgueltig_am) > Date.now(), + `und es steht dabei, wann es von selbst geht (${drin?.endgueltig_am?.slice(0, 10)})`); + + const zurueck = await roh(CREW, `/workspace/api/notizblock/${blattId}/zurueck`, { + method: "POST", keks: kekse.modi, + }); + ok(zurueck.code === 200, `zurueckholen geht (${zurueck.code})`); + const block2 = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + ok((block2.j?.notizen || []).some((n) => n.id === blattId), "und es ist wieder da"); +} +{ + /* ENDGUELTIG NUR AUS DEM PAPIERKORB. Vom Block aus gibt es diesen + Weg nicht -- wer danebentippt, soll nicht alles verlieren. */ + const zuFrueh = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, { + method: "DELETE", keks: kekse.modi, + }); + ok(zuFrueh.code === 404, + `endgueltig loeschen geht vom Block aus NICHT (${zuFrueh.code})`); + await roh(CREW, `/workspace/api/notizblock/${blattId}`, { method: "DELETE", keks: kekse.modi }); + const jetzt = await roh(CREW, `/workspace/api/notizblock/${blattId}/endgueltig`, { + method: "DELETE", keks: kekse.modi, + }); + ok(jetzt.code === 200, `aus dem Papierkorb schon (${jetzt.code})`); + const korb = await roh(CREW, "/workspace/api/notizblock/papierkorb", { keks: kekse.modi }); + ok((korb.j?.notizen || []).length === 0, "und dann ist es wirklich weg"); +} + +/* ======================================================================= + 6. DAS AUFRÄUMEN HÄLT SEINE FRIST + + Eine Frist ohne Werkzeug ist ein Satz in einem Kommentar. Geprüft + wird deshalb an echten Daten, in beide Richtungen. + ======================================================================= */ +melde(""); +melde("=== 6. Dreissig Tage ==="); +{ + const { notizenAufraeumen } = await import("./workspace-notizen.js"); + const { PAPIERKORB_TAGE } = await import("./notiz-tabellen.js"); + const { db } = await import("./workspace.js"); + const alt = new Date(Date.now() - (PAPIERKORB_TAGE + 2) * 86400000).toISOString(); + const frisch = new Date(Date.now() - 86400000).toISOString(); + const einfuegen = (weg) => db().prepare( + "INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert, weg_am)" + + " VALUES (?,?,?,0,?,?,?) RETURNING id") + .get(IDS.modi, "Probe", "block", nun, nun, weg).id; + const idAlt = einfuegen(alt); + const idNeu = einfuegen(frisch); + const idOffen = db().prepare( + "INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert)" + + " VALUES (?,?,?,0,?,?) RETURNING id").get(IDS.modi, "Auf dem Block", "block", nun, nun).id; + + const weg = notizenAufraeumen(); + const da = (id) => !!db().prepare("SELECT 1 FROM notizen WHERE id = ?").get(id); + ok(weg === 1, `genau eines wird geraeumt (${weg})`); + ok(!da(idAlt), `das alte ist weg (nach ${PAPIERKORB_TAGE} Tagen)`); + ok(da(idNeu), "das von gestern liegt noch da"); + /* DIE WICHTIGERE HAELFTE: Was auf dem Block liegt, wird NIE + geraeumt. Ohne diese Zeile waere ein Aufraeumer, der alles + loescht, hier gruen. */ + ok(da(idOffen), "und was gar nicht weggelegt wurde, bleibt unberuehrt"); + db().prepare("DELETE FROM notizen WHERE id IN (?,?)").run(idNeu, idOffen); +} +{ + /* Steht die Frist auch in der Aufbewahrungsliste? Sie ist die + einzige Stelle im Haus, die wirklich raeumt -- am 24.09. war + genau dieser Eintrag beim Support vergessen worden, und die + Meldungen waeren fuer immer liegen geblieben. */ + const { FRISTEN } = await import("./workspace-aufbewahrung.js"); + const eintrag = (FRISTEN || []).find((e) => e.schluessel === "notizen_papierkorb"); + ok(!!eintrag, "der Papierkorb steht in der Aufbewahrungsliste"); + ok(typeof eintrag?.raeumen === "function", + "und hat ein Werkzeug, das ihn wirklich raeumt"); +} + +/* ======================================================================= + 7. DAS BLATT AUF DEM BILDSCHIRM + + Jetzt der Teil, den keine Schnittstelle beantworten kann. + ======================================================================= */ +melde(""); +melde("=== 7. Das Blatt auf dem Bildschirm ==="); + +const { execFileSync } = await import("node:child_process"); +const { createServer: httpsServer } = await import("node:https"); +const { request: httpAnfrage } = await import("node:http"); + +const schluesselDatei = join(ordner, "pruef.key"); +const zertDatei = join(ordner, "pruef.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], + { stdio: "ignore" }); + +const HTTPS_PORT = PORT + 1; +const vorbau = httpsServer({ + key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei), +}, (von, nach) => { + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); +}); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch({ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, + "--ignore-certificate-errors"], +}); +const kontext = await browser.newContext({ + viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true, +}); +await kontext.addCookies((kekse.modi || "").split("; ").filter(Boolean).map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: CREW, path: "/" }; +})); +const seite = await kontext.newPage(); +const konsole = []; +seite.on("pageerror", (f) => konsole.push(String(f?.message || f))); +seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + +await seite.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(700); + +ok(await seite.locator("#block").count() === 1, "die Seite kommt an"); + +/* Ein Blatt anlegen und Text hineinschreiben, der alle Auszeichnungen + enthaelt -- und eine SEHR lange Zeile, die umbrechen MUSS. Genau an + ihr faellt eine Breitenaenderung auf: Brechen die zwei Schichten an + verschiedenen Stellen um, sind sie verschieden hoch. */ +const PROBE = [ + "# Eine Ueberschrift", + "[ ] Offener Punkt", + "[x] Erledigter Punkt", + "- Ein Aufzaehlungspunkt", + "> Ein Zitat", + "Ein Link: https://example.org/pfad", + "Eine sehr lange Zeile, die auf jeden Fall umbrechen muss, damit man sieht, " + + "ob beide Schichten an derselben Stelle umbrechen, und deshalb ist sie so " + + "lang wie sie ist, mit vielen Woertern hintereinander ohne Punkt und Komma " + + "damit der Umbruch wirklich mitten im Satz passiert und nicht am Ende.", +].join("\n"); + +await seite.click("#neu"); +await seite.waitForTimeout(400); +await seite.fill("#feld", PROBE); +await seite.waitForTimeout(400); + +{ + const m = await seite.evaluate(() => { + const f = document.getElementById("feld"); + const dk = document.getElementById("deck"); + const cf = getComputedStyle(f), cd = getComputedStyle(dk); + const gleich = (n) => cf[n] === cd[n]; + return { + hoeheFeld: f.scrollHeight, hoeheDeck: dk.scrollHeight, + breiteFeld: f.clientWidth, breiteDeck: dk.clientWidth, + schriftGleich: ["fontFamily", "fontSize", "fontWeight", "fontStyle", + "letterSpacing", "wordSpacing", "lineHeight", "whiteSpace", + "overflowWrap", "tabSize", "paddingLeft", "paddingRight", + "paddingTop"].filter((n) => !gleich(n)), + /* Und dasselbe fuer JEDES ausgezeichnete Stueck im Deck: Wenn + eine Auszeichnung die Schrift veraendert, steht es hier. */ + stuecke: [...dk.querySelectorAll("span, a")].map((s) => { + const c = getComputedStyle(s); + return ["fontFamily", "fontSize", "fontWeight", "fontStyle", + "letterSpacing", "wordSpacing"] + .filter((n) => c[n] !== cf[n]) + .map((n) => `${s.className || s.tagName}.${n}=${c[n]}`); + }).flat(), + haken: dk.querySelectorAll(".n-hk").length, + hakenAn: dk.querySelectorAll('.n-hk[data-an="ja"]').length, + links: dk.querySelectorAll("a.n-link").length, + ueber: dk.querySelectorAll(".n-h").length, + text: dk.textContent, + }; + }); + + ok(m.schriftGleich.length === 0, + `beide Schichten schreiben gleich (${m.schriftGleich.join(", ") || "alles gleich"})`); + ok(m.stuecke.length === 0, + `keine Auszeichnung aendert die Schrift (${m.stuecke.slice(0, 3).join(", ") || "keine"})`); + ok(m.breiteFeld === m.breiteDeck, + `beide sind gleich breit (${m.breiteFeld} / ${m.breiteDeck})`); + /* DIE EIGENTLICHE MESSUNG. Zwei Bildpunkte Spielraum fuers Runden; + ein verschobener Umbruch kostet eine ganze Zeile (30 px). */ + ok(Math.abs(m.hoeheFeld - m.hoeheDeck) <= 2, + `und gleich hoch -- der Umbruch sitzt an derselben Stelle ` + + `(${m.hoeheFeld} / ${m.hoeheDeck})`); + ok(m.text.replace(/\s+$/, "") === PROBE, + "die Deckschicht zeigt genau den Text, Zeichen fuer Zeichen"); + ok(m.haken === 2 && m.hakenAn === 1, + `zwei Kaestchen, eines angehakt (${m.haken}/${m.hakenAn})`); + ok(m.links === 1, `die Adresse ist anklickbar (${m.links})`); + ok(m.ueber === 1, `die Ueberschrift ist ausgezeichnet (${m.ueber})`); +} + +/* GEGENPROBE ZUR HOEHENMESSUNG. Ohne sie wuerde die Zeile oben auch + dann gruen, wenn die Messung gar nichts ansieht. Ein Fettdruck auf + der Deckschicht MUSS sie rot machen. */ +{ + const unterschied = await seite.evaluate(() => { + const f = document.getElementById("feld"); + const dk = document.getElementById("deck"); + const vorher = Math.abs(f.scrollHeight - dk.scrollHeight); + dk.style.fontWeight = "800"; + dk.style.letterSpacing = "0.4px"; + const nachher = Math.abs(f.scrollHeight - dk.scrollHeight); + dk.style.fontWeight = ""; dk.style.letterSpacing = ""; + return { vorher, nachher }; + }); + ok(unterschied.nachher > 2, + `Gegenprobe: Fettdruck auf der Deckschicht bricht die Deckung ` + + `(${unterschied.vorher} -> ${unterschied.nachher} px Unterschied)`); +} + +/* Ein Klick auf ein Kaestchen schaltet es um. */ +{ + const vorher = await seite.inputValue("#feld"); + await seite.locator("#deck .n-hk").first().click(); + await seite.waitForTimeout(250); + const nachher = await seite.inputValue("#feld"); + ok(vorher.includes("[ ] Offener Punkt") && nachher.includes("[x] Offener Punkt"), + "ein Klick auf das Kaestchen setzt den Haken"); + ok(nachher.length === vorher.length, + `und der Text bleibt zeichengenau gleich lang (${nachher.length}/${vorher.length})`); + await seite.locator("#deck .n-hk").first().click(); + await seite.waitForTimeout(250); + ok((await seite.inputValue("#feld")).includes("[ ] Offener Punkt"), + "noch ein Klick nimmt ihn wieder weg"); +} + +/* Die Tastatur fuehrt die Liste fort. */ +{ + await seite.fill("#feld", "- Erster Punkt"); + await seite.click("#feld"); + await seite.keyboard.press("Control+End"); + await seite.keyboard.press("Enter"); + await seite.keyboard.type("Zweiter"); + await seite.waitForTimeout(200); + const t = await seite.inputValue("#feld"); + ok(t === "- Erster Punkt\n- Zweiter", + `Enter setzt die Aufzaehlung fort ("${t.replace(/\n/g, "\\n")}")`); + + await seite.fill("#feld", "[x] Fertig"); + await seite.click("#feld"); + await seite.keyboard.press("Control+End"); + await seite.keyboard.press("Enter"); + await seite.keyboard.type("Noch offen"); + await seite.waitForTimeout(200); + const t2 = await seite.inputValue("#feld"); + /* DER HAKEN WIRD NICHT MITGENOMMEN -- die naechste Aufgabe ist noch + nicht erledigt. Das ist die Sorte Kleinigkeit, die den + Unterschied macht zwischen „funktioniert" und „denkt mit". */ + ok(t2 === "[x] Fertig\n[ ] Noch offen", + `und uebernimmt einen gesetzten Haken NICHT ("${t2.replace(/\n/g, "\\n")}")`); +} + +/* Es sichert sich von selbst -- gemessen am Neuladen, nicht an der + Anzeige. Eine Anzeige, die „gesichert" sagt, ist eine Behauptung. */ +{ + const wort = `Selbstgesichert-${Date.now()}`; + await seite.fill("#feld", wort); + await seite.waitForTimeout(1400); + const stand = (await seite.locator("#stand").textContent() || "").trim(); + /* GEFRAGT WIRD DER SERVER, nicht die Seite nach dem Neuladen. + + Der erste Anlauf lud neu und sah ins Schreibfeld -- und wurde + rot, obwohl alles richtig war: Die Seite schlaegt nach dem Laden + das OBERSTE Blatt auf, und das war ein anderes (ein angeheftetes + aus Abschnitt 4). Gemessen wurde also ein Blatt, in das nie + jemand getippt hat. Eine Messung, die das Falsche ansieht, ist + schlimmer als keine -- sie schickt einen los, einen Fehler zu + suchen, den es nicht gibt. */ + const drueben = await roh(CREW, "/workspace/api/notizblock", { keks: kekse.modi }); + const gefunden = (drueben.j?.notizen || []).some((n) => String(n.text).includes(wort)); + ok(gefunden, `der Text ist ohne Knopfdruck beim Server angekommen ` + + `(Anzeige sagte "${stand}")`); + ok(stand === "gesichert", `und die Anzeige sagt es auch ("${stand}")`); +} + +/* Und auf dem Handy: Das Blatt legt sich ueber die Liste, der + Zurueck-Knopf holt sie wieder. */ +{ + const handy = await kontext.newPage(); + await handy.setViewportSize({ width: 390, height: 780 }); + await handy.goto(`${BASIS}/workspace/notizen.html`, { waitUntil: "networkidle" }); + await handy.waitForTimeout(700); + const listeDa = await handy.locator(".n-zettel").count(); + ok(listeDa >= 1, `auf dem Handy steht zuerst der Stapel (${listeDa} Blaetter)`); + ok(await handy.locator("#blatt").isHidden(), + "und kein Blatt liegt davor -- sonst kaeme man nie an die Liste"); + await handy.locator(".n-zettel").first().click(); + await handy.waitForTimeout(400); + ok(await handy.locator("#blatt").isVisible(), "ein Tipp schlaegt das Blatt auf"); + ok(await handy.locator("#zurueck").isVisible(), + "und der Zurueck-Knopf erscheint"); + /* KEIN WAAGERECHTES SCHIEBEN. Die Hausgrenze ist die Breite des + Schirms; ein Blatt, das darueber hinausragt, macht die ganze + Seite schiebbar. */ + const breit = await handy.evaluate(() => + document.documentElement.scrollWidth - document.documentElement.clientWidth); + ok(breit <= 1, `nichts ragt seitlich hinaus (${breit} px)`); + await handy.locator("#zurueck").click(); + await handy.waitForTimeout(400); + ok(await handy.locator("#blatt").isHidden(), "der Zurueck-Knopf schliesst es wieder"); + await handy.close(); +} + +/* ======================================================================= + 8. ZWEI WEGE MIT DEMSELBEN NAMEN -- EINE HAUSWEITE PRUEFUNG + + Sie steht in dieser Datei, weil sie hier gefunden wurde, und sie + gilt fuer das ganze Haus. + + BEIM BAUEN DIESES BLOCKS: Die Route DELETE /workspace/api/notizen/:id + gab es schon -- in workspace-aufgaben.js, fuer die Notizen AN einer + Aufgabe. Weil aufgabenRouter frueher eingehaengt wird, hat sie jeden + Loeschversuch des Blocks abgefangen und mit 404 beantwortet. + + VON AUSSEN SAH DAS AUS WIE EIN FEHLER IM NEUEN MODUL: Anlegen ging, + Aendern ging, Loeschen nicht. Man sucht dann im eigenen Code, und + dort ist nichts. Express meldet so etwas nicht -- es nimmt die + erste Route, die passt, und schweigt ueber die zweite. + + Geprueft wird der Routenbaum von express selbst, nicht eine Liste. + Findet sich ein Paar aus Methode und Pfad zweimal, steht hier, wo. + ======================================================================= */ +melde(""); +melde("=== 8. Kein Weg heisst zweimal gleich ==="); +{ + const app = (await import("./index.js")).default; + const wege = new Map(); + const doppelt = []; + const sammeln = (schicht, vorn = "") => { + if (schicht.route) { + const pfad = vorn + (schicht.route.path || ""); + for (const m of Object.keys(schicht.route.methods || {})) { + if (m === "_all") continue; + const name = `${m.toUpperCase()} ${pfad}`; + if (wege.has(name)) doppelt.push(name); else wege.set(name, true); + } + } else if (schicht.handle?.stack) { + for (const s of schicht.handle.stack) sammeln(s, vorn); + } + }; + const stapel = app?._router?.stack || app?.router?.stack || []; + for (const s of stapel) sammeln(s); + /* DIE ZAHL GEHOERT IN DIE BEDINGUNG. Faende diese Messung gar keine + Wege (weil express seinen Baum eines Tages anders ablegt), waere + „keine Dubletten" trivial wahr -- und bliebe es fuer immer. */ + ok(wege.size > 200, `${wege.size} Wege im Baum gefunden`); + ok(doppelt.length === 0, + doppelt.length ? `doppelt vergeben: ${[...new Set(doppelt)].join(", ")}` + : "kein Paar aus Methode und Pfad kommt zweimal vor"); + /* GEGENPROBE: Die Messung MUSS eine Dublette finden koennen. */ + const probe = new Map(); const gefunden = []; + for (const n of ["GET /x", "GET /x"]) { + if (probe.has(n)) gefunden.push(n); else probe.set(n, true); + } + ok(gefunden.length === 1, "Gegenprobe: zwei gleiche Wege wuerden auffallen"); +} + +ok(konsole.length === 0, `keine Fehler in der Konsole (${konsole.slice(0, 2).join(" | ") || "keine"})`); + +await browser.close(); +await new Promise((r) => vorbau.close(r)); + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "FEHLGESCHLAGEN" : "ALLES IN ORDNUNG"); +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index bdb3690b..032ed40f 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -503,6 +503,28 @@ export const SEITEN = { Weiterhin ausdruecklich NICHT fuer die Agentur und nicht fuer die Modis: Die Seite ist eine vollstaendige Karte dieses Hauses -- jede Seite, jede Rolle. Wer sie hat, weiss, wo er es versuchen muesste. */ + /* DER NOTIZBLOCK (26.09.2026). + + Filipe: „fuer die modis, rechte und linke hand und dogfather + eine kachel hinzufuegst, sie soll: Notizen, heissen." + + GENAU DIESE VIER, und die linke Hand steht ausdruecklich dabei + statt sich aus der Schleife weiter unten zu ergeben: Sie ist + hier namentlich genannt, und eine namentliche Nennung gehoert + in die Tafel, die man liest, wenn man wissen will, wer wohin + darf. + + WAS AUF DER SEITE ZU SEHEN IST, entscheidet nicht diese Zeile: + In workspace-notizen.js hat jede Abfrage `person_id = ?` fest + eingebaut. Es gibt keine Rolle, die mehr sieht -- auch + DogFather nicht. Diese Tafel beantwortet nur, wer die Tuer + aufbekommt. + + DIE AGENTUR STEHT NICHT DABEI, und die Adresse auch nicht: Die + Seite gehoert zum Haus von Team Dogi und ist auf der + Agenturadresse ein 404 (GEHOERT_ZU_ADRESSE in crew-adresse.js). */ + "/workspace/notizen.html": ["admin", "hand", "linke", "modi"], + "/workspace/rechte.html": ["admin", "hand"], }; diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index 6406c01b..c1610a38 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -45,6 +45,8 @@ import express from "express"; import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js"; import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js"; import { supportAufraeumen } from "./workspace-support.js"; +import { notizenAufraeumen } from "./workspace-notizen.js"; +import { PAPIERKORB_TAGE as NOTIZ_TAGE } from "./notiz-tabellen.js"; import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js"; import { @@ -299,6 +301,28 @@ export const FRISTEN = [ lassen — und ein Bild ohne Zeile findet danach niemand mehr. */ raeumen: () => supportAufraeumen(), }, + /* ==== DER PAPIERKORB DES NOTIZBLOCKS (26.09.2026) ============== + + Der Eintrag entsteht ZUSAMMEN mit der Frist und nicht danach. + Am 24.09. ist genau das schiefgegangen: `supportAufraeumen()` + war geschrieben, exportiert und dokumentiert -- und wurde + nirgends aufgerufen. Im Kopf der Datei stand „90 Tage", und + geräumt wurde nie. */ + { + schluessel: "notizen_papierkorb", + was: "Weggelegte Notizblätter", + art: "raeumen", + frist: `${NOTIZ_TAGE} Tage im Papierkorb`, + zweck: "Ein versehentlich weggetipptes Blatt zurückholen können.", + grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung", + wirkung: `Das Blatt verschwindet ganz. Was auf dem Block liegt, wird ` + + `nie geräumt – nur was jemand selbst weggelegt hat, und erst ` + + `${NOTIZ_TAGE} Tage danach.`, + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM notizen WHERE weg_am IS NOT NULL AND weg_am < ?", + vorTagen(NOTIZ_TAGE)), + raeumen: () => notizenAufraeumen(), + }, { schluessel: "unterstuetzung_striche", was: "Tagesstriche der Unterstützen-Seite", diff --git a/server/workspace-notizen.js b/server/workspace-notizen.js new file mode 100644 index 00000000..f9b7fe28 --- /dev/null +++ b/server/workspace-notizen.js @@ -0,0 +1,402 @@ +/* ===================================================================== + DER NOTIZBLOCK — die Wege (26.09.2026) + + Die Regeln und ihre Begründung stehen im Kopf von + notiz-tabellen.js. Hier sind die Routen — und die vier Stellen, an + denen etwas schiefgehen kann: + + 1. EINE NOTIZ DARF NIE BEIM FALSCHEN LANDEN. Jede Abfrage hat + `person_id = ?` fest eingebaut. Es gibt keine Rolle, die mehr + sieht, und deshalb auch keinen Zweig, der die Bedingung + weglassen könnte. `blattFuer()` gibt den Datensatz zurück oder + `null` — nie `true`/`false`. So kann keine Route erst „darf + sehen" beantworten und danach trotzdem das falsche Blatt laden. + + 2. NUR AUF DER TEAM-ADRESSE. Der Block gehört zum Haus von Team + Dogi. Auf der Agenturadresse ist er ein 404 — auch für + DogFather, der in beiden Häusern arbeitet. Das ist dieselbe + Trennung wie bei Chat, Kalender und Aufgaben, und sie wird hier + geprüft und nicht nur in der Rechtetafel: Die Tafel beantwortet, + wer die SEITE öffnen darf, nicht von welcher Adresse aus. + + 3. GESPEICHERT WIRD OHNE KNOPF. Die Oberfläche schickt beim Tippen + alle 800 ms. Das ist viel Verkehr für wenig Inhalt — deshalb + antwortet PATCH knapp (nur Zeitstempel), und es gibt eine + Schreibbremse, die ein hängendes Skript stoppt, ohne einen + schnell Tippenden zu treffen. + + 4. ENDGÜLTIG IST ENDGÜLTIG. Löschen legt ins Papierkorbfach; + erst das zweite Löschen entfernt wirklich, und nur von dort. + Ein direkter Weg von „auf dem Block" zu „weg" existiert nicht. + ===================================================================== */ + +import express from "express"; +import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { PAPIERKORB_TAGE, TEXT_MAX, BLAETTER_MAX, FARBEN, FARB_NAMEN } + from "./notiz-tabellen.js"; + +export const notizenRouter = express.Router(); + +/** Wer einen Block hat. + * + * Genau die vier Rollen aus Filipes Satz. Die Liste steht hier UND + * in rechte.js, und das ist kein Versehen: Die Tafel bewacht die + * Seite, diese Menge die Daten. Wer nur die Tafel prüfte, hätte eine + * Schnittstelle, die jeder Angemeldete aufrufen kann — die Seite ist + * nur HTML, die Daten sind die Sache. + * + * pruef-notizen hält beide gegeneinander und wird rot, wenn sie + * auseinanderlaufen. */ +const MIT_BLOCK = new Set(["admin", "hand", "linke", "modi"]); + +const jetzt = () => new Date().toISOString(); + +/* ---------- Schranken --------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/** Team-Adresse und passende Rolle — sonst gibt es diese Seite nicht. + * + * 404 UND NICHT 403: Ein „verboten" verrät, dass es hier etwas gibt. + * Auf der Agenturadresse soll der Block nicht einmal als verschlossene + * Tür sichtbar sein. */ +function nurTeamDogi(req, res, next) { + if (req.person?.haus !== "crew") return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!MIT_BLOCK.has(req.person?.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" }); + next(); +} + +/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den + übrigen Modulen. Fehlt der Kopf ganz (curl), ist es kein + Browser-Angriff über eine fremde Seite; dann zählt allein das + Cookie. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +/* ==== DIE SCHREIBBREMSE ================================================ + + Automatisches Speichern heißt: Bei jedem Tippen kann eine Anfrage + kommen. Die Oberfläche wartet 800 ms Ruhe ab, ein schneller + Schreiber löst also höchstens rund 75 Anfragen je Minute aus, und + das auch nur, wenn er die ganze Minute durchtippt. + + DIE GRENZE IST DAS VIERFACHE DAVON. Sie steht nicht gegen den + Menschen, sondern gegen ein Skript, das in einer Schleife hängt -- + und sie ist absichtlich so hoch, dass niemand sie im Alltag je + sieht. Eine Bremse, die beim normalen Arbeiten greift, wird + umgangen und ist ab da wirkungslos (siehe die Hausregel dazu). + + Im Speicher und nicht in der Datenbank: Ein Neustart setzt sie + zurück, und das ist richtig -- sie schützt vor einem laufenden + Fehler, nicht vor einem Angreifer mit Geduld. */ +const SCHREIB_FENSTER_MS = 60_000; +const SCHREIB_MAX = 300; +const schreibZaehler = new Map(); // personId -> [Zeitpunkte] + +function schreibBremse(req, res, next) { + const id = req.person?.id; + if (!id) return next(); + const jetztMs = Date.now(); + const liste = (schreibZaehler.get(id) || []).filter((t) => jetztMs - t < SCHREIB_FENSTER_MS); + if (liste.length >= SCHREIB_MAX) { + return res.status(429).json({ + fehler: "Zu viele Änderungen in kurzer Zeit. Warte einen Moment — " + + "dein Text ist nicht verloren, er wird gleich erneut gesichert.", + }); + } + liste.push(jetztMs); + schreibZaehler.set(id, liste); + /* Damit die Karte nicht ewig wächst: Wer lange nichts geschrieben + hat, fliegt beim nächsten Durchgang heraus. */ + if (schreibZaehler.size > 500) { + for (const [k, v] of schreibZaehler) { + if (!v.some((t) => jetztMs - t < SCHREIB_FENSTER_MS)) schreibZaehler.delete(k); + } + } + next(); +} + +notizenRouter.use("/workspace/api/notizblock", angemeldet, nurTeamDogi); + +/* ---------- Hilfen ------------------------------------------------------ */ + +/** Ein Blatt dieser Person — oder null. + * + * Gibt den DATENSATZ zurück und nicht ein Ja/Nein: Eine Route, die + * erst fragt „darf ich?" und danach selbst lädt, kann die zweite + * Abfrage anders stellen als die erste. So kann sie es nicht. */ +function blattFuer(person, id, { ausPapierkorb = false } = {}) { + const n = Number(id); + if (!Number.isInteger(n) || n <= 0) return null; + const zeile = db().prepare("SELECT * FROM notizen WHERE id = ? AND person_id = ?") + .get(n, person.id); + if (!zeile) return null; + if (ausPapierkorb ? !zeile.weg_am : zeile.weg_am) return null; + return zeile; +} + +/** Mehrzeiliger Text: Absätze bleiben, Leerzeilenwüsten nicht. + * + * Kein `trim()` am Anfang jeder Zeile — Einrückung ist in einer + * Notiz Inhalt („ - Unterpunkt"). Entfernt werden nur Wagenrückläufe + * (damit die Länge überall dieselbe ist) und mehr als zwei leere + * Zeilen am Stück. */ +function textSauber(s) { + return String(s ?? "") + .replace(/\r\n?/g, "\n") + .replace(/\n{4,}/g, "\n\n\n") + .slice(0, TEXT_MAX); +} + +/** Die erste nicht leere Zeile, ohne ihre Auszeichnung. + * + * Wird NUR für die Liste gebraucht (und für die Suche). Der Titel + * wird nicht gespeichert: Zwei Orte für dieselbe Sache laufen + * auseinander, sobald jemand die erste Zeile ändert und das Feld + * nicht. Hier wird er jedes Mal aus dem Text abgeleitet und ist + * damit immer der, der wirklich oben steht. */ +function titelVon(text) { + const zeile = String(text || "").split("\n").find((z) => z.trim().length) || ""; + return zeile + .replace(/^\s*#{1,3}\s*/, "") + .replace(/^\s*[-*]\s+/, "") + .replace(/^\s*\[[ xX]\]\s*/, "") + .trim() + .slice(0, 120); +} + +function nachDraussen(z) { + return { + id: z.id, + text: z.text, + titel: titelVon(z.text), + farbe: z.farbe, + angeheftet: !!z.angeheftet, + erstellt: z.erstellt, + geaendert: z.geaendert, + weg_am: z.weg_am || null, + }; +} + +/* ---------- Lesen -------------------------------------------------------- */ + +/** Der Block: alle eigenen Blätter, angeheftete zuerst. */ +notizenRouter.get("/workspace/api/notizblock", (req, res) => { + try { + const zeilen = db().prepare(` + SELECT * FROM notizen + WHERE person_id = ? AND weg_am IS NULL + ORDER BY angeheftet DESC, geaendert DESC + LIMIT ?`).all(req.person.id, BLAETTER_MAX); + /* DIE ZAHL KOMMT AUS DER DATENBANK, nicht aus der Liste oben: Die + ist bei BLAETTER_MAX abgeschnitten, und eine Zählung darauf + wäre ab dem 301. Blatt still falsch. */ + const imKorb = db().prepare(`SELECT COUNT(*) AS n FROM notizen + WHERE person_id = ? AND weg_am IS NOT NULL`).get(req.person.id).n; + res.json({ + notizen: zeilen.map(nachDraussen), + papierkorb: imKorb, + text_max: TEXT_MAX, + blaetter_max: BLAETTER_MAX, + papierkorb_tage: PAPIERKORB_TAGE, + farben: FARBEN, + }); + } catch (fehler) { + console.error("[notizen] Liste:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Was im Papierkorb liegt — mit dem Tag, an dem es von selbst geht. */ +notizenRouter.get("/workspace/api/notizblock/papierkorb", (req, res) => { + try { + const zeilen = db().prepare(` + SELECT * FROM notizen + WHERE person_id = ? AND weg_am IS NOT NULL + ORDER BY weg_am DESC LIMIT ?`).all(req.person.id, BLAETTER_MAX); + res.json({ + notizen: zeilen.map((z) => ({ + ...nachDraussen(z), + /* WANN ES WIRKLICH WEG IST -- gerechnet und nicht „in 30 + Tagen" geschrieben. Wer gestern geloescht hat, will wissen, + wie viel Zeit er noch hat, und nicht, wie lang die Frist + ist. */ + endgueltig_am: new Date(Date.parse(z.weg_am) + PAPIERKORB_TAGE * 86400000) + .toISOString(), + })), + papierkorb_tage: PAPIERKORB_TAGE, + }); + } catch (fehler) { + console.error("[notizen] Papierkorb:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Schreiben ----------------------------------------------------- */ + +/** Ein neues Blatt. */ +notizenRouter.post("/workspace/api/notizblock", gleicheHerkunft, schreibBremse, + express.json({ limit: "256kb" }), (req, res) => { + try { + const offen = db().prepare(`SELECT COUNT(*) AS n FROM notizen + WHERE person_id = ? AND weg_am IS NULL`).get(req.person.id).n; + if (offen >= BLAETTER_MAX) { + return res.status(429).json({ + fehler: `Dein Block hat ${BLAETTER_MAX} Blätter — mehr gehen nicht. ` + + "Räum ein paar in den Papierkorb, dann geht es weiter.", + }); + } + const text = textSauber(req.body?.text); + const farbe = FARB_NAMEN.includes(req.body?.farbe) ? req.body.farbe : FARB_NAMEN[0]; + const n = jetzt(); + const { lastInsertRowid } = db().prepare(` + INSERT INTO notizen (person_id, text, farbe, angeheftet, erstellt, geaendert) + VALUES (?,?,?,0,?,?)`).run(req.person.id, text, farbe, n, n); + const frisch = db().prepare("SELECT * FROM notizen WHERE id = ?").get(lastInsertRowid); + res.status(201).json({ notiz: nachDraussen(frisch) }); + } catch (fehler) { + console.error("[notizen] Anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Ändern: Text, Farbe, angeheftet. Einzeln oder zusammen. + * + * ANTWORTET KNAPP. Diese Route läuft beim Tippen alle 800 ms; das + * ganze Blatt zurückzuschicken wäre bei einer langen Notiz + * zwanzigtausend Zeichen je Tastendruckpause, für eine Information, + * die der Browser schon hat. Zurück kommt der Zeitstempel — das + * Einzige, was er NICHT weiß. */ +notizenRouter.patch("/workspace/api/notizblock/:id(\\d+)", gleicheHerkunft, schreibBremse, + express.json({ limit: "256kb" }), (req, res) => { + try { + const z = blattFuer(req.person, req.params.id); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const felder = [], werte = []; + if (req.body?.text !== undefined) { + felder.push("text = ?"); werte.push(textSauber(req.body.text)); + } + if (req.body?.farbe !== undefined) { + if (!FARB_NAMEN.includes(req.body.farbe)) { + return res.status(400).json({ fehler: "Diese Papierfarbe gibt es nicht." }); + } + felder.push("farbe = ?"); werte.push(req.body.farbe); + } + if (req.body?.angeheftet !== undefined) { + felder.push("angeheftet = ?"); werte.push(req.body.angeheftet ? 1 : 0); + } + if (!felder.length) return res.status(400).json({ fehler: "Nichts zu ändern." }); + + const n = jetzt(); + felder.push("geaendert = ?"); werte.push(n); + werte.push(z.id, req.person.id); + db().prepare(`UPDATE notizen SET ${felder.join(", ")} + WHERE id = ? AND person_id = ?`).run(...werte); + res.json({ geaendert: n }); + } catch (fehler) { + console.error("[notizen] Aendern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** In den Papierkorb. */ +notizenRouter.delete("/workspace/api/notizblock/:id(\\d+)", gleicheHerkunft, schreibBremse, + (req, res) => { + try { + const z = blattFuer(req.person, req.params.id); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + const n = jetzt(); + db().prepare("UPDATE notizen SET weg_am = ?, angeheftet = 0 WHERE id = ? AND person_id = ?") + .run(n, z.id, req.person.id); + res.json({ weg_am: n, papierkorb_tage: PAPIERKORB_TAGE }); + } catch (fehler) { + console.error("[notizen] Wegtun:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Wieder heraus. */ +notizenRouter.post("/workspace/api/notizblock/:id(\\d+)/zurueck", gleicheHerkunft, schreibBremse, + (req, res) => { + try { + const z = blattFuer(req.person, req.params.id, { ausPapierkorb: true }); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + const offen = db().prepare(`SELECT COUNT(*) AS n FROM notizen + WHERE person_id = ? AND weg_am IS NULL`).get(req.person.id).n; + if (offen >= BLAETTER_MAX) { + return res.status(429).json({ + fehler: `Dein Block ist voll (${BLAETTER_MAX} Blätter). ` + + "Tu erst eines weg, dann kommt dieses zurück.", + }); + } + db().prepare("UPDATE notizen SET weg_am = NULL, geaendert = ? WHERE id = ? AND person_id = ?") + .run(jetzt(), z.id, req.person.id); + const frisch = db().prepare("SELECT * FROM notizen WHERE id = ?").get(z.id); + res.json({ notiz: nachDraussen(frisch) }); + } catch (fehler) { + console.error("[notizen] Zurueck:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Endgültig — und nur aus dem Papierkorb. + * + * Es gibt keinen Weg von „auf dem Block" direkt zu „weg". Wer + * danebentippt, landet im Papierkorb; erst wer dort noch einmal + * drückt, meint es. */ +notizenRouter.delete("/workspace/api/notizblock/:id(\\d+)/endgueltig", gleicheHerkunft, + schreibBremse, (req, res) => { + try { + const z = blattFuer(req.person, req.params.id, { ausPapierkorb: true }); + if (!z) return res.status(404).json({ fehler: "nicht_gefunden" }); + db().prepare("DELETE FROM notizen WHERE id = ? AND person_id = ?") + .run(z.id, req.person.id); + /* PROTOKOLLIERT WIRD NUR DAS UNWIDERRUFLICHE -- und ohne eine + Silbe des Inhalts. Jede Textänderung zu protokollieren würde + aus einem privaten Block eine Aktivitätsspur machen, die + andere lesen können; genau das soll er nicht sein. Dass etwas + endgültig verschwunden ist, gehört dagegen in die Ablage: + Sonst sucht jemand eines Tages eine Zeile, die niemand + vermisst hat. */ + protokolliere("notiz_endgueltig_weg", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${z.id}`, + }); + res.json({ weg: true }); + } catch (fehler) { + console.error("[notizen] Endgueltig:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ===================================================================== + AUFRAEUMEN + + Wird aus der Aufbewahrungsliste gerufen (workspace-aufbewahrung.js) + und nirgends sonst. Das ist die einzige Stelle im Haus, die + wirklich räumt — eine Frist ohne Eintrag dort ist kein Versprechen, + sondern ein Satz in einem Kommentar. + ===================================================================== */ +export function notizenAufraeumen() { + const grenze = new Date(Date.now() - PAPIERKORB_TAGE * 86400000).toISOString(); + const weg = db().prepare("DELETE FROM notizen WHERE weg_am IS NOT NULL AND weg_am < ?") + .run(grenze); + return Number(weg?.changes || 0); +} + +/* Für die Prüfung: dieselbe Menge, die `nurTeamDogi` benutzt. Sie + wird exportiert, damit pruef-notizen sie gegen die Rechtetafel + halten kann, statt sie abzuschreiben. */ +export const ROLLEN_MIT_BLOCK = MIT_BLOCK; diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js index b8e59dd0..0d23e6d7 100644 --- a/server/workspace-willkommen.js +++ b/server/workspace-willkommen.js @@ -140,6 +140,9 @@ const LANG = { "hilfe.html": "Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather " + "und die rechte Hand sehen diese Meldungen.", + "notizen.html": + "Dein Block. Schreib hinein, was dir einfällt — es speichert sich von " + + "selbst, und niemand außer dir sieht es. Auch DogFather nicht.", "support.html": "Etwas an der Seite funktioniert nicht oder sieht falsch aus? Schreib " + "einen Satz dazu und häng ein Bildschirmfoto an — DogFather und die " diff --git a/server/workspace.js b/server/workspace.js index 28648236..b975cf00 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -34,6 +34,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js"; import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js"; import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; +import { notizTabellen } from "./notiz-tabellen.js"; import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN, CREW_ADRESSE, WORKSPACE_ADRESSE, AGENTUR_ROLLEN, BEIDE_HAEUSER_ROLLEN, @@ -1049,6 +1050,49 @@ const MODI_BEREICHE = [ { gruppe: "Für dich", gruppeUnter: "Deine Seite im Team", name: "Mein Steckbrief", unter: "Dein Bild und deine Kanäle", zeichen: "steckbrief", ton: 10, ziel: "steckbrief.html", szene: "halle" }, + + /* ================================================================= + DER NOTIZBLOCK (26.09.2026) + + Filipe: „fuer die modis, rechte und linke hand und dogfather + eine kachel hinzufuegst, sie soll: Notizen, heissen. ich will + dass du das auch wie ein notizblock erstellst." + + GENAU DIESE VIER ROLLEN, und dafuer muss hier nichts aufgezaehlt + werden: MODI_BEREICHE ist die Grundliste, aus der HAND_BEREICHE + durch Erweitern entsteht und LINKE_MIT_TREFF durch Wegnehmen + einer einzigen Kachel. Wer hier eintraegt, trifft Modi, rechte + Hand, linke Hand und DogFather auf der Team-Adresse -- und + sonst niemanden. Eine eigene Liste waere die, die beim naechsten + Rollenzuschnitt auseinanderlaeuft. + + ER GEHOERT ZU „FUER DICH" UND NICHT ZU „TAEGLICH", obwohl man + ihn taeglich aufmacht. Die Gruppe sagt die wichtigste + Eigenschaft, bevor man die Kachel anfasst: Diese Notizen sieht + NIEMAND ausser dem, der sie schreibt -- auch DogFather nicht. + Stuende die Kachel zwischen Chat und Kalender, waere die erste + Frage „wer liest das mit?", und die Antwort stuende erst auf der + Seite. + + NUR AUF DER TEAM-ADRESSE. Die Seite steht in + GEHOERT_ZU_ADRESSE (crew-adresse.js) und ist auf der + Agenturadresse ein 404 -- auch fuer DogFather, der in beiden + Haeusern arbeitet. Das ist dieselbe Trennung wie bei Chat, + Kalender und Aufgaben: Auf jeder Adresse sieht er nur deren + Bestand. + + TON 46 UND DIE OBERFLAECHE `papier`. Der Ton ist die groesste + freie Luecke der Palette; warum es kein Bernstein wurde, steht + mit allen Messungen in tools/kachelton-regeln.mjs. Das + Papiergefuehl traegt nicht die Farbe, sondern die OBERFLAECHE -- + Linien und ein perforierter Rand, so wie `silber` bei den + Aufgaben und `regenbogen` bei Willkommen. Faellt sie aus (alter + Zwischenspeicher, Kontrastmodus), bleibt die Kachel in ihrer + Farbe und damit unterscheidbar. */ + { gruppe: "Für dich", gruppeUnter: "Deine Seite im Team", + name: "Notizen", unter: "Dein Block – nur du siehst ihn", + zeichen: "notizen", ton: 46, papier: true, + ziel: "notizen.html", szene: "halle" }, /* ==== DIE WISSENS-KACHEL IST HIER RAUS (24.09.2026) ============= Filipe, zur Frage, wohin die neun Dokumente gehören: „diese @@ -5990,6 +6034,9 @@ export function db() { kommt deshalb hierher, nicht in den Bauplan darueber. */ hilfeTabellen(d); supportTabellen(d); + /* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und + kommt deshalb hierher, nicht in den Bauplan darueber. */ + notizTabellen(d); /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen (geaendert_von) und kommen deshalb hierher, nicht in den Bauplan darüber. */ diff --git a/tools/kachelton-entzerren.mjs b/tools/kachelton-entzerren.mjs index c65077a5..8fcc57b8 100644 --- a/tools/kachelton-entzerren.mjs +++ b/tools/kachelton-entzerren.mjs @@ -1,35 +1,55 @@ /* ===================================================================== - NUR DIE FARBEN BEWEGEN, DIE SICH WIRKLICH IN DIE QUERE KOMMEN - (25.09.2026, zweiter Anlauf) + DIE KACHELFARBEN AUSEINANDERZIEHEN -- SO WENIG WIE MOEGLICH + (25./26.09.2026) - Der erste Anlauf hat ALLE 45 Farben neu verteilt (die Rechnung - dazu steht in der Geschichte des Verzeichnisses, Commit d7bb0f7e). - Das Ergebnis war rechnerisch besser und praktisch falsch: Er ist - dabei ueber zwei ausdrueckliche Wuensche gelaufen (das knallrote - #ff1a1a fuer "Vertraulich melden", das Babyblau #a8d8ff fuer - "Support") und hat drei Toene unter die Buntheitsgrenze von 0,12 - gedrueckt. pruef-kachelfarben hat alles fuenf gefunden. + Filipe, 17.09.: „ich will das jede kachel eine andere farbe hat, es + soll keine die gleiche farben haben bitte und auch keine die sich + irgendwie aehnlich sind ... die farben sollen auch richtig geil und + speziell sein." - DIE LEHRE IST NICHT "die Rechnung war falsch", sondern: Eine - Gesamtloesung fuer ein LOKALES Problem bezahlt man mit allem, was - an den nicht betroffenen Stellen schon richtig war. Acht Paare - standen zu eng -- also werden acht Paare auseinandergezogen und - sonst nichts angefasst. + Gemessen lagen acht Paare zwischen 0,015 und 0,054 OKLab-Abstand. + #ff8fb4 gegen #fe8ebe ist mit blossem Auge DIESELBE Farbe. - VORGEHEN. Solange irgendein Paar unter dem Mindestabstand liegt: - das engste nehmen, den beweglicheren der beiden auf die naechste - erlaubte Farbe schieben, die alle Bedingungen haelt. Festgelegte - Toene sind unbeweglich. + --------------------------------------------------------------------- + ZWEI ANLAEUFE HAT DAS GEKOSTET, UND BEIDE FEHLER STEHEN HIER - ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs. Keine Zahl - wird hier abgeschrieben -- genau dafuer gibt es die Datei. + ERSTER ANLAUF: alle 45 Farben neu verteilt. Rechnerisch besser, + praktisch falsch -- er lief ueber zwei ausdrueckliche Wuensche + hinweg (das knallrote #ff1a1a, das Babyblau #a8d8ff) und drueckte + drei Toene unter die Buntheitsgrenze. + + ZWEITER ANLAUF: nur die Kollisionen angefasst, dafuer immer nur + EINEN der beiden Toene bewegt -- den, dessen Ausweichen billiger + war. Das sieht sparsam aus und ist es nicht: Ist die Nachbarschaft + voll, muss dieser eine quer durch den Farbraum. Gemessen wanderte + Ton 1 von einem Tuerkis (#0c86af) auf ein Altrosa (#b4649c), 0,197 + weit. Eine Kachel, die von Tuerkis auf Rosa springt, erkennt + niemand wieder -- und der Zweck der ganzen Uebung war ja, dass man + sie auseinanderhaelt. + + DRITTER ANLAUF, und der steht hier: BEIDE duerfen sich bewegen, und + zwar beide nur ein bisschen. Zwei Toene, die 0,02 auseinanderliegen + und 0,09 brauchen, koennen sich das teilen -- jeder rueckt 0,045, + und beide bleiben, was sie waren. + + Umgesetzt als wachsender Spielraum: Erst darf jeder Ton 0,02 von + seiner Farbe abweichen, dann 0,03, dann 0,04 ... Es wird immer die + KLEINSTE Abweichung genommen, mit der es aufgeht. Wer gar nicht + abweichen muss, bleibt unberuehrt. + + --------------------------------------------------------------------- + ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs + + Keine Zahl wird hier abgeschrieben -- genau dafuer gibt es die + Datei. Und die FESTGELEGTEN Toene stehen ebenfalls dort: Sie werden + nie bewegt, auch nicht um einen Bildpunkt. node tools/kachelton-entzerren.mjs rechnen und zeigen node tools/kachelton-entzerren.mjs schreiben start.css aendern + + Umgebung: ABSTAND=0.09 (Ziel), SCHRITT=3 (Rasterweite des Suchfelds) ===================================================================== */ -import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; import { GRUND, MINDEST_ABSTAND, MINDEST_BUNTHEIT, HOECHST_BUNTHEIT, MINDEST_KONTRAST, FESTGELEGT, @@ -40,251 +60,239 @@ const DATEI = "workspace/assets/css/start.css"; /* Etwas Luft ueber jeder Schwelle -- gerundet wird auf ganze Bildpunkte, und eine Farbe, die genau auf der Grenze steht, faellt beim naechsten Runden darunter. */ -const ZIEL_ABSTAND = MINDEST_ABSTAND + 0.004; +const ZIEL_ABSTAND = Number(process.env.ABSTAND || MINDEST_ABSTAND) + 0.0005; const ZIEL_KONTRAST = MINDEST_KONTRAST + 0.15; const ZIEL_BUNT = MINDEST_BUNTHEIT + 0.004; +const SCHRITT = Number(process.env.SCHRITT || 3); +/* ---------- Farbrechnung ---------------------------------------------- */ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const srgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); -function oklab(h) { - const [r, g, b] = srgb(h).map(lin); - const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); - const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); - const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); +function labVon(r, g, b) { + const rr = lin(r / 255), gg = lin(g / 255), bb = lin(b / 255); + const l = Math.cbrt(0.4122214708 * rr + 0.5363325363 * gg + 0.0514459929 * bb); + const m = Math.cbrt(0.2119034982 * rr + 0.6806995451 * gg + 0.1073969566 * bb); + const s = Math.cbrt(0.0883024619 * rr + 0.2817188376 * gg + 0.6299787005 * bb); return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s]; } -const leuchte = (h) => { const [r, g, b] = srgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; -const kontrast = (h) => (leuchte(h) + 0.05) / (leuchte(GRUND) + 0.05); -const bunt = (h) => { const [, a, b] = oklab(h); return Math.hypot(a, b); }; -const abst = (a, b) => { const x = oklab(a), y = oklab(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); }; +const labHex = (h) => labVon(...[1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16))); +const leuchteVon = (r, g, b) => 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255); +const kontrastVon = (y) => (y + 0.05) / (leuchteVon(...[1, 3, 5].map((i) => parseInt(GRUND.slice(i, i + 2), 16))) + 0.05); +const buntVon = (L) => Math.hypot(L[1], L[2]); +const d2 = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; const hexVon = (r, g, b) => "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); -/* ---------- Die Farben aus der Datei --------------------------------- */ +/* ---------- Die Farben aus der Datei ----------------------------------- */ const css = readFileSync(DATEI, "utf8"); const TOENE = [...css.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)] .map((m) => ({ n: Number(m[1]), alt: m[2].toLowerCase(), hex: m[2].toLowerCase() })); if (TOENE.length < 20) { console.error("Muster passt nicht mehr"); process.exit(3); } +for (const t of TOENE) t.altLab = labHex(t.alt); -/* ---------- Wer darf sich nicht bewegen, wer darf blass sein --------- */ const FEST = new Map(FESTGELEGT.filter((f) => f.hex).map((f) => [f.ton, f.hex.toLowerCase()])); const BLASS_ERLAUBT = new Map(FESTGELEGT.filter((f) => f.blassErlaubt).map((f) => [f.ton, f.blassErlaubt])); for (const t of TOENE) { if (FEST.has(t.n) && t.hex !== FEST.get(t.n)) { - console.log(` Ton ${t.n} steht auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- wird zurueckgesetzt`); + console.log(` Ton ${t.n} stand auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- zurueckgesetzt`); t.hex = FEST.get(t.n); } + t.lab = labHex(t.hex); } -/* ---------- WELCHE TOENE WERDEN UEBERHAUPT BENUTZT? ------------------ +/* ---------- WELCHE TOENE WERDEN BENUTZT? ------------------------------- - Die Buntheitsgrenze gilt nur fuer BENUTZTE Toene -- so steht es in - pruef-kachelfarben, und das ist der Grund, aus dem die Reparatur - ueberhaupt aufgeht: Mit Buntheit >= 0,12 fuer ALLE 45 ist ein - Mindestabstand von 0,09 rechnerisch unmoeglich (gemessene Decke: - 0,0853). Die zwoelf unbenutzten duerfen blasser sein und nehmen - damit Druck aus dem engen Blau-Tuerkis-Bereich. + ALLE FUENFUNDVIERZIG. Und das ist der Befund, an dem der zweite + Anlauf gescheitert ist. - Gefragt wird das Programm selbst, nicht eine Liste. */ -process.env.WORKSPACE_DB = process.env.WORKSPACE_DB - || join(mkdtempSync(join(tmpdir(), "toene-")), "workspace.db"); -const w = await import("../server/workspace.js"); + Wer das PROGRAMM fragt (bereicheFuer() je Rolle), bekommt 33. Die + uebrigen zwoelf sehen dann aus wie freie Reserve -- sie duerfen + blasser werden und weiter wandern. Sie sind keine Reserve: Fuer die + fuenf Rollen des Agenturhauses gibt bereicheFuer() `null` zurueck, + und das heisst „nimm die Liste aus dem Browser". Die steht in + workspace/assets/js/bereiche.js, und dort stehen die Toene 1 bis 22 + und 44 -- genau die zwoelf. + + Gemessen: Ton 1 traegt „Dashboard", Ton 2 „Zahlen", Ton 21 + „Scout-Pipeline", Ton 22 „Team-Lage". Alles Kacheln, die jeden Tag + jemand ansieht. + + Gezaehlt wird deshalb ueber dieselben Quellen, die auch + pruef-kachel-universum liest. Die Frage lautet nicht „wem gibt der + Server eine Kachel", sondern „steht diese Nummer irgendwo an einer + Kachel". */ +const QUELLEN = ["../server/workspace.js", "../workspace/assets/js/bereiche.js", + "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js", + "../workspace/assets/js/befinden.js"]; const BENUTZT = new Set(); -for (const r of ["admin", "hand", "linke", "modi", "gast", "manager", "scout", "creator", "spicy"]) { - for (const haus of ["crew", "agentur"]) { - let k = []; - try { k = [...(w.bereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || []), - ...(w.zusatzBereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || [])]; } - catch { continue; } - for (const kachel of k) if (kachel?.ton != null) BENUTZT.add(Number(kachel.ton)); +for (const q of QUELLEN) { + for (const m of readFileSync(new URL(q, import.meta.url), "utf8").matchAll(/ton:\s*(\d+)/g)) { + BENUTZT.add(Number(m[1])); } } if (BENUTZT.size < 20) { console.error(`nur ${BENUTZT.size} benutzte Toene gefunden`); process.exit(5); } -console.log(`${BENUTZT.size} von ${TOENE.length} Toenen werden benutzt: ${[...BENUTZT].sort((a, b) => a - b).join(", ")}`); -/* Auch die unbenutzten sollen so bunt bleiben, wie es noch passt -- - sonst steht beim naechsten Gebrauch ein blasser Ton bereit und die - Pruefung wird rot, obwohl niemand etwas falsch gemacht hat. */ -const UNBENUTZT_BUNT = Number(process.env.UNBUNT || 0.045); -const buntNoetig = (n) => (BENUTZT.has(n) && !BLASS_ERLAUBT.has(n) ? ZIEL_BUNT - : Math.min(UNBENUTZT_BUNT, bunt(TOENE.find((t) => t.n === n).alt))); +console.log(`${BENUTZT.size} von ${TOENE.length} Toenen werden benutzt`); -/* Der verlangte Mindestabstand kann je Ton hoeher sein: Wer blass sein - darf, muss das beim Abstand ausgleichen (steht so in - pruef-kachelfarben und ist dort begruendet). */ -const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.004); +/* Wer blass sein darf, muss das beim Abstand ausgleichen -- so steht es + mit Messung in kachelton-regeln.mjs. */ +const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.0005); +const BLASS_BIS = new Map(FESTGELEGT.filter((f) => f.blassBis).map((f) => [f.ton, f.blassBis])); +const buntFuer = (n) => (BLASS_BIS.has(n) ? BLASS_BIS.get(n) + 0.002 : ZIEL_BUNT); -/* ---------- Alle erlaubten Farben ------------------------------------ */ -const ALLE = []; -for (let r = 0; r <= 255; r += 2) { - for (let g = 0; g <= 255; g += 2) { - for (let b = 0; b <= 255; b += 2) { - const h = hexVon(r, g, b); - if (kontrast(h) < ZIEL_KONTRAST) continue; - const c = bunt(h); +/* ---------- Das Suchfeld ----------------------------------------------- */ +const KANDIDATEN = []; +for (let r = 0; r <= 255; r += SCHRITT) { + for (let g = 0; g <= 255; g += SCHRITT) { + for (let b = 0; b <= 255; b += SCHRITT) { + const rr = Math.min(255, r), gg = Math.min(255, g), bb = Math.min(255, b); + if (kontrastVon(leuchteVon(rr, gg, bb)) < ZIEL_KONTRAST) continue; + const L = labVon(rr, gg, bb); + const c = buntVon(L); if (c > HOECHST_BUNTHEIT) continue; - ALLE.push({ hex: h, bunt: c }); + KANDIDATEN.push({ hex: hexVon(rr, gg, bb), L, bunt: c }); } } } -const kandidatenFuer = (n) => ALLE.filter((x) => x.bunt >= buntNoetig(n)).map((x) => x.hex); -console.log(`${ALLE.length} erlaubte Farben (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, ` - + `Buntheit bis ${HOECHST_BUNTHEIT}; benutzte Toene ab ${ZIEL_BUNT.toFixed(3)})`); +console.log(`${KANDIDATEN.length} Farben im Suchfeld (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, ` + + `Buntheit bis ${HOECHST_BUNTHEIT}, Raster ${SCHRITT})`); -/* ---------- Was steht zu eng? ---------------------------------------- */ -const engstes = () => { - let m = Infinity, wo = null; +/* ---------- Messen ------------------------------------------------------ */ +function engstes() { + let fehl = 0, klein = Infinity, wo = null; for (let i = 0; i < TOENE.length; i++) { for (let j = i + 1; j < TOENE.length; j++) { const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n)); - const d = abst(TOENE[i].hex, TOENE[j].hex); - if (d < noetig && d - noetig < m) { m = d - noetig; wo = { i, j, d, noetig }; } + const d = Math.sqrt(d2(TOENE[i].lab, TOENE[j].lab)); + if (d < noetig) fehl++; + if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; } } } - return wo; -}; + return { fehl, klein, wo }; +} +const zuBlass = () => TOENE.filter((t) => + buntVon(t.lab) < (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT)); -const naechsterZu = (h, ausser) => { +/** Wie gut steht dieser Ton gerade? (Abstand zum naechsten, in Einheiten + * dessen, was er braucht -- >= 1 heisst „in Ordnung".) */ +function guete(lab, selbst) { let m = Infinity; - for (const t of TOENE) { if (t === ausser) continue; m = Math.min(m, abst(h, t.hex)); } - return m; -}; - -console.log(""); -let bewegt = 0; -for (let runde = 0; runde < 200; runde++) { - const p = engstes(); - if (!p) break; - const a = TOENE[p.i], b = TOENE[p.j]; - - /* Wer wird bewegt? Nicht der Festgelegte. Sonst der, fuer den die - naechste erlaubte Stelle naeher an seinem eigenen Ton liegt. */ - const kandidatFuer = (t) => { - if (FEST.has(t.n)) return null; - let best = null, bestWeg = Infinity; - const noetig = abstandFuer(t.n); - for (const h of kandidatenFuer(t.n)) { - const weg = abst(t.alt, h); - if (weg >= bestWeg) continue; - let ok = true; - for (const u of TOENE) { - if (u === t) continue; - if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; } - } - if (ok) { best = h; bestWeg = weg; } - } - return best ? { hex: best, weg: bestWeg } : null; - }; - const ka = kandidatFuer(a), kb = kandidatFuer(b); - if (!ka && !kb) { - console.error(`Fuer ${a.hex} (Ton ${a.n}) und ${b.hex} (Ton ${b.n}) gibt es keinen Platz.`); - process.exit(4); + for (const u of TOENE) { + if (u === selbst) continue; + const noetig = Math.max(abstandFuer(selbst.n), abstandFuer(u.n)); + const v = Math.sqrt(d2(lab, u.lab)) / noetig; + if (v < m) m = v; } - /* WER WEICHT AUS? ZUERST DER, DEN NIEMAND SIEHT. - - Gemessen: In JEDEM der acht zu engen Paare ist mindestens einer - der beiden Toene gar nicht in Gebrauch -- 33 der 45 tragen eine - Kachel, zwoelf stehen nur bereit. Wer den unbenutzten verschiebt, - loest dasselbe Problem, ohne dass sich fuer irgendjemanden auch - nur eine Kachel veraendert. - - Das ist mehr als Bequemlichkeit: Eine Kachel, die ihre Farbe - wechselt, muss neu gelernt werden. Wer zwei Jahre lang „das - Tuerkise" angesteuert hat, sucht danach. Diese Kosten entstehen - hier schlicht nicht. - - Erst wenn beide benutzt sind (oder beide nicht), entscheidet der - kuerzere Weg. */ - const frei = (t, k) => k && !BENUTZT.has(t.n); - const nimm = frei(a, ka) && !frei(b, kb) ? { t: a, k: ka } - : frei(b, kb) && !frei(a, ka) ? { t: b, k: kb } - : !ka ? { t: b, k: kb } : !kb ? { t: a, k: ka } - : (ka.weg <= kb.weg ? { t: a, k: ka } : { t: b, k: kb }); - console.log(` ${p.d.toFixed(4)} (noetig ${p.noetig.toFixed(3)}) Ton ${a.n} ${a.hex} <-> ` - + `Ton ${b.n} ${b.hex} -> Ton ${nimm.t.n} wandert nach ${nimm.k.hex} ` - + `(${nimm.k.weg.toFixed(3)})`); - nimm.t.hex = nimm.k.hex; - bewegt++; + return m; } -/* ---------- NACHZIEHEN ----------------------------------------------- +/* ---------- Der wachsende Spielraum ------------------------------------- */ +const anfang = engstes(); +console.log(`vorher: kleinster Abstand ${anfang.klein.toFixed(4)}, ` + + `${anfang.fehl} Paare unter dem Soll, ${zuBlass().length} zu blass`); +console.log(""); - Der Durchgang oben arbeitet sich vom engsten Paar nach vorn und - entscheidet dabei immer nur mit dem, was GERADE dasteht. Wer - frueh ausweichen muss, weicht deshalb manchmal weiter aus als - noetig -- die Stelle, die ihm gepasst haette, wird erst zwei - Schritte spaeter frei. +/* Der Spielraum waechst in jeder Runde ein wenig. Er MUSS wachsen, + auch wenn sich gerade etwas bewegt hat: Sonst haengt ein Ton, der + mehr Platz braucht als die anderen, fuer immer fest, waehrend die + uebrigen noch hin und her rutschen -- gemessen blieben so vier zu + blasse Toene stehen, obwohl das Verfahren noch lange nicht am + Ende war. */ +let spielraum = 0.015; +for (let runde = 0; runde < 300; runde++) { + const lage = engstes(); + const blass = zuBlass(); + if (!lage.fehl && !blass.length) break; - Gemessen war das kein Schoenheitsfehler: Ton 10 (#ff6877, ein - Korallrot) landete beim ersten Lauf auf #a86cb4, einem Violett -- - 0,178 entfernt, und niemand haette die Kachel wiedererkannt. + let bewegt = 0; + for (const t of TOENE) { + if (FEST.has(t.n)) continue; + const jetztGuete = Math.min(guete(t.lab, t), 1); + const jetztBunt = buntVon(t.lab); + const bunkOk = jetztBunt >= (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT); + if (jetztGuete >= 1 && bunkOk) continue; // dieser ist fertig - Also am Ende noch einmal von vorn: Jeder bewegte Ton darf so weit - zu seiner alten Farbe zurueck, wie die Bedingungen es zulassen. - Das aendert am Mindestabstand nichts -- es wird nur geprueft, ob - es NAEHER geht. */ + const grenze2 = spielraum * spielraum; + const mindBunt = buntFuer(t.n); + /* IST DIE BUNTHEIT DAS PROBLEM, ZAEHLT DIE JETZIGE LAGE NICHT. + + Hier stand `bestGuete = jetztGuete`. Fuer einen Ton, der weit + genug von allen wegliegt (Guete 1) und nur zu blass ist, konnte + damit KEIN Kandidat gewinnen -- kein Wert ist groesser als 1, + wenn bei 1 gedeckelt wird. Vier Toene blieben deshalb blass + stehen, obwohl das Suchfeld voller passender Farben war. + + Mit -1 als Ausgangswert gewinnt jeder Kandidat, der ueberhaupt + bunt genug ist -- und unter denen wieder der naechste. */ + let best = null, bestGuete = bunkOk ? jetztGuete : -1, bestWeg = 0; + for (const k of KANDIDATEN) { + if (k.bunt < mindBunt) continue; + const weg2 = d2(t.altLab, k.L); + if (weg2 > grenze2) continue; + /* GEDECKELT BEI 1. „Weiter weg als noetig" ist kein Gewinn -- + ohne den Deckel sucht das Verfahren das Maximum und wandert + dafuer quer durch den Farbraum. Mit ihm gewinnt unter allen + Farben, die reichen, die NAECHSTE. */ + const g = Math.min(guete(k.L, t), 1); + if (g > bestGuete + 1e-9 || (g > bestGuete - 1e-9 && best && weg2 < bestWeg)) { + best = k; bestGuete = g; bestWeg = weg2; + } + } + if (best && best.hex !== t.hex) { + t.hex = best.hex; t.lab = best.L; bewegt++; + } + } + spielraum += 0.0015; + if (spielraum > 0.26) { console.error("Spielraum ausgeschoepft -- es geht nicht auf."); break; } +} + +/* ---------- Nachziehen: so weit zurueck wie moeglich --------------------- */ for (let runde = 0; runde < 12; runde++) { let geruckt = false; for (const t of TOENE) { if (FEST.has(t.n) || t.hex === t.alt) continue; - const jetzt = abst(t.alt, t.hex); - const noetig = abstandFuer(t.n); - let best = null, bestWeg = jetzt; - for (const h of kandidatenFuer(t.n)) { - const weg = abst(t.alt, h); - if (weg >= bestWeg) continue; - let ok = true; - for (const u of TOENE) { - if (u === t) continue; - if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; } - } - if (ok) { best = h; bestWeg = weg; } - } - if (best) { - console.log(` nachgezogen: Ton ${t.n} ${t.hex} -> ${best} ` - + `(${jetzt.toFixed(3)} -> ${bestWeg.toFixed(3)} vom alten Ton)`); - t.hex = best; geruckt = true; + const jetztWeg = d2(t.altLab, t.lab); + const mindBunt = buntFuer(t.n); + let best = null, bestWeg = jetztWeg; + for (const k of KANDIDATEN) { + if (k.bunt < mindBunt) continue; + const weg2 = d2(t.altLab, k.L); + if (weg2 >= bestWeg) continue; + if (guete(k.L, t) < 1) continue; + best = k; bestWeg = weg2; } + if (best) { t.hex = best.hex; t.lab = best.L; geruckt = true; } } if (!geruckt) break; } -/* ---------- Nachmessen ------------------------------------------------ */ -let klein = Infinity, wo = null, unterSoll = 0; -for (let i = 0; i < TOENE.length; i++) { - for (let j = i + 1; j < TOENE.length; j++) { - const d = abst(TOENE[i].hex, TOENE[j].hex); - const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n)); - if (d < noetig) unterSoll++; - if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; } - } -} +/* ---------- Ergebnis ----------------------------------------------------- */ +const ende = engstes(); +const blassEnde = zuBlass(); const geaendert = TOENE.filter((t) => t.hex !== t.alt); -const zuBlass = TOENE.filter((t) => BENUTZT.has(t.n) - && bunt(t.hex) < MINDEST_BUNTHEIT && !BLASS_ERLAUBT.has(t.n)); -const zuDunkel = TOENE.filter((t) => kontrast(t.hex) < MINDEST_KONTRAST); const doppelt = TOENE.length - new Set(TOENE.map((t) => t.hex)).size; -console.log(""); -console.log(`geaendert ${geaendert.length} von ${TOENE.length}`); -for (const t of geaendert) { +console.log(`geaendert ${geaendert.length} von ${TOENE.length}`); +for (const t of geaendert.sort((a, b) => d2(b.altLab, b.lab) - d2(a.altLab, a.lab))) { console.log(` Ton ${String(t.n).padStart(2)} ${t.alt} -> ${t.hex} ` - + `verschoben ${abst(t.alt, t.hex).toFixed(3)} Buntheit ${bunt(t.hex).toFixed(3)} ` - + `Kontrast ${kontrast(t.hex).toFixed(2)}:1`); + + `verschoben ${Math.sqrt(d2(t.altLab, t.lab)).toFixed(3)} ` + + `Buntheit ${buntVon(t.lab).toFixed(3)} ` + + `Kontrast ${kontrastVon(leuchteVon(...[1, 3, 5].map((i) => parseInt(t.hex.slice(i, i + 2), 16)))).toFixed(2)}:1`); } -console.log(`kleinster Abstand ${klein.toFixed(4)} (Ton ${wo[0].n} ${wo[0].hex} / Ton ${wo[1].n} ${wo[1].hex})`); -console.log(`unter dem Soll ${unterSoll}`); -console.log(`zu blass ${zuBlass.map((t) => `Ton ${t.n} ${bunt(t.hex).toFixed(3)}`).join(", ") || "keiner"}`); -console.log(`zu dunkel ${zuDunkel.map((t) => `Ton ${t.n}`).join(", ") || "keiner"}`); -console.log(`doppelt ${doppelt}`); +console.log(`kleinster Abstand ${ende.klein.toFixed(4)} ` + + `(Ton ${ende.wo[0].n} ${ende.wo[0].hex} / Ton ${ende.wo[1].n} ${ende.wo[1].hex})`); +console.log(`unter dem Soll ${ende.fehl}`); +console.log(`zu blass ${blassEnde.map((t) => `Ton ${t.n} ${buntVon(t.lab).toFixed(3)}`).join(", ") || "keiner"}`); +console.log(`doppelt ${doppelt}`); +console.log(`groesste Aenderung ${geaendert.length ? Math.max(...geaendert.map((t) => Math.sqrt(d2(t.altLab, t.lab)))).toFixed(3) : "0"}`); for (const [n, h] of FEST) { const t = TOENE.find((x) => x.n === n); - console.log(`festgelegt Ton ${n} ${t.hex} ${t.hex === h ? "(unveraendert)" : "*** ABWEICHUNG ***"}`); + console.log(`festgelegt Ton ${n} ${t.hex} ${t.hex === h ? "(unveraendert)" : "*** ABWEICHUNG ***"}`); } if (process.argv[2] === "schreiben") { - if (unterSoll || zuBlass.length || zuDunkel.length || doppelt + if (ende.fehl || blassEnde.length || doppelt || [...FEST].some(([n, h]) => TOENE.find((x) => x.n === n).hex !== h)) { console.error("\nNICHT geschrieben -- das Ergebnis haelt die Bedingungen nicht."); process.exit(1); diff --git a/tools/kachelton-regeln.mjs b/tools/kachelton-regeln.mjs index 4f0536af..4c985f2f 100644 --- a/tools/kachelton-regeln.mjs +++ b/tools/kachelton-regeln.mjs @@ -108,6 +108,70 @@ const FESTGELEGT = [ Haus ohnehin steht. Die Bedingung dazu steht unten bei der Pruefung und ist echt: Faellt der Abstand unter 0,10, wird diese Zeile rot. */ - { ton: 44, kachel: "Support", hex: "#a8d8ff", blassErlaubt: 0.10, + { ton: 44, kachel: "Support", hex: "#a8d8ff", blassErlaubt: 0.10, blassBis: 0.070, warum: "Babyblau auf Wunsch -- blass, dafuer mit gemessenem Sicherheitsabstand" }, + + /* ==== TUERKIS GEHT NICHT BUNTER (26.09.2026) ===================== + + Ton 1 traegt „Dashboard" im Agenturhaus und liegt bei einer + Buntheit von 0,112 -- acht Tausendstel unter der Hausgrenze. + Aufgefallen ist das erst, als diese Pruefung endlich AUCH die + Toene des Agenturhauses ansah; vorher kannte sie nur 33 von 45. + + DIE NAHELIEGENDE ANTWORT WAERE, IHN BUNTER ZU MACHEN. Gemessen + geht das nicht: Durchsucht man ALLE 16,7 Millionen sRGB-Farben + nach der naechstgelegenen, die gleichzeitig Buntheit >= 0,124, + Kontrast >= 4,65 und 0,09 Abstand zu allen 44 anderen haelt, + kommt #7a75c8 heraus -- ein Blauviolett, 0,113 entfernt. Aus + dem Tuerkis des Dashboards wuerde also Flieder. + + Der Grund ist der Farbraum selbst: Tuerkis erreicht in sRGB nur + eine geringe Buntheit, und dieses schmale Band teilen sich + bereits die Toene 15, 16, 23, 26, 31, 32 und 43. Es ist voll. + + ALSO EINE AUSNAHME MIT ZAHL, KEIN FREIBRIEF: `blassBis` sagt, + wie blass er hoechstens sein darf -- blasser als heute darf er + nicht werden. Und `blassErlaubt` fordert weiterhin den vollen + Abstand ein. Eine Kachel, die ihre Farbfamilie behaelt, ist die + bessere Antwort auf „richtig geil und speziell" als eine, die + acht Tausendstel bunter ist und niemand wiedererkennt. */ + { ton: 1, kachel: "Dashboard", blassErlaubt: 0.09, blassBis: 0.108, + warum: "Tuerkis -- in diesem schmalen Band gibt es keine buntere Farbe mit Abstand" }, + + /* ==== DER SECHSUNDVIERZIGSTE TON (26.09.2026) ==================== + + Filipe: „fuer die modis, rechte und linke hand und dogfather + eine kachel ... sie soll: Notizen, heissen." + + GESUCHT WAR BERNSTEIN -- die Farbe von Papier. Gemessen gibt es + sie nicht mehr: Durchsucht man alle sRGB-Farben im Bernsteinband + (Farbwinkel 55 bis 100 Grad) nach der, die am weitesten von + allen 45 vorhandenen wegliegt, kommt #d0ac48 heraus -- mit + 0,0799 Abstand, also UNTER der Hausgrenze von 0,09. + + Und Platz zu schaffen hilft nicht: Setzt man diesen Bernstein + fest und laesst die Nachbarn ausweichen, geht es zwar auf -- + aber ein warmer Ton muss dafuer das Band verlassen. Gemessen + wanderte Ton 45 (Orange) 0,212 weit ins Magenta; haelt man auch + den fest, trifft es Ton 40 mit 0,246. Das warme Band ist voll, + und jede neue Farbe darin kostet eine vorhandene Kachel ihre + Identitaet. Das ist ein schlechter Tausch. + + DIE FREIEN LUECKEN, alle gemessen: + + Gruen #9cde90 0,0917 <- genommen + Violett #ca50ac 0,0913 + Blau #746afc 0,0890 + Tuerkis #66e2bc 0,0821 + Bernstein #d0ac48 0,0799 + + Gruen passt zur Sache besser, als es zunaechst klingt: Das + Notizblatt im Haus traegt seine Linien in dieser Farbe, und ein + linierter Block in Gruen ist Papier, seit es Papier gibt. + + ER IST FESTGELEGT, weil die Kachel ihre eigene Oberflaeche hat + (siehe `papier` in start.css): Ein Farblauf, der ihn verschiebt, + wuerde Linien und Flaeche auseinanderlaufen lassen. */ + { ton: 46, kachel: "Notizen", hex: "#9cde90", + warum: "die groesste freie Luecke der Palette -- Bernstein gibt es nicht mehr" }, ]; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 9808121c..0057c649 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +