From a66482a137022c174e5ba7245602a7e3338301f0 Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 2 Sep 2026 00:53:22 +0200 Subject: [PATCH] update database schema and update editor script --- .../20260901_add_route_activated_at.sql | 10 +- database/schema.sql | 13 +- docs/editor-merge-notes.md | 54 ++-- public/index.html | 2 +- public/js/editor-ui.js | 50 ++- scripts/apply-editor-schema.mjs | 68 ++++ test/editor-database-schema.test.js | 20 +- test/editor-ui.test.js | 293 ++++++++++++++++++ 8 files changed, 455 insertions(+), 55 deletions(-) create mode 100644 scripts/apply-editor-schema.mjs diff --git a/database/migrations/20260901_add_route_activated_at.sql b/database/migrations/20260901_add_route_activated_at.sql index 8cee501..20df538 100644 --- a/database/migrations/20260901_add_route_activated_at.sql +++ b/database/migrations/20260901_add_route_activated_at.sql @@ -1,7 +1,3 @@ --- Wegwichtel editor support: route activation timestamp. --- Neue Strecken bleiben unveröffentlicht, solange activated_at NULL ist. --- Diese Migration einmalig gegen eine bestehende SQLite-Datenbank ausführen. --- Bei einer neu erzeugten Datenbank ohne Migrationslauf muss dieselbe Spalte --- direkt in der CREATE-TABLE-Anweisung der routes-Tabelle enthalten sein: --- activated_at DATETIME NULL -ALTER TABLE routes ADD COLUMN activated_at DATETIME NULL; +-- Ergänzt den manuellen Freigabezeitpunkt für Strecken. +-- Neue Strecken bleiben unveröffentlicht, solange activated_at is NULL. +ALTER TABLE routes ADD COLUMN activated_at TEXT NULL DEFAULT NULL; diff --git a/database/schema.sql b/database/schema.sql index f1d2453..5f38ab0 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -19,10 +19,21 @@ CREATE TABLE IF NOT EXISTS routes ( point_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TEXT, + deleted_at TEXT DEFAULT NULL, + activated_at TEXT DEFAULT NULL, trash_path TEXT ) STRICT; +CREATE TRIGGER IF NOT EXISTS routes_updated_at +AFTER UPDATE ON routes +FOR EACH ROW +WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE routes + SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; +END; + CREATE TABLE IF NOT EXISTS route_points ( id INTEGER PRIMARY KEY AUTOINCREMENT, route_id INTEGER NOT NULL REFERENCES routes(id), diff --git a/docs/editor-merge-notes.md b/docs/editor-merge-notes.md index 21b85fa..b458341 100644 --- a/docs/editor-merge-notes.md +++ b/docs/editor-merge-notes.md @@ -1,40 +1,38 @@ -# Wegwichtel Streckeneditor - einfache Fassung mit Tests +# Wegwichtel Editor v3 -Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl und liefert passende Tests mit. +Dieses Paket ergänzt den einfachen Streckeneditor und die zugehörigen Tests. -## Enthaltene Dateien +## Datenbankschema -- `public/index.html` - - neuer Abschnitt `Strecken verwalten` mit rundem Plus-Button - - neues Dialogfenster `#editor-dialog` - - Einbindung von `css/editor.css` und `js/editor-ui.js` -- `public/css/editor.css` - - eigenständige Editor-Styles -- `public/js/editor-ui.js` - - Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular - - POI-Sortierung per Pfeil nach oben/unten - - Upload einer Audiodatei und beliebig vieler Bilder pro POI -- `database/migrations/20260901_add_route_activated_at.sql` - - ergänzt `routes.activated_at DATETIME NULL` für bestehende SQLite-Datenbanken -- `test/editor-ui.test.js` - - prüft Editor-Markup, Editor-Klassen, Pointer-Events, CRUD-/Sortier-/Upload-Anbindungen -- `test/editor-database-schema.test.js` - - prüft, dass `activated_at DATETIME NULL` über Schema oder Migration verfügbar ist +Für bestehende SQLite-Datenbanken liegt weiterhin eine Migration vor: -## Datenbank - -Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist. Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt. - -Für bestehende SQLite-Datenbanken: - -```sh +```bash sqlite3 < database/migrations/20260901_add_route_activated_at.sql ``` -Falls `database/schema.sql` für komplett neue Datenbanken ohne Migrationslauf verwendet wird, muss dort in der `routes`-Tabelle zusätzlich diese Spalte enthalten sein: +Für neu erzeugte Datenbanken muss `database/schema.sql` die Spalte direkt in der `routes`-Tabelle enthalten: ```sql activated_at DATETIME NULL ``` -Das Paket ersetzt `database/schema.sql` bewusst nicht blind, damit bestehende Tabellen, Indizes und Trigger nicht versehentlich überschrieben werden. +Falls das Paket ohne bereits angepasste `database/schema.sql` eingespielt wird, kann die Datei einmalig im Repository angepasst werden: + +```bash +node scripts/apply-editor-schema.mjs +``` + +Danach sollte `database/schema.sql` mit committed werden. + +## Tests + +```bash +node --check public/js/editor-ui.js +npm test -- test/editor-ui.test.js +npm test -- test/editor-database-schema.test.js +npm run test:coverage +``` + +## SonarQube + +Der frühere HTML-Smell durch `role="status"` am Editorstatus wurde entfernt. Der Status ist jetzt ein semantisches `` mit `aria-live="polite"`. diff --git a/public/index.html b/public/index.html index 6907cc0..fee1f2f 100644 --- a/public/index.html +++ b/public/index.html @@ -328,7 +328,7 @@ - +
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index 45b17ad..27cc0ab 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -564,14 +564,56 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; return Object.values(classes); } - function init() { - bind(); - ns.EditorUi = Object.freeze({ + function testHooks() { + return Object.freeze({ + apiUrl, + classNames, + closeEditor, + fillPoiForm, + fillRouteForm, + handleResponse, + isActivation, + loadRoute, + loadRoutes, + newPoi, + newRoute, + poiArray, + poiFromForm, + poiItem, + parseJson, + readJson, + renderPoiList, + renderRouteList, + routeArray, + routeFromForm, + routeItem, + savePoi, + sendForm, + sendJson, + setStatus, + savePoiOrder, + saveRoute, + state, + swapPois, + uploadPoiMedia + }); + } + + function editorApi() { + const api = { close: closeEditor, classNames, open: openEditor, refresh: loadRoutes - }); + }; + + if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks(); + return Object.freeze(api); + } + + function init() { + bind(); + ns.EditorUi = editorApi(); } ready(init); diff --git a/scripts/apply-editor-schema.mjs b/scripts/apply-editor-schema.mjs new file mode 100644 index 0000000..2970f80 --- /dev/null +++ b/scripts/apply-editor-schema.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const schemaPath = path.join(root, 'database/schema.sql'); +const columnSql = ' activated_at DATETIME NULL'; + +function hasActivatedAt(schema) { + return /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema); +} + +function splitRouteTableBody(body) { + const lines = body.split('\n'); + const trailing = []; + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + trailing.unshift(lines.pop()); + } + return { lines, trailing }; +} + +function isTableConstraint(line) { + return /^\s*,?\s*(?:PRIMARY\s+KEY|CONSTRAINT|FOREIGN\s+KEY|UNIQUE\s*\()/i.test(line); +} + +function hasLeadingComma(line) { + return /^\s*,/.test(line); +} + +function ensureCommaBeforeInsert(lines, targetIndex) { + const previousIndex = targetIndex - 1; + if (previousIndex < 0) return; + const previous = lines[previousIndex].trimEnd(); + if (!previous.endsWith(',')) lines[previousIndex] = `${previous},`; +} + +function columnForTarget(lines, insertBefore) { + if (insertBefore === -1) return columnSql; + return hasLeadingComma(lines[insertBefore]) ? columnSql : `${columnSql},`; +} + +function insertActivatedAt(schema) { + if (hasActivatedAt(schema)) return schema; + + const routeTable = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?routes\s*\((?[\s\S]*?)\);/i.exec(schema); + if (!routeTable?.groups?.body) { + throw new Error('Die routes-Tabelle wurde in database/schema.sql nicht gefunden.'); + } + + const tableSql = routeTable[0]; + const body = routeTable.groups.body; + const { lines, trailing } = splitRouteTableBody(body); + const insertBefore = lines.findIndex(isTableConstraint); + const targetIndex = insertBefore === -1 ? lines.length : insertBefore; + + ensureCommaBeforeInsert(lines, targetIndex); + lines.splice(targetIndex, 0, columnForTarget(lines, insertBefore)); + + return schema.replace(tableSql, tableSql.replace(body, [...lines, ...trailing].join('\n'))); +} + +const schema = await fs.readFile(schemaPath, 'utf8'); +const next = insertActivatedAt(schema); +if (next === schema) { + console.log('database/schema.sql enthält activated_at bereits.'); +} else { + await fs.writeFile(schemaPath, next); + console.log('database/schema.sql wurde um routes.activated_at ergänzt.'); +} diff --git a/test/editor-database-schema.test.js b/test/editor-database-schema.test.js index 5c9aeda..5210f69 100644 --- a/test/editor-database-schema.test.js +++ b/test/editor-database-schema.test.js @@ -9,21 +9,13 @@ async function read(relativePath) { return fs.readFile(path.join(root, relativePath), 'utf8'); } -async function readIfExists(relativePath) { - try { - return await read(relativePath); - } catch (error) { - if (error.code === 'ENOENT') return ''; - throw error; - } -} - -test('route activation timestamp is available for editor draft handling', async () => { - const schema = await readIfExists('database/schema.sql'); +test('initial database schema creates routes with a nullable activation timestamp', async () => { + const schema = await read('database/schema.sql'); const migration = await read('database/migrations/20260901_add_route_activated_at.sql'); - const schemaDefinesColumn = /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema); - const migrationDefinesColumn = /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+DATETIME\s+NULL/i.test(migration); + const routeTable = schema.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?routes\s*\([\s\S]*?\);/i); - assert.ok(schemaDefinesColumn || migrationDefinesColumn); + assert.ok(routeTable, 'database/schema.sql must define the routes table'); + assert.match(routeTable[0], /\bactivated_at\s+DATETIME\s+NULL\b/i); + assert.match(migration, /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+DATETIME\s+NULL/i); assert.match(migration, /Neue Strecken bleiben unveröffentlicht|unpublished|activated_at is NULL/i); }); diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js index 5226974..96726bd 100644 --- a/test/editor-ui.test.js +++ b/test/editor-ui.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; +import vm from 'node:vm'; const root = path.resolve(import.meta.dirname, '..'); @@ -17,6 +18,8 @@ test('route editor entry point, dialog and forms are present', async () => { assert.match(html, /id="editor-button"/); assert.match(html, /aria-label="Streckeneditor öffnen"/); assert.match(html, /