Schritt 4 von 4 -- damit sind beide Wuensche vom 22.08.2026 vollstaendig:
"wenn sie drauf druecken sollen sie auch sehen was ich schon gemacht habe
von dem was im plan war" und "die kunden sollen mir auch nachrichten
hinterlassen koennen".
AUFGABENLISTE IM PROJEKT
Bisher sah der Kunde nur die Phase ("Design . 3/7") -- eine Zahl ohne
Inhalt. Sie beantwortet die eigentliche Frage nicht: WAS ist denn fertig?
Die Liste steht GANZ OBEN, direkt nach dem Projektkopf, noch vor
Zahlungen und Dateien. Die sind wichtig, aber sie sind nicht der Grund
fuer den Klick.
Zwei Entscheidungen praegen die Ansicht:
1. Offene Kundenpunkte stehen in einem EIGENEN Kasten ganz oben ("Das
brauche ich noch von dir"), nicht nur farblich markiert irgendwo
mittendrin. Wer eine lange Liste sieht, liest sie als Bericht ueber
fremde Arbeit und ueberliest seinen eigenen Teil -- genau daraus
entstehen die meisten Verzoegerungen. Ist nichts offen, steht das
auch da: "Von dir wird gerade nichts gebraucht." Eine gute Nachricht
darf ausgesprochen werden.
2. Der Kunde hakt seine eigenen Punkte selbst ab. Nur diese haben einen
Knopf; bei fremden Punkten ist das Zeichen reine Anzeige. Ein Knopf,
der nichts tut, laesst die Seite kaputt wirken. Geprueft: 4 Knoepfe
bei 2 offenen eigenen Punkten (sie erscheinen zweimal), 5 feste.
Grosse Zahl statt Prozent: "2 von 6 erledigt" ist greifbar, "33 %" ist
eine Rechnung, die niemand fuehlt. Der Balken traegt die vier
Prozessfarben -- dieselben wie auf der Ablaufseite und in der Verwaltung.
Der Ton ist bewusst gewaehlt. Der Kunde liest die Liste, wenn er unsicher
ist -- also im Zweifel schon angespannt. "Wir warten auf dich" waere
Druck, "Das brauche ich noch von dir" ist eine Bitte.
POSTFACH AUF DER UEBERSICHT
Bewusst auf der Uebersicht, nicht im Projekt: Wer kein Projekt hat -- oder
eine Frage, die zu keinem gehoert -- konnte vorher gar nicht schreiben und
musste zur E-Mail greifen. Damit war der Verlauf weg, sobald man ihn
brauchte.
Niedrigschwellig: kein Betreff, kein Pflichtfeld, der Projektbezug ist
freiwillig. Wer glaubt, eine Nachricht muesse eine "richtige" Anfrage
sein, schreibt gar nicht erst -- und genau die kurzen Fragen sollen hier
landen. Wird nachgeladen, damit die Uebersicht sofort dasteht.
Projekt- und Aufgabendaten werden gleichzeitig angefordert statt
nacheinander -- sonst waere die Wartezeit die Summe beider Anfragen. Die
Aufgabenliste darf dabei fehlschlagen, ohne die Seite mitzureissen: Wer
wegen einer leeren Liste seine Zahlungen nicht mehr saehe, waere
schlechter dran als vorher.
EIN SICHTBARER FEHLER GEFUNDEN
Auf dem Screenshot stand "Ideen & Aenderungswuensche". Ursache ist
eine Falle mit zwei Wegen: Texte ueber data-i18n landen als HTML in der
Seite, dort ist "&" richtig. Derselbe Text im JavaScript laeuft aber
durch die Absicherung und wird ein zweites Mal kodiert. Derselbe Baustein
ist also je nach Verwendungsort richtig oder falsch.
Das kann man nicht im Kopf behalten -- deshalb neu pruef-texte.mjs mit
ZWEI Pruefungen:
Anzeige: alle 11 Seiten x 5 Sprachen = 55 Kombinationen, sichtbarer
Text darf keine Kodierungsreste enthalten.
Quelltext: welcher Baustein mit "&" wird irgendwo abgesichert
eingesetzt?
Beide sind noetig. Gegengeprueft: Die Anzeigepruefung allein haette den
Fehler NICHT gefunden, weil die Ideen-Karte erst nach einer Anmeldung
erscheint. Die Quelltextpruefung findet ihn ohne Anzeige.
Auch die Quelltextpruefung selbst wurde zweimal gegengeprueft: Zuerst
meldete sie zusaetzlich po_senden ("Senden", voellig harmlos) -- ihre
Blockerkennung lief bis zum naechsten Schluessel und schluckte dabei
einen Kommentar mit "&". Jetzt zaehlt sie geschweifte Klammern. Ein
Fehlalarm im Pruefwerkzeug ist fast so schaedlich wie ein uebersehener
Fehler: Man gewoehnt sich daran, ihn wegzusehen.
GEPRUEFT
32 Pruefungen im Portal (Computer und Handy), alle bestanden. Darunter:
114 Texte in allen fuenf Sprachen vollstaendig, Kundenpunkte stehen vor
der langen Liste, Aufgaben vor den Zahlungen, Abhaken meldet den
richtigen Punkt, alle Kaestchen mindestens 24 px, keine Fehler im
Protokoll. Dazu 55 Seiten-Sprach-Kombinationen ohne Kodierungsreste.
Ein Fehler im Test selbst gefunden und behoben: Die Adressabfrage prueft
jetzt den PFAD statt der ganzen Adresse. Der API-Server heisst
"postfach.dogfather-universe.com" -- ein includes("/postfach") traf den
HOSTNAMEN und damit jede Anfrage, auch die Uebersicht.
Versionsstempel und Cache-Name auf v5.
Co-Authored-By: Claude Opus 5 <[email protected]>
183 lines
7.6 KiB
JavaScript
183 lines
7.6 KiB
JavaScript
/* Sucht doppelt kodierte Zeichen auf ALLEN Webdesign-Seiten in ALLEN
|
|
fünf Sprachen.
|
|
|
|
Anlass: Im Portal stand sichtbar "Ideen &amp; Änderungswünsche".
|
|
Ursache ist eine Falle, die sich leicht wiederholt — es gibt zwei
|
|
Wege, wie ein Text in die Seite kommt:
|
|
|
|
data-i18n="schluessel" -> wird als HTML eingesetzt.
|
|
Dort ist "&" RICHTIG.
|
|
s(window.WD.t("schluessel")) -> wird abgesichert eingesetzt.
|
|
Dort wird "&" ein zweites Mal
|
|
kodiert und erscheint wörtlich.
|
|
|
|
Derselbe Textbaustein kann also je nach Verwendungsort richtig oder
|
|
falsch sein. Das kann man nicht zuverlässig im Kopf behalten —
|
|
deshalb prüft es der Browser: Was am Ende auf dem Bildschirm steht,
|
|
darf keine Kodierungsreste enthalten.
|
|
*/
|
|
import { chromium } from "playwright";
|
|
import { createServer } from "node:http";
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
import { join, extname } from "node:path";
|
|
|
|
const W = process.cwd();
|
|
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
|
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
|
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
|
|
const srv = createServer((q, r) => {
|
|
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
|
|
if (existsSync(p) && !p.endsWith("/")) {
|
|
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
|
|
return r.end(readFileSync(p));
|
|
}
|
|
r.writeHead(404).end("");
|
|
});
|
|
await new Promise((r) => srv.listen(4327, r));
|
|
|
|
const SEITEN = readdirSync(join(W, "webdesign"))
|
|
.filter((f) => f.endsWith(".html"));
|
|
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
|
/* Reste, die eine doppelte Kodierung hinterlässt. Sie dürfen im
|
|
sichtbaren Text nie vorkommen. */
|
|
const RESTE = ["&", "<", ">", """, "'", " "];
|
|
|
|
/* =====================================================================
|
|
TEIL 1 — Prüfung am QUELLTEXT
|
|
|
|
Die Browserprüfung weiter unten sieht nur, was gerade auf dem
|
|
Bildschirm steht. Grosse Teile des Portals und der Verwaltung
|
|
erscheinen aber erst NACH einer Anmeldung — genau dort sass der
|
|
Fehler, den es zu finden galt ("Ideen &amp; Änderungswünsche").
|
|
Gegengeprüft: Die Browserprüfung allein hätte ihn NICHT gefunden.
|
|
|
|
Diese Prüfung braucht keine Anzeige. Sie fragt nur:
|
|
Gibt es einen Textbaustein mit "&", der irgendwo durch die
|
|
Absicherung s(...) geschickt wird? Dann ist er dort doppelt kodiert.
|
|
===================================================================== */
|
|
function quelltextPruefen() {
|
|
const i18nDateien = readdirSync(join(W, "assets", "js"))
|
|
.filter((f) => f.startsWith("i18n-wd-") && f.endsWith(".js"));
|
|
|
|
// Alle Schlüssel einsammeln, deren Text ein "&" enthält.
|
|
const verdaechtig = new Map(); // schluessel -> datei
|
|
for (const datei of i18nDateien) {
|
|
const inhalt = readFileSync(join(W, "assets", "js", datei), "utf-8");
|
|
/* Der Block eines Schlüssels endet dort, wo seine geschweifte
|
|
Klammer wieder zugeht — nicht erst beim nächsten Schlüssel.
|
|
|
|
Der Unterschied ist nicht theoretisch: Zwischen zwei Schlüsseln
|
|
stehen Kommentare, und einer davon lautet "Ideen & Änderungs-
|
|
wünsche". Mit der Regel "bis zum nächsten Schlüssel" hat der
|
|
VORHERIGE Schlüssel (po_senden, ein schlichtes "Senden") diesen
|
|
Kommentar mitgeschluckt und wurde fälschlich gemeldet. Ein
|
|
Fehlalarm im Prüfwerkzeug ist fast so schädlich wie ein
|
|
übersehener Fehler — man gewöhnt sich daran, ihn wegzusehen. */
|
|
const zeilen = inhalt.split("\n");
|
|
let aktuell = null;
|
|
let text = "";
|
|
let tiefe = 0;
|
|
const abschliessen = () => {
|
|
if (aktuell && text.includes("&")) verdaechtig.set(aktuell, datei);
|
|
aktuell = null; text = ""; tiefe = 0;
|
|
};
|
|
for (const z of zeilen) {
|
|
if (!aktuell) {
|
|
const m = z.match(/^ ([a-zA-Z0-9_]+):\s*\{/);
|
|
if (!m) continue;
|
|
aktuell = m[1]; text = z;
|
|
tiefe = (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length;
|
|
if (tiefe <= 0) abschliessen();
|
|
} else {
|
|
text += "\n" + z;
|
|
tiefe += (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length;
|
|
if (tiefe <= 0) abschliessen();
|
|
}
|
|
}
|
|
abschliessen();
|
|
}
|
|
|
|
// Wo werden sie abgesichert eingesetzt?
|
|
const quellen = [
|
|
...readdirSync(join(W, "assets", "js")).filter((f) => f.startsWith("wd-") && f.endsWith(".js"))
|
|
.map((f) => ["assets/js/" + f, readFileSync(join(W, "assets", "js", f), "utf-8")]),
|
|
...SEITEN.map((f) => ["webdesign/" + f, readFileSync(join(W, "webdesign", f), "utf-8")]),
|
|
];
|
|
|
|
const funde = [];
|
|
for (const [schluessel, datei] of verdaechtig) {
|
|
// s(window.WD.t("key")) oder s(t("key"))
|
|
const muster = new RegExp(
|
|
"\\bs\\(\\s*(?:window\\.WD\\.)?t\\(\\s*[\"']" + schluessel + "[\"']", "");
|
|
for (const [name, inhalt] of quellen) {
|
|
if (muster.test(inhalt)) funde.push({ schluessel, datei, benutztIn: name });
|
|
}
|
|
}
|
|
|
|
console.log(`Quelltext: ${verdaechtig.size} Textbausteine enthalten "&".`);
|
|
if (funde.length) {
|
|
for (const f of funde) {
|
|
console.log(` FEHL ${f.schluessel} (${f.datei}) wird in ${f.benutztIn} abgesichert eingesetzt`);
|
|
console.log(` -> dort muss ein echtes "&" stehen, sonst erscheint "&" wörtlich.`);
|
|
}
|
|
} else {
|
|
console.log(" ok keiner davon wird durch die Absicherung geschickt.\n");
|
|
}
|
|
return funde.length;
|
|
}
|
|
|
|
let quellFehler = quelltextPruefen();
|
|
|
|
const browser = await chromium.launch();
|
|
const seite = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
// Die API gibt es hier nicht -- leere, gültige Antworten genügen.
|
|
await seite.route("**/webdesign/admin/**", (r) => r.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], verlaeufe: [] } }));
|
|
await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } }));
|
|
await seite.route("**/webdesign/api-ausweis", (r) => r.fulfill({ json: {} }));
|
|
await seite.addInitScript(() => { sessionStorage.setItem("wd-sitzung-aktiv", "1"); });
|
|
|
|
let treffer = 0;
|
|
let geprueft = 0;
|
|
|
|
for (const datei of SEITEN) {
|
|
for (const sprache of SPRACHEN) {
|
|
await seite.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sprache)})`);
|
|
await seite.goto("http://127.0.0.1:4327/webdesign/" + datei);
|
|
await seite.waitForTimeout(320);
|
|
geprueft++;
|
|
|
|
const gefunden = await seite.evaluate((reste) => {
|
|
const raus = [];
|
|
const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
let n;
|
|
while ((n = lauf.nextNode())) {
|
|
const eltern = n.parentElement;
|
|
if (!eltern || eltern.closest("script, style, code, pre")) continue;
|
|
const t = n.nodeValue;
|
|
for (const r of reste) {
|
|
if (t.includes(r)) {
|
|
raus.push({ rest: r, text: t.trim().slice(0, 70), wo: eltern.tagName.toLowerCase() });
|
|
}
|
|
}
|
|
}
|
|
return raus;
|
|
}, RESTE);
|
|
|
|
if (gefunden.length) {
|
|
treffer += gefunden.length;
|
|
for (const g of gefunden) {
|
|
console.log(` FEHL ${datei} [${sprache}] <${g.wo}> ${g.rest} in: "${g.text}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
srv.close();
|
|
console.log(`\n${geprueft} Seiten-Sprach-Kombinationen geprüft (${SEITEN.length} Seiten x ${SPRACHEN.length} Sprachen).`);
|
|
const gesamt = treffer + quellFehler;
|
|
console.log(gesamt === 0
|
|
? "===== keine doppelt kodierten Zeichen gefunden (Quelltext + Anzeige) ====="
|
|
: `===== ${gesamt} Fundstellen (${quellFehler} im Quelltext, ${treffer} in der Anzeige) =====`);
|
|
process.exitCode = gesamt ? 1 : 0;
|