From 5f52ef54e85896c2e459ef02ea9f711e1e6f030c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:19:22 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Ersatz-Tabelle=20und=20W?= =?UTF-8?q?ochenplan-Hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ersatzHtml liefert die schlichte Wochentabelle eines Plans als reines ASCII (Entities, maskierter Planname), wochenplanHash den 16-stelligen sha1 der Tage des Hauptplans. Dafuer wird modusUndFenster aus der Zeitlogik exportiert. Co-Authored-By: Claude Opus 5.5 --- server/services/zeiten/ersatz.js | 116 ++++++++++++ server/services/zeiten/zeitlogik.js | 2 +- test/zeitenErsatz.test.js | 273 ++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 server/services/zeiten/ersatz.js create mode 100644 test/zeitenErsatz.test.js diff --git a/server/services/zeiten/ersatz.js b/server/services/zeiten/ersatz.js new file mode 100644 index 0000000..40dcbdf --- /dev/null +++ b/server/services/zeiten/ersatz.js @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto' +import { hauptplan, modusUndFenster } from './zeitlogik.js' + +/** + * Oeffnungszeiten live: statische Ersatz-Tabelle und Wochenplan-Hash (Spec 4.5, rein). + * Kein Appwrite, keine Config, keine Uhr. Die Tabelle steht in den Platzhaltern der Kundenseite + * (ohne JavaScript und nach Abschaltung der Funktion) und zeigt nur den Wochenplan eines Plans, + * keine Ausnahmen, Feiertage oder Hinweise. + * + * Ausgabe ist reines ASCII: Umlaute und ss-Ligatur als benannte Entities (ä ... ß), Gedankenstrich + * als –, jedes andere Nicht-ASCII-Zeichen als numerische Entity (&#NNNN;). Kundentext (Planname) wird + * zuerst HTML-maskiert (& < > " '). + * + * Mitternacht: Ein Fensterende '24:00' steht in der Tabelle als '00:00' ("18:00-00:00"), nie als "24:00". + * Ein einziges Fenster 00:00-24:00 gilt als "rund um die Uhr". + */ + +const WOCHENTAGE = Object.freeze(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']) +const GEDANKENSTRICH = '–' +const STANDARD_UEBERSCHRIFT = 'Öffnungszeiten' + +const ENTITIES = new Map([ + ['&', '&'], + ['<', '<'], + ['>', '>'], + ['"', '"'], + ["'", '''], + ['\u00e4', 'ä'], + ['\u00f6', 'ö'], + ['\u00fc', 'ü'], + ['\u00c4', 'Ä'], + ['\u00d6', 'Ö'], + ['\u00dc', 'Ü'], + ['\u00df', 'ß'], +]) + +/** + * Kundentext -> sicheres ASCII-HTML. Steuerzeichen (auch C1) werden zu Leerzeichen (der HTML-Parser + * wuerde numerische Verweise darauf umdeuten), einzelne Surrogate zu U+FFFD. + */ +function asciiText(roh) { + let aus = '' + for (const zeichen of String(roh)) { + const cp = zeichen.codePointAt(0) + if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) aus += ' ' + else if (ENTITIES.has(zeichen)) aus += ENTITIES.get(zeichen) + else if (cp < 0x7f) aus += zeichen + else if (cp >= 0xd800 && cp <= 0xdfff) aus += '�' + else aus += `&#${cp};` + } + return aus +} + +/** Text eines Tages; modus/fenster kommen bereits bereinigt aus modusUndFenster (gueltige 'HH:MM'-Fenster). */ +function tagText({ modus, fenster }) { + if (modus === 'rund') return 'rund um die Uhr' + if (modus === 'vereinbarung') return 'nach Vereinbarung' + if (modus !== 'zeiten' || fenster.length === 0) return 'geschlossen' + if (fenster.length === 1 && fenster[0][0] === '00:00' && fenster[0][1] === '24:00') return 'rund um die Uhr' + return fenster + .map(([von, bis]) => `${von}${GEDANKENSTRICH}${bis === '24:00' ? '00:00' : bis}`) + .join(', ') +} + +/** + * Schlichte Wochentabelle des Plans: mit ` + }) + return [ + '
= Planname (leer => "Oeffnungszeiten") + * und einer Zeile je Wochentag Montag bis Sonntag (
+ ). Ohne 'plan' gilt der Hauptplan; + * gibt es keinen Plan, ist das Ergebnis '' (der Aufrufer entscheidet, z. B. 409). Kaputte Tageseintraege + * (unbekannter Modus, fehlende Tage, unlesbare Fenster) zaehlen als geschlossen. + */ +export function ersatzHtml(daten, plan) { + const p = plan && typeof plan === 'object' ? plan : hauptplan(daten) + if (!p) return '' + const name = typeof p.name === 'string' ? p.name.trim() : '' + const zeilen = WOCHENTAGE.map((tag, i) => { + const text = tagText(modusUndFenster(p.tage?.[i + 1])) + return `
${tag}${text}
', + ``, + '', + ...zeilen, + '', + '
${name ? asciiText(name) : STANDARD_UEBERSCHRIFT}
', + ].join('\n') +} + +/** + * JSON mit rekursiv sortierten Objektschluesseln (Arrays behalten ihre Reihenfolge), sonst wie JSON.stringify: + * undefined-Werte in Objekten entfallen, der Gesamtwert undefined ergibt undefined. + */ +function stabilesJson(wert) { + if (Array.isArray(wert)) return `[${wert.map((w) => stabilesJson(w) ?? 'null').join(',')}]` + if (wert && typeof wert === 'object') { + const teile = [] + for (const schluessel of Object.keys(wert).sort()) { + const v = stabilesJson(wert[schluessel]) + if (v !== undefined) teile.push(`${JSON.stringify(schluessel)}:${v}`) + } + return `{${teile.join(',')}}` + } + return JSON.stringify(wert) +} + +/** + * Hash des Wochenplans des Hauptplans: sha1 ueber das stabile JSON von 'tage', die ersten 16 Hex-Zeichen. + * Aendert sich nur mit 'tage' des Hauptplans (nicht mit Name, Ausnahmen, Hinweisen, Feiertagen, Land oder anderen + * Plaenen); '' ohne Hauptplan. Hauptplan ohne 'tage' ergibt den Hash von 'null'. + */ +export function wochenplanHash(daten) { + const haupt = hauptplan(daten) + if (!haupt) return '' + const json = stabilesJson(haupt.tage) ?? 'null' + return createHash('sha1').update(json).digest('hex').slice(0, 16) +} diff --git a/server/services/zeiten/zeitlogik.js b/server/services/zeiten/zeitlogik.js index 0a96422..2f0aeb9 100644 --- a/server/services/zeiten/zeitlogik.js +++ b/server/services/zeiten/zeitlogik.js @@ -99,7 +99,7 @@ function sauberFenster(roh) { } /** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */ -function modusUndFenster(eintrag) { +export function modusUndFenster(eintrag) { const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu' if (modus !== 'zeiten') return { modus, fenster: [] } const fenster = sauberFenster(eintrag.fenster) diff --git a/test/zeitenErsatz.test.js b/test/zeitenErsatz.test.js new file mode 100644 index 0000000..9fdd07e --- /dev/null +++ b/test/zeitenErsatz.test.js @@ -0,0 +1,273 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { ersatzHtml, wochenplanHash } from '../server/services/zeiten/ersatz.js' +import { hauptplan } from '../server/services/zeiten/zeitlogik.js' + +// Fixture wie in zeitenZeitlogik.test.js: Wochentage 1..7 (Mo..So). +// Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster, 'rund' / 'vereinbarung' = Modus, fehlt = zu. +function plan(woche = {}, extra = {}) { + const tage = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + if (!w) tage[d] = { modus: 'zu', fenster: [] } + else if (typeof w === 'string') tage[d] = { modus: w, fenster: [] } + else tage[d] = { modus: 'zeiten', fenster: Array.isArray(w[0]) ? w : [w] } + } + return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage, ...extra } +} + +function daten({ plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {}, hinweise = [], land = 'RP' } = {}) { + return { land, plaene, feiertage, ausnahmen, hinweise, kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } } +} + +function tiefEinfrieren(x) { + if (x && typeof x === 'object' && !Object.isFrozen(x)) { + Object.freeze(x) + for (const v of Object.values(x)) tiefEinfrieren(v) + } + return x +} + +const ASCII = /^[\x00-\x7F]*$/ +const WOCHE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] + +/** Zeilen der Tabelle: [['Montag', '09:00–18:00'], ...] */ +function zeilen(html) { + return [...html.matchAll(/([^<]*)<\/th>([^<]*)<\/td><\/tr>/g)].map((m) => [m[1], m[2]]) +} + +// ------------------------------------------------------------------ ersatzHtml + +test('Ersatz-HTML ist reines ASCII', () => { + const p = plan( + { 1: ['09:00', '18:00'], 2: [['08:00', '12:00'], ['14:00', '18:30']], 3: 'rund', 4: 'vereinbarung', 5: ['18:00', '24:00'], 6: ['22:00', '02:00'] }, + { name: 'Öffnungszeiten für Müller & Söhne – groß \u{1F600}' }, + ) + const html = ersatzHtml(daten({ plaene: [p] }), p) + assert.equal(typeof html, 'string') + assert.ok(html.length > 0) + assert.match(html, ASCII) +}) + +test('Ersatz-HTML zeigt alle Wochentage in der Reihenfolge Montag bis Sonntag', () => { + const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '14:00'] }) + const html = ersatzHtml(daten({ plaene: [p] }), p) + assert.match(html, /^/) + assert.match(html, /<\/table>\s*$/) + const z = zeilen(html) + assert.deepEqual(z.map((r) => r[0]), WOCHE) + assert.ok(html.includes('09:00–18:00')) + assert.ok(html.includes('10:00–14:00')) + assert.equal(z[1][1], 'geschlossen') + assert.equal(z[6][1], 'geschlossen') + for (const tag of WOCHE) { + assert.ok(html.indexOf(``) >= 0, `${tag} fehlt`) + } +}) + +test('Ersatz-HTML: Caption ist der Planname, bei leerem Namen "Öffnungszeiten"', () => { + const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Sommer' }) + assert.match(ersatzHtml(daten({ plaene: [p] }), p), /')) + assert.ok(!html.includes('')) + + const boese = plan({ 1: ['09:00', '18:00'] }, { name: '"> & \'x\'' }) + const h2 = ersatzHtml(daten({ plaene: [boese] }), boese) + assert.ok(!h2.includes(' { + const p = plan({ 1: ['09:00', '18:00'] }, { name: 'Bäckerei Müller – Öl ÄÜß é \u{1F600}' }) + const html = ersatzHtml(daten({ plaene: [p] }), p) + assert.ok(html.includes('')) + assert.match(html, ASCII) +}) + +test('Ersatz-HTML: Steuerzeichen und einzelne Surrogate im Namen brechen nichts', () => { + const p = plan({ 1: ['09:00', '18:00'] }, { name: 'a\nb\tc\u0000d\u0085e\ud800f' }) + const html = ersatzHtml(daten({ plaene: [p] }), p) + assert.match(html, ASCII) + assert.doesNotMatch(html, /[\x00-\x08\x0b-\x1f\x7f]/) + assert.ok(html.includes('')) +}) + +test('Ersatz-HTML: Modi als Text, nie "24:00", Mitternacht als 00:00', () => { + const p = plan({ + 1: 'rund', + 2: 'vereinbarung', + 3: ['18:00', '24:00'], + 4: ['22:00', '02:00'], + 5: [['08:00', '12:00'], ['14:00', '18:30']], + 6: ['00:00', '12:00'], + 7: ['00:00', '24:00'], + }) + const html = ersatzHtml(daten({ plaene: [p] }), p) + const z = zeilen(html) + assert.equal(z[0][1], 'rund um die Uhr') + assert.equal(z[1][1], 'nach Vereinbarung') + assert.equal(z[2][1], '18:00–00:00') + assert.equal(z[3][1], '22:00–02:00') + assert.equal(z[4][1], '08:00–12:00, 14:00–18:30') + assert.equal(z[5][1], '00:00–12:00') + assert.equal(z[6][1], 'rund um die Uhr', 'ein Fenster 00:00-24:00 ist rund um die Uhr') + assert.ok(!html.includes('24:00')) + assert.ok(!html.includes('24 Uhr')) +}) + +test('Ersatz-HTML: Fenster erscheinen nach Beginn sortiert', () => { + const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] }) + assert.equal(zeilen(ersatzHtml(daten({ plaene: [p] }), p))[0][1], '08:00–12:00, 14:00–18:00') +}) + +test('Ersatz-HTML: kaputte Tageseinträge zählen als geschlossen und werfen nicht', () => { + const p = plan({ 1: ['09:00', '18:00'] }) + p.tage[2] = { modus: 'irgendwas', fenster: [['09:00', '18:00']] } + p.tage[3] = { modus: 'zeiten', fenster: [] } + p.tage[4] = { modus: 'zeiten', fenster: [['9:00', 'abc'], ['10:00', '10:00'], ['25:00', '26:00']] } + delete p.tage[5] + p.tage[6] = null + p.tage[7] = 'x' + const html = ersatzHtml(daten({ plaene: [p] }), p) + const z = zeilen(html) + assert.equal(z.length, 7) + assert.equal(z[0][1], '09:00–18:00') + for (let i = 1; i < 7; i += 1) assert.equal(z[i][1], 'geschlossen', WOCHE[i]) + const ohneTage = { id: 'x', kurz: 'x', name: 'Ohne', haupt: true } + const leer = zeilen(ersatzHtml(daten({ plaene: [ohneTage] }), ohneTage)) + assert.equal(leer.length, 7) + assert.ok(leer.every((r) => r[1] === 'geschlossen')) +}) + +test('Ersatz-HTML zeigt nur den Wochenplan, keine Ausnahmen, Feiertage oder Hinweise', () => { + const p = plan({ 1: ['09:00', '18:00'] }) + const ohne = ersatzHtml(daten({ plaene: [p] }), p) + const mit = ersatzHtml( + daten({ + plaene: [p], + ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }], + feiertage: { '2026-10-12': { modus: 'zeiten', fenster: [['10:00', '12:00']] } }, + hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }], + }), + p, + ) + assert.equal(mit, ohne) + assert.ok(!mit.includes('Urlaub')) +}) + +test('Ersatz-HTML nutzt den übergebenen Plan, ohne Plan den Hauptplan, ohne beides den leeren Text', () => { + const haupt = plan({ 1: ['09:00', '18:00'] }, { name: 'Haupt' }) + const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) + const d = daten({ plaene: [haupt, sommer] }) + assert.match(ersatzHtml(d, sommer), /
${tag}
Sommer<\/caption>/) + for (const name of ['', ' ', null, undefined, 42]) { + const q = plan({ 1: ['09:00', '18:00'] }, { name }) + const html = ersatzHtml(daten({ plaene: [q] }), q) + assert.match(html, /Öffnungszeiten<\/caption>/, `Name ${String(name)}`) + assert.match(html, ASCII) + } +}) + +test('Ersatz-HTML: Kundentext wird maskiert', () => { + const p = plan({ 1: ['09:00', '18:00'] }, { name: '' }) + const html = ersatzHtml(daten({ plaene: [p] }), p) + assert.ok(html.includes('<b>Bäckerei Müller – Öl ÄÜß é 😀a b c d e�fSommer<\/caption>[\s\S]*10:00–20:00/) + assert.match(ersatzHtml(d, haupt), /Haupt<\/caption>[\s\S]*09:00–18:00/) + assert.equal(ersatzHtml(d, null), ersatzHtml(d, haupt)) + assert.equal(ersatzHtml(d, undefined), ersatzHtml(d, haupt)) + assert.equal(ersatzHtml(daten({ plaene: [] }), null), '') + assert.equal(ersatzHtml(null, null), '') + assert.equal(ersatzHtml({}, undefined), '') +}) + +test('Ersatz-HTML ist deterministisch und verändert die Eingabe nicht', () => { + const p = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']], 2: 'rund' }) + const d = tiefEinfrieren(daten({ plaene: [p] })) + const a = ersatzHtml(d, p) + const b = ersatzHtml(d, p) + assert.equal(a, b) + assert.deepEqual(p.tage[1].fenster, [['14:00', '18:00'], ['08:00', '12:00']]) +}) + +// ------------------------------------------------------------------ wochenplanHash + +test('Hash hat 16 Hex-Zeichen und ist die sha1 des stabilen JSON der Tage des Hauptplans', () => { + const p = { id: 'p1', kurz: 's', name: 'S', haupt: true, tage: { 1: { modus: 'zu', fenster: [] } } } + const h = wochenplanHash(daten({ plaene: [p] })) + assert.match(h, /^[0-9a-f]{16}$/) + const erwartet = createHash('sha1').update('{"1":{"fenster":[],"modus":"zu"}}').digest('hex').slice(0, 16) + assert.equal(h, erwartet) +}) + +test('Hash ändert sich nur mit dem Wochenplan des Hauptplans', () => { + const basis = daten({ plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] })] }) + const h = wochenplanHash(basis) + assert.match(h, /^[0-9a-f]{16}$/) + + // Ausnahme, Hinweis, Feiertag, Kontakt, Land, Planname: gleich + const anders = daten({ + land: 'BY', + plaene: [plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] }, { name: 'Umbenannt', kurz: 'neu' })], + ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-09', text: 'Urlaub', modus: 'zu', fenster: [], plan: null }], + feiertage: { '2026-10-12': { modus: 'rund', fenster: [] } }, + hinweise: [{ id: 'h', art: 'info', text: 'Hallo', plan: null }], + }) + assert.equal(wochenplanHash(anders), h) + + // Montag geändert: anders + const montag = daten({ plaene: [plan({ 1: ['09:00', '17:00'], 2: ['09:00', '18:00'] })] }) + assert.notEqual(wochenplanHash(montag), h) + // Modus geändert: anders + assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: 'rund', 2: ['09:00', '18:00'] })] })), h) + // zweites Fenster dazu: anders + assert.notEqual(wochenplanHash(daten({ plaene: [plan({ 1: [['09:00', '12:00'], ['13:00', '18:00']], 2: ['09:00', '18:00'] })] })), h) +}) + +test('Hash: Änderungen an einem anderen Plan ändern ihn nicht, ein anderer Hauptplan schon', () => { + const haupt = plan({ 1: ['09:00', '18:00'] }) + const sommer = plan({ 1: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) + const sommer2 = plan({ 3: ['10:00', '20:00'] }, { id: 'p2', kurz: 'sommer', name: 'Sommer', haupt: false }) + const h = wochenplanHash(daten({ plaene: [haupt, sommer] })) + assert.equal(wochenplanHash(daten({ plaene: [haupt, sommer2] })), h) + assert.equal(wochenplanHash(daten({ plaene: [sommer, haupt] })), h, 'Reihenfolge der Pläne egal') + const nachBefoerderung = [{ ...haupt, haupt: false }, { ...sommer, haupt: true }] + assert.notEqual(wochenplanHash(daten({ plaene: nachBefoerderung })), h) + assert.equal(hauptplan(daten({ plaene: nachBefoerderung })).kurz, 'sommer') +}) + +test('Hash hängt nicht von der Schlüsselreihenfolge ab, wohl aber von der Fensterreihenfolge', () => { + const a = { id: 'p', kurz: 'p', name: 'P', haupt: true, tage: { 2: { modus: 'zeiten', fenster: [['09:00', '12:00']] }, 1: { fenster: [], modus: 'zu' } } } + const b = { haupt: true, name: 'P', kurz: 'p', id: 'p', tage: { 1: { modus: 'zu', fenster: [] }, 2: { fenster: [['09:00', '12:00']], modus: 'zeiten' } } } + assert.equal(wochenplanHash(daten({ plaene: [a] })), wochenplanHash(daten({ plaene: [b] }))) + const c = plan({ 1: [['08:00', '12:00'], ['14:00', '18:00']] }) + const d = plan({ 1: [['14:00', '18:00'], ['08:00', '12:00']] }) + assert.notEqual(wochenplanHash(daten({ plaene: [c] })), wochenplanHash(daten({ plaene: [d] }))) +}) + +test('Hash ist leer ohne Hauptplan und wirft bei kaputten Daten nicht', () => { + assert.equal(wochenplanHash(daten({ plaene: [] })), '') + assert.equal(wochenplanHash(daten({ plaene: [plan({ 1: ['09:00', '18:00'] }, { haupt: false })] })), '') + assert.equal(wochenplanHash(null), '') + assert.equal(wochenplanHash(undefined), '') + assert.equal(wochenplanHash({}), '') + assert.equal(wochenplanHash({ plaene: 'x' }), '') + // Hauptplan ohne Tage: stabiler Wert, kein Wurf, anders als mit Tagen + const ohneTage = { id: 'p', kurz: 'p', name: 'P', haupt: true } + assert.match(wochenplanHash(daten({ plaene: [ohneTage] })), /^[0-9a-f]{16}$/) + assert.notEqual(wochenplanHash(daten({ plaene: [ohneTage] })), wochenplanHash(daten())) +}) + +test('Hash verändert die Eingabe nicht', () => { + const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })) + assert.equal(wochenplanHash(d), wochenplanHash(d)) +}) + +test('ersatz.js ist rein: nur node:crypto und zeitlogik.js, kein Appwrite, keine Config, keine Uhr', () => { + const quelle = readFileSync(new URL('../server/services/zeiten/ersatz.js', import.meta.url), 'utf8') + const importe = [...quelle.matchAll(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm)].map((m) => m[1]) + assert.deepEqual(importe.sort(), ['./zeitlogik.js', 'node:crypto']) + assert.doesNotMatch(quelle, /Date\.now|new Date\(/) + assert.match(quelle, /^[\x00-\x7F]*$/, 'Quelltext selbst nur ASCII') +})