From 679f99659786b155b58fbe8ab6b5a92161933985 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:26:27 +0000 Subject: [PATCH] Bausteine: slugify mit Umlauten, deutsche Zeit, Sperren Co-Authored-By: Claude Opus 5.5 --- server/services/berlinTime.js | 22 ++++++++++++++++++++ server/services/keyedLock.js | 36 ++++++++++++++++++++++++++++++++ server/services/slug.js | 22 ++++++++++++++++++++ test/berlinTime.test.js | 7 +++++++ test/keyedLock.test.js | 39 +++++++++++++++++++++++++++++++++++ test/slug.test.js | 18 ++++++++++++++++ 6 files changed, 144 insertions(+) create mode 100644 server/services/berlinTime.js create mode 100644 server/services/keyedLock.js create mode 100644 server/services/slug.js create mode 100644 test/berlinTime.test.js create mode 100644 test/keyedLock.test.js create mode 100644 test/slug.test.js diff --git a/server/services/berlinTime.js b/server/services/berlinTime.js new file mode 100644 index 0000000..1d6dc0f --- /dev/null +++ b/server/services/berlinTime.js @@ -0,0 +1,22 @@ +/** + * Datum und Uhrzeit in der Berliner Zeitzone (beruecksichtigt MESZ/MEZ automatisch), + * fuer Anzeige und Dateinamen. date: 'tt.mm.jjjj', time: 'hhmm' (24h, '0030' statt '2430'). + */ +export function berlinDateTime(date = new Date()) { + const parts = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + + const value = (type) => parts.find((part) => part.type === type)?.value + + return { + date: `${value('day')}.${value('month')}.${value('year')}`, + time: `${value('hour')}${value('minute')}`, + } +} diff --git a/server/services/keyedLock.js b/server/services/keyedLock.js new file mode 100644 index 0000000..d350bf1 --- /dev/null +++ b/server/services/keyedLock.js @@ -0,0 +1,36 @@ +/** + * Sperren je Schluessel (z. B. Kundennummer, Ticket-Id), damit parallele Anfragen fuer + * dieselbe Ressource nacheinander laufen, waehrend Anfragen zu anderen Schluesseln parallel + * bleiben. Ein Aufruf kann mehrere Schluessel auf einmal sperren (alle muessen frei werden). + * waitMs begrenzt, wie lange auf die aktuellen Inhaber gewartet wird (Default: unbegrenzt); + * nach Ablauf laeuft fn trotzdem los, ohne weiter zu warten. + * + * Umsetzung: je Schluessel eine Promise-Kette (das "tail") in einer modulweiten Map. + */ +const tailsByKey = new Map() + +// setTimeout(fn, Infinity) wuerde Node zu 1 ms clampen (samt Warnung) - hier bewusst +// eine Promise, die nie aufloest, damit "unbegrenzt warten" auch wirklich unbegrenzt ist. +const sleep = (ms) => + ms === Infinity ? new Promise(() => {}) : new Promise((resolve) => setTimeout(resolve, ms)) + +export async function withLocks(keys, fn, { waitMs = Infinity } = {}) { + const previousTails = keys.map((key) => tailsByKey.get(key)).filter(Boolean) + + let releaseTail + const tail = new Promise((resolve) => { + releaseTail = resolve + }) + for (const key of keys) tailsByKey.set(key, tail) + + await Promise.race([Promise.allSettled(previousTails), sleep(waitMs)]) + + try { + return await fn() + } finally { + releaseTail() + for (const key of keys) { + if (tailsByKey.get(key) === tail) tailsByKey.delete(key) + } + } +} diff --git a/server/services/slug.js b/server/services/slug.js new file mode 100644 index 0000000..f93ace0 --- /dev/null +++ b/server/services/slug.js @@ -0,0 +1,22 @@ +/** + * URL-/Dateiname-taugliches Kuerzel aus beliebigem Text: klein geschrieben, deutsche + * Umlaute ausgeschrieben (ä→ae, ö→oe, ü→ue, ß→ss), Akzente entfernt, Apostrophe entfernt, + * alles ausser a-z/0-9/- wird zu einem einzelnen '-', Rand-Bindestriche entfernt, auf + * 100 Zeichen gekuerzt. Kein Text (z. B. null/undefined) ergibt ''. + */ +export function slugify(value) { + const input = value === null || value === undefined ? '' : String(value) + + return input + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/['’‘`]/g, '') + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) +} diff --git a/test/berlinTime.test.js b/test/berlinTime.test.js new file mode 100644 index 0000000..3866d18 --- /dev/null +++ b/test/berlinTime.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { berlinDateTime } from '../server/services/berlinTime.js' + +test('Sommerzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T14:46:34Z')), { date: '27.09.2026', time: '1646' })) +test('Winterzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-01-15T10:05:00Z')), { date: '15.01.2026', time: '1105' })) +test('nach Mitternacht UTC', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T22:30:00Z')), { date: '28.09.2026', time: '0030' })) diff --git a/test/keyedLock.test.js b/test/keyedLock.test.js new file mode 100644 index 0000000..d232006 --- /dev/null +++ b/test/keyedLock.test.js @@ -0,0 +1,39 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { withLocks } from '../server/services/keyedLock.js' + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +let log +beforeEach(() => { + log = [] +}) + +const job = (name, ms) => async () => { + log.push(name + ':start') + await sleep(ms) + log.push(name + ':end') +} + +test('gleicher Schlüssel läuft nacheinander', async () => { + await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['a'], job('2', 10))]) + assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end']) +}) +test('verschiedene Schlüssel laufen parallel', async () => { + await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['b'], job('2', 10))]) + assert.deepEqual(log.slice(0, 2), ['1:start', '2:start']) +}) +test('mehrere Schlüssel sperren alle', async () => { + await Promise.all([withLocks(['a', 'b'], job('1', 50)), withLocks(['b'], job('2', 10))]) + assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end']) +}) +test('waitMs begrenzt das Warten', async () => { + const t0 = Date.now() + let started + await Promise.all([withLocks(['a'], job('1', 300)), withLocks(['a'], async () => { started = Date.now() - t0 }, { waitMs: 50 })]) + assert.ok(started >= 45 && started < 250, `gestartet nach ${started} ms`) +}) +test('Fehler geben die Sperre frei', async () => { + await assert.rejects(withLocks(['a'], async () => { throw new Error('x') })) + assert.equal(await withLocks(['a'], async () => 42), 42) +}) diff --git a/test/slug.test.js b/test/slug.test.js new file mode 100644 index 0000000..ff03986 --- /dev/null +++ b/test/slug.test.js @@ -0,0 +1,18 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { slugify } from '../server/services/slug.js' + +test('Umlaute', () => { + assert.equal(slugify('Döner Nation'), 'doener-nation') + assert.equal(slugify('Größe Überall Ärger'), 'groesse-ueberall-aerger') +}) +test('Apostrophe entfallen', () => { + assert.equal(slugify("Emma's Schatzkiste"), 'emmas-schatzkiste') + assert.equal(slugify('Emma’s'), 'emmas') +}) +test('Akzente', () => assert.equal(slugify('Café Élan'), 'cafe-elan')) +test('Sonderzeichen, Ränder, Länge', () => { + assert.equal(slugify('--A&B--'), 'a-b') + assert.equal(slugify('x'.repeat(150)).length, 100) + assert.equal(slugify(null), '') +})