diff --git a/.env.example b/.env.example index e53ef8c..ef56d43 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,6 @@ -# Vorlage für die MongoDB-Zugangsdaten. +# Vorlage für die optionalen Editor-Einstellungen. # Kopieren nach .env und Werte anpassen: cp .env.example .env -# Verbindungs-URI (ohne Auth für lokale Entwicklung): -MONGODB_URI=mongodb://localhost:27017 - -# Mit Authentifizierung: -# MONGODB_URI=mongodb://benutzer:passwort@host:27017/?authSource=admin - -# Zieldatenbank: -MONGODB_DATABASE=tasklist_editor - -# Collection, in der das Task-JSON gespeichert wird: -MONGODB_COLLECTION=tasks_json - -# Collection, in der die Canvas-Stände (Layout + Werte) gespeichert werden: -MONGODB_CANVAS_COLLECTION=canvas_layouts - -# Collection, in der die benutzerdefinierten Typen gespeichert werden: -MONGODB_CUSTOM_TYPE_COLLECTION=custom_types - -# --- Collections je Element-Typ --------------------------------------------- -# Beim Speichern eines Canvas werden die Einstellungen jedes platzierten -# Elements zusätzlich je Typ in eine eigene Collection geschrieben. -# (Benutzerdefinierte Typen nutzen die Collection ihres Basistyps.) -MONGODB_ELEMENT_ARRIVAL_COLLECTION=element_arrival -MONGODB_ELEMENT_SIGNATURE_COLLECTION=element_signature -MONGODB_ELEMENT_PHOTOS_COLLECTION=element_photos -MONGODB_ELEMENT_RECEIPT_GIVER_COLLECTION=element_receipt_giver -MONGODB_ELEMENT_REMARK_COLLECTION=element_remark -MONGODB_ELEMENT_COMMISSION_NUMBER_COLLECTION=element_commission_number -MONGODB_ELEMENT_CHECKBOX_COLLECTION=element_checkbox -MONGODB_ELEMENT_BARCODE_COLLECTION=element_barcode -MONGODB_ELEMENT_STATUS_SELECTION_COLLECTION=element_status_selection -MONGODB_ELEMENT_TEXT_LIST_COLLECTION=element_text_list -MONGODB_ELEMENT_NUMBER_LIST_COLLECTION=element_number_list -MONGODB_ELEMENT_CHECKBOX_LIST_COLLECTION=element_checkbox_list -MONGODB_ELEMENT_SCRIPT_COLLECTION=element_script - # Script-Block (grafisches Logik-Element) ausblenden: # true = Script-Element wird im Editor nicht angezeigt # false = Script-Element ist verfügbar (Standard) diff --git a/README.md b/README.md index d539985..fca5b1c 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,23 @@ angeordnet und verbunden; daraus wird das app-konforme `tasks`-JSON erzeugt. - Funktionsblöcke für die stadtbote-Task-Typen (Ankunft, Unterschrift, Fotos, Quittungsgeber, Stationshinweis, Kommissionsnummer, Abhol-/Lieferstatus) -- Erzeugen des app-konformen Task-JSON und Speichern in MongoDB -- Speichern und Wiederherstellen des Canvas-Standes in MongoDB -- Aufruf per Deep-Link mit Übergabe von Task-Daten als JSON-Body +- Erzeugen des app-konformen Task-JSON +- Eigene Typen mit konfigurierbaren Vorgaben +- Aufruf per Deep-Link mit Übergabe von Tasks, eigenen Typen und Metadaten als JSON-Body +- Rückgabe der bearbeiteten Tasks und des vollständigen eigenen Typkatalogs an den Aufrufer ## Technik - Java 21, Spring Boot 3, Vaadin 24 -- MongoDB (Spring Data) zur Persistenz - Drawflow als Canvas-/Node-Editor ## Konfiguration -Zugangsdaten und Collections werden über eine `.env`-Datei gesetzt -(Vorlage: `.env.example`): +Für den Editor ist keine Datenbank erforderlich. Tasks und eigene Typen werden +beim Aufruf übergeben und bei **Fertig** an den Aufrufer zurückgegeben. Der +Aufrufer ist für deren dauerhafte Speicherung verantwortlich. -``` -MONGODB_URI=mongodb://localhost:27017 -MONGODB_DATABASE=tasklist_editor -MONGODB_COLLECTION=tasks_json -MONGODB_CANVAS_COLLECTION=canvas_layouts -``` +Optionale Einstellungen sind in `.env.example` beschrieben. ## Start @@ -37,3 +33,126 @@ mvn spring-boot:run ``` Die Anwendung läuft anschließend unter http://localhost:8080/. + +## Aufruf und Rückgabe mit dem Python-Testserver + +Die Webapp muss mit dem aktuellen Code auf Port 8080 laufen. In einem zweiten +Terminal den Testserver starten (Python 3, keine zusätzlichen Pakete): + +```bash +python3 tools/editor_test_server.py +``` + +Der Testserver öffnet seine Aufruferseite unter `http://127.0.0.1:8090`. +Dort das Beispiel bearbeiten oder eigenes JSON einfügen und den Editor öffnen. +Der Editor erscheint eingebettet in der Seite. **Fertig** gibt die validierten +Tasks und den vollständigen Katalog eigener Typen zurück; **Abbrechen** meldet +den Abbruch ohne bearbeitete Tasks oder Typen. +Beide Ergebnisse werden im Python-Terminal als JSON ausgegeben und auf der +Testseite angezeigt. Nach **Fertig** enthält das JSON-Eingabefeld die Rückgabe +für den nächsten Editor-Aufruf. **Neue Liste** leert die Tasks und behält den +aktuellen eigenen Typkatalog. Nach **Abbrechen** bleibt die Eingabe unverändert. +Die Testseite hält diese Daten nur bis zum Neuladen; die Konsolenausgabe bleibt +im Terminal verfügbar. + +Eine Datei vorgeben oder andere Ports verwenden: + +```bash +python3 tools/editor_test_server.py --json tools/example_tasks.json +python3 tools/editor_test_server.py --editor-url http://127.0.0.1:8080 --port 8090 --no-browser +``` + +Als Eingabe ist ein Task-Array oder ein Objekt erlaubt: + +```json +{ + "name": "Testauftrag", + "tasks": [{ "type": 3, "topic": "Ankunft melden", "track": true }], + "customTypes": [], + "metadata": { "auftragId": "TEST-4711", "revision": 2 } +} +``` + +Für eine neue Liste `tasks` weglassen oder `[]` übergeben. Die Metadaten werden +unverändert zurückgegeben. Zum Abschließen eines neu aufgebauten Workflows +müssen Start, Aufgaben und Abschluss vollständig verbunden sein. Beim Import +vorhandener Tasks ergänzt der Editor die internen Start-/Abschlussmarkierungen; +sie sind nicht Teil des zurückgegebenen Task-Arrays. + +### Eigene Typen + +`customTypes` enthält den vollständigen Katalog für diesen Editor-Aufruf. +Ohne dieses Feld oder mit `[]` stehen nur die eingebauten Typen zur Verfügung. +Eigene Typen werden vor den Tasks geladen. Ein Task verweist mit `type` auf die +`taskType`-Nummer seines eigenen Typs (ab 1000), beispielsweise: + +```json +{ + "name": "Zustellnachweis", + "tasks": [{ "type": 1000, "topic": "Zustellung fotografieren", "min": 1, "max": 3 }], + "customTypes": [{ + "typeId": "custom-1000", + "taskType": 1000, + "baseTypeId": "fotos-aufnehmen", + "label": "Zustellnachweis", + "description": "Fotos zur Dokumentation einer Zustellung.", + "icon": "vaadin:camera", + "color": "#059669", + "inputs": 1, + "outputs": 1, + "properties": [ + { "id": "min", "label": "Mindestanzahl Fotos", "type": "NUMBER", "defaultValue": 1 }, + { "id": "max", "label": "Maximale Anzahl Fotos", "type": "NUMBER", "defaultValue": 3 }, + { "id": "opt", "label": "Optionale Aufgabe", "type": "BOOLEAN", "defaultValue": false }, + { "id": "reenter", "label": "Erneut bearbeitbar", "type": "BOOLEAN", "defaultValue": true } + ] + }], + "metadata": { "auftragId": "TEST-4711" } +} +``` + +`properties` beschreibt die Eigenschaften samt Vorgabewerten; `type` ist dabei +einer der Werte `TEXT`, `TEXTAREA`, `NUMBER`, `BOOLEAN`, `FIELD_LIST`, `NAME_LIST` +oder `CHOICE_LIST`. Das mitgelieferte Beispiel `tools/example_tasks.json` +enthält einen eigenen Foto-Typ und einen zugehörigen Task. + +Bei **Fertig** wird `customTypes` immer vollständig zurückgegeben, einschließlich +unveränderter und neu angelegter Typen. Der Aufrufer ersetzt seinen bisherigen +Katalog durch dieses Array, damit auch gelöschte Typen berücksichtigt werden. +`[]` bedeutet, dass keine eigenen Typen mehr vorhanden sind. Bei **Abbrechen** +übernimmt der Aufrufer keine Änderungen. + +### Übergabeprotokoll + +Die Python-Seite ruft serverseitig `POST /api/editor/import` auf und ergänzt +`returnOrigin`, z. B. `http://127.0.0.1:8090`. Die Antwort enthält `token` und +`deepLink`. Der Aufrufer öffnet den Deep-Link in einem iframe oder Popup. +Der Import bleibt für 30 Minuten im Speicher der Webapp verfügbar, sodass ein +Neuladen während dieser Zeit möglich ist. + +Der Editor sendet das Ergebnis per `postMessage` ausschließlich an diese Origin: + +```json +{ + "type": "tasklist-editor:result", + "token": "", + "status": "completed", + "name": "Testauftrag", + "metadata": { "auftragId": "TEST-4711", "revision": 2 }, + "tasks": [{ "s_id": 1, "type": 3, "topic": "Ankunft melden", "track": true }], + "customTypes": [] +} +``` + +Bei Abbruch ist `status` gleich `cancelled`; die Felder `tasks` und `customTypes` +entfallen. +Die Testseite prüft Absender-Origin, Fenster und Token und leitet das Ergebnis +an `POST /api/result` des Python-Servers weiter. Erst nach dessen Bestätigung +wird der iframe geschlossen. Ohne `returnOrigin` zeigt der Editor einen +schreibgeschützten JSON-Dialog zum Kopieren mit `name`, `metadata`, `tasks` +und `customTypes`. + +Der Testserver ist ein lokales Entwicklungswerkzeug und bindet an `127.0.0.1`. +Er übernimmt bekannte Task-Felder des Editors; unbekannte Task-Typen werden vom +bestehenden Importer übersprungen. Beliebige zusätzliche Daten daher in +`metadata` übergeben. diff --git a/pom.xml b/pom.xml index 691253b..2c0bf47 100644 --- a/pom.xml +++ b/pom.xml @@ -45,11 +45,6 @@ spring-boot-starter-actuator - - org.springframework.boot - spring-boot-starter-data-mongodb - - me.paulschwarz diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js index 98b8c93..3e304d6 100644 --- a/src/main/frontend/node-editor/node-editor.js +++ b/src/main/frontend/node-editor/node-editor.js @@ -276,6 +276,24 @@ function refreshValidity(state) { } } +// Drawflow berechnet SVG-Pfade sofort anhand der Port-Geometrie. Beim Import +// vor dem ersten Layout ist die Zeichenfläche noch 0 × 0 groß; diese Pfade +// müssen nach dem Layout bzw. nach dem Einblenden neu berechnet werden. +function scheduleConnectionRefresh(state) { + if (state.connectionRefreshFrame != null) return; + state.connectionRefreshFrame = requestAnimationFrame(() => { + state.connectionRefreshFrame = null; + const editor = state.editor; + if (!state.container.isConnected + || editor.precanvas.clientWidth <= 0 || editor.precanvas.clientHeight <= 0) return; + const data = editor.drawflow.drawflow[editor.module].data; + for (const id of Object.keys(data)) { + editor.updateConnectionNodes(`node-${id}`); + } + refreshValidity(state); + }); +} + /** Liefert true, wenn mindestens einer der vorhandenen Ports keine Verbindung hat. */ function hasOpenPort(ports) { if (!ports) return false; @@ -329,7 +347,8 @@ function attach(host) { registry: new Map(), history: [], undoSuppressed: false, - undoScheduled: false + undoScheduled: false, + connectionRefreshFrame: null }; editors.set(host, state); resetHistory(state); @@ -422,13 +441,19 @@ function attach(host) { // Unvollständig verdrahtete Elemente (offener Ein- oder Ausgang) hellrot // markieren. Nach jeder strukturellen Änderung neu bewerten. - const refresh = () => requestAnimationFrame(() => refreshValidity(state)); + const refresh = () => scheduleConnectionRefresh(state); editor.on('connectionCreated', refresh); editor.on('connectionRemoved', refresh); editor.on('nodeCreated', refresh); editor.on('nodeRemoved', refresh); editor.on('import', refresh); + // Erfasst insbesondere den Wechsel von einem noch nicht sichtbaren + // iframe/Host zur tatsächlich verfügbaren Canvas-Größe. + const resizeObserver = new ResizeObserver(refresh); + resizeObserver.observe(container); + resizeObserver.observe(editor.precanvas); + // Undo-Historie: jede strukturelle Änderung erzeugt einen Schnappschuss. const record = () => recordHistory(state); editor.on('nodeCreated', record); @@ -566,6 +591,11 @@ function attach(host) { document.addEventListener('keydown', undoKeyHandler); state.cleanup = () => { + resizeObserver.disconnect(); + if (state.connectionRefreshFrame != null) { + cancelAnimationFrame(state.connectionRefreshFrame); + state.connectionRefreshFrame = null; + } document.removeEventListener('keydown', deleteSelectedConnection); document.removeEventListener('keydown', undoKeyHandler); document.removeEventListener('mousemove', onRightMove); @@ -668,6 +698,7 @@ window.taskListEditor = { attach, registerBlockTypes(host, types) { const state = attach(host); + state.registry.clear(); for (const t of types) { state.registry.set(t.id, t); } @@ -694,7 +725,7 @@ window.taskListEditor = { current--; } recordHistory(state); - requestAnimationFrame(() => refreshValidity(state)); + scheduleConnectionRefresh(state); }, updateNodeLabel(host, nodeId, label) { const state = editors.get(host); @@ -709,6 +740,7 @@ window.taskListEditor = { if (nodeData) nodeData.html = html; const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`); if (el) el.innerHTML = html; + scheduleConnectionRefresh(state); recordHistory(state); }, undo(host) { @@ -761,6 +793,7 @@ window.taskListEditor = { // Frisch geladener Stand ist der neue Basiszustand der Undo-Historie. resetHistory(state); + scheduleConnectionRefresh(state); return JSON.stringify(ids); }, importGraph(host, graphJson) { diff --git a/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java b/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java deleted file mode 100644 index d4699de..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/config/ElementCollectionProperties.java +++ /dev/null @@ -1,50 +0,0 @@ -package de.assecutor.tasklisteditor.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -/** - * Konfiguration der MongoDB-Collections, in denen die Einstellungen der - * einzelnen Element-Typen abgelegt werden. - * - *

Die Collection-Namen werden aus {@code application.properties} gebunden, - * deren Werte wiederum aus den {@code MONGODB_ELEMENT_*}-Variablen der - * {@code .env} stammen: - *

- *   tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LISTE_COLLECTION:element_text_liste_erfassen}
- * 
- * - *

Für jeden (festen wie benutzerdefinierten) Typ muss eine Zuordnung - * existieren; benutzerdefinierte Typen werden zuvor auf ihren statischen - * Basistyp aufgelöst. Fehlt ein Eintrag, ist das ein Konfigurationsfehler. - */ -@Component -@ConfigurationProperties(prefix = "tasklist") -public class ElementCollectionProperties { - - /** Typ-ID → Collection-Name. */ - private final Map elementCollections = new HashMap<>(); - - public Map getElementCollections() { - return elementCollections; - } - - /** - * Liefert den Collection-Namen für die angegebene Typ-ID. - * - * @throws IllegalStateException wenn für die Typ-ID keine Zuordnung - * konfiguriert ist - */ - public String collectionFor(String typeId) { - String configured = elementCollections.get(typeId); - if (configured == null || configured.isBlank()) { - throw new IllegalStateException( - "Keine Collection für Element-Typ '" + typeId + "' konfiguriert " - + "(tasklist.element-collections)."); - } - return configured; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/document/CanvasDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/CanvasDocument.java deleted file mode 100644 index 7f71509..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/document/CanvasDocument.java +++ /dev/null @@ -1,79 +0,0 @@ -package de.assecutor.tasklisteditor.document; - -import de.assecutor.tasklisteditor.model.CanvasBlockState; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.Instant; -import java.util.List; - -/** - * In MongoDB gespeicherter Canvas-Stand für die spätere Weiterbearbeitung - * im Editor. - * - *

{@link #layout} enthält den vollständigen Drawflow-Graphen (Knoten, - * Positionen, Verbindungen) als JSON. {@link #blocks} hält die serverseitigen - * Property-Werte je Knoten, damit der Canvas vollständig wiederhergestellt - * werden kann. - */ -@Document(collection = "${MONGODB_CANVAS_COLLECTION:canvas_layouts}") -public class CanvasDocument { - - @Id - private String id; - - private String name; - private Instant createdAt; - private String layout; - private List blocks; - - public CanvasDocument() { - } - - public CanvasDocument(String name, Instant createdAt, String layout, List blocks) { - this.name = name; - this.createdAt = createdAt; - this.layout = layout; - this.blocks = blocks; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } - - public String getLayout() { - return layout; - } - - public void setLayout(String layout) { - this.layout = layout; - } - - public List getBlocks() { - return blocks; - } - - public void setBlocks(List blocks) { - this.blocks = blocks; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java deleted file mode 100644 index a3a58ae..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/document/CustomTypeDocument.java +++ /dev/null @@ -1,135 +0,0 @@ -package de.assecutor.tasklisteditor.document; - -import de.assecutor.tasklisteditor.model.CustomPropertyState; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.Indexed; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.Instant; -import java.util.List; - -/** - * In MongoDB gespeicherter benutzerdefinierter Typ. Enthält alle Daten, um den - * {@code BlockType} ohne Rückgriff auf den Basis-Typ wiederherstellen zu können - * (Aussehen, Ein-/Ausgänge und die Eigenschaften mit ihren erfassten Vorgaben). - */ -@Document(collection = "${MONGODB_CUSTOM_TYPE_COLLECTION:custom_types}") -public class CustomTypeDocument { - - @Id - private String id; - - /** Eindeutige Typnummer (>= 1000); dient zugleich als Schlüssel. */ - @Indexed(unique = true) - private int taskType; - - private String typeId; - private String label; - private String baseTypeId; - private String description; - private String icon; - private String color; - private int inputs; - private int outputs; - private List properties; - private Instant createdAt; - - public CustomTypeDocument() { - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public int getTaskType() { - return taskType; - } - - public void setTaskType(int taskType) { - this.taskType = taskType; - } - - public String getTypeId() { - return typeId; - } - - public void setTypeId(String typeId) { - this.typeId = typeId; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - - public String getBaseTypeId() { - return baseTypeId; - } - - public void setBaseTypeId(String baseTypeId) { - this.baseTypeId = baseTypeId; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getIcon() { - return icon; - } - - public void setIcon(String icon) { - this.icon = icon; - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color; - } - - public int getInputs() { - return inputs; - } - - public void setInputs(int inputs) { - this.inputs = inputs; - } - - public int getOutputs() { - return outputs; - } - - public void setOutputs(int outputs) { - this.outputs = outputs; - } - - public List getProperties() { - return properties; - } - - public void setProperties(List properties) { - this.properties = properties; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java deleted file mode 100644 index a6f0618..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/document/ElementSettingsDocument.java +++ /dev/null @@ -1,136 +0,0 @@ -package de.assecutor.tasklisteditor.document; - -import org.springframework.data.annotation.Id; - -import java.time.Instant; -import java.util.Map; - -/** - * Einstellungen eines einzelnen platzierten Elements (Block-Instanz) eines - * Canvas. Wird beim Speichern eines Canvas je Element-Typ in eine eigene - * MongoDB-Collection geschrieben (Collection-Name pro Typ konfigurierbar, siehe - * {@code tasklist.element-collections} bzw. die {@code MONGODB_ELEMENT_*}- - * Variablen in der {@code .env}). - * - *

Die Verknüpfung zum Canvas erfolgt über {@link #canvasId}; {@link #nodeId} - * identifiziert den Knoten innerhalb des Canvas. - */ -public class ElementSettingsDocument { - - @Id - private String id; - - /** ID des zugehörigen {@link CanvasDocument}. */ - private String canvasId; - - /** Anzeigename des Canvas zum Zeitpunkt des Speicherns. */ - private String canvasName; - - /** Knoten-ID innerhalb des Canvas (Drawflow). */ - private int nodeId; - - /** Typ-ID des Elements (z. B. {@code text-liste-erfassen} oder {@code custom-1000}). */ - private String typeId; - - /** - * Bei benutzerdefinierten Typen die Typ-ID des statischen Basistyps, in - * dessen Collection das Dokument abgelegt wird; bei festen Typen {@code null}. - */ - private String baseTypeId; - - /** Bezeichnung des Tasks (entspricht {@code topic}). */ - private String topic; - - /** Erfasste Property-Werte des Elements. */ - private Map values; - - private Instant createdAt; - - public ElementSettingsDocument() { - } - - public ElementSettingsDocument(String canvasId, String canvasName, int nodeId, - String typeId, String baseTypeId, String topic, - Map values, Instant createdAt) { - this.canvasId = canvasId; - this.canvasName = canvasName; - this.nodeId = nodeId; - this.typeId = typeId; - this.baseTypeId = baseTypeId; - this.topic = topic; - this.values = values; - this.createdAt = createdAt; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getCanvasId() { - return canvasId; - } - - public void setCanvasId(String canvasId) { - this.canvasId = canvasId; - } - - public String getCanvasName() { - return canvasName; - } - - public void setCanvasName(String canvasName) { - this.canvasName = canvasName; - } - - public int getNodeId() { - return nodeId; - } - - public void setNodeId(int nodeId) { - this.nodeId = nodeId; - } - - public String getTypeId() { - return typeId; - } - - public void setTypeId(String typeId) { - this.typeId = typeId; - } - - public String getBaseTypeId() { - return baseTypeId; - } - - public void setBaseTypeId(String baseTypeId) { - this.baseTypeId = baseTypeId; - } - - public String getTopic() { - return topic; - } - - public void setTopic(String topic) { - this.topic = topic; - } - - public Map getValues() { - return values; - } - - public void setValues(Map values) { - this.values = values; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/document/TaskListDocument.java b/src/main/java/de/assecutor/tasklisteditor/document/TaskListDocument.java deleted file mode 100644 index 839eefc..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/document/TaskListDocument.java +++ /dev/null @@ -1,80 +0,0 @@ -package de.assecutor.tasklisteditor.document; - -import de.assecutor.tasklisteditor.model.TaskDto; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.Instant; -import java.util.List; - -/** - * In MongoDB gespeicherte Task-Liste. Das Feld {@link #tasks} entspricht - * exakt dem {@code tasks}-Array, das zur stadtbote-App gesendet wird. - * {@link #layout} hält zusätzlich den rohen Editor-Graphen (Drawflow), - * damit die Liste später im Editor wieder geöffnet werden kann. - */ -@Document(collection = "${MONGODB_COLLECTION:tasks_json}") -public class TaskListDocument { - - /** - * Eindeutige Dokument-ID. Wird beim Speichern automatisch als - * MongoDB-ObjectId vergeben, falls nicht gesetzt. - */ - @Id - private String id; - - private String name; - private Instant createdAt; - private List tasks; - private String layout; - - public TaskListDocument() { - } - - public TaskListDocument(String name, Instant createdAt, List tasks, String layout) { - this.name = name; - this.createdAt = createdAt; - this.tasks = tasks; - this.layout = layout; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } - - public List getTasks() { - return tasks; - } - - public void setTasks(List tasks) { - this.tasks = tasks; - } - - public String getLayout() { - return layout; - } - - public void setLayout(String layout) { - this.layout = layout; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java index 9fd645f..42f5c73 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java @@ -1,9 +1,12 @@ package de.assecutor.tasklisteditor.model; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -28,6 +31,7 @@ public class BlockTypeRegistry { /** Typnummer der virtuellen Abschluss-Markierung (negativ = nicht exportiert). */ public static final int END_TASK_TYPE = -2; + @Autowired public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) { // Rein virtuelle Workflow-Markierungen: Start (nur Ausgang) und Abschluss // (nur Eingang). Sie dienen ausschließlich der Darstellung eines @@ -261,6 +265,21 @@ public class BlockTypeRegistry { types.put(type.id(), type); } + private BlockTypeRegistry(List standardTypes) { + standardTypes.forEach(this::register); + } + + /** Jeder Editor-Aufruf erhält seinen eigenen Katalog ohne fremde eigene Typen. */ + public BlockTypeRegistry forEditor() { + return new BlockTypeRegistry(all().stream() + .filter(type -> type.taskType() < CUSTOM_TYPE_START) + .toList()); + } + + public void clearCustom() { + types.values().removeIf(type -> type.taskType() >= CUSTOM_TYPE_START); + } + public List all() { return List.copyOf(types.values()); } @@ -281,12 +300,16 @@ public class BlockTypeRegistry { /** Nächste freie Typnummer ab {@value #CUSTOM_TYPE_START}. */ public synchronized int nextCustomTaskType() { - int next = CUSTOM_TYPE_START; - for (BlockType type : types.values()) { - if (type.taskType() >= next) { - next = type.taskType() + 1; - } - } + return nextCustomTaskType(List.of()); + } + + /** Im Undo-Verlauf vorkommende Nummern dürfen ebenfalls nicht erneut vergeben werden. */ + public synchronized int nextCustomTaskType(Collection reserved) { + var occupied = new HashSet<>(reserved); + types.values().forEach(type -> occupied.add(type.taskType())); + int highest = occupied.stream().mapToInt(Integer::intValue).max().orElse(CUSTOM_TYPE_START - 1); + int next = highest < Integer.MAX_VALUE - 1 ? Math.max(CUSTOM_TYPE_START, highest + 1) : CUSTOM_TYPE_START; + while (occupied.contains(next)) next++; return next; } @@ -321,7 +344,7 @@ public class BlockTypeRegistry { /** * Registriert einen bereits vollständig aufgebauten benutzerdefinierten Typ - * (z. B. aus MongoDB geladen). Bereits vorhandene Typen mit gleicher ID + * (z. B. vom Aufrufer übergeben). Bereits vorhandene Typen mit gleicher ID * werden überschrieben. */ public synchronized BlockType registerCustom(BlockType custom) { diff --git a/src/main/java/de/assecutor/tasklisteditor/model/CanvasBlockState.java b/src/main/java/de/assecutor/tasklisteditor/model/CanvasBlockState.java deleted file mode 100644 index 1e14be5..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/model/CanvasBlockState.java +++ /dev/null @@ -1,59 +0,0 @@ -package de.assecutor.tasklisteditor.model; - -import java.util.Map; - -/** - * Serverseitiger Zustand eines Canvas-Knotens (Property-Werte + Bezeichnung), - * der zusammen mit dem Drawflow-Layout gespeichert wird. Über die - * {@link #nodeId} wird der Zustand beim Laden wieder dem importierten Knoten - * zugeordnet (Drawflow erhält die IDs beim Import). - */ -public class CanvasBlockState { - - private int nodeId; - private String typeId; - private String topic; - private Map values; - - public CanvasBlockState() { - } - - public CanvasBlockState(int nodeId, String typeId, String topic, Map values) { - this.nodeId = nodeId; - this.typeId = typeId; - this.topic = topic; - this.values = values; - } - - public int getNodeId() { - return nodeId; - } - - public void setNodeId(int nodeId) { - this.nodeId = nodeId; - } - - public String getTypeId() { - return typeId; - } - - public void setTypeId(String typeId) { - this.typeId = typeId; - } - - public String getTopic() { - return topic; - } - - public void setTopic(String topic) { - this.topic = topic; - } - - public Map getValues() { - return values; - } - - public void setValues(Map values) { - this.values = values; - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java b/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java index 4c5a5e5..93f763b 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/CustomPropertyState.java @@ -1,7 +1,7 @@ package de.assecutor.tasklisteditor.model; /** - * In MongoDB ablegbarer Zustand einer {@link PropertyDefinition} eines + * Als JSON übertragbarer Zustand einer {@link PropertyDefinition} eines * benutzerdefinierten Typs (inkl. der erfassten Vorgabe). Beim Laden wird * daraus wieder eine {@link PropertyDefinition} aufgebaut. */ diff --git a/src/main/java/de/assecutor/tasklisteditor/model/CustomTypeDefinition.java b/src/main/java/de/assecutor/tasklisteditor/model/CustomTypeDefinition.java new file mode 100644 index 0000000..4a69375 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/model/CustomTypeDefinition.java @@ -0,0 +1,56 @@ +package de.assecutor.tasklisteditor.model; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Vollständiger eigener Typ im Import- und Rückgabe-JSON des Editors. */ +public record CustomTypeDefinition( + String typeId, + int taskType, + String baseTypeId, + String label, + String description, + String icon, + String color, + int inputs, + int outputs, + List properties +) { + public CustomTypeDefinition { + properties = properties == null ? List.of() : properties.stream() + .map(property -> new CustomPropertyState(property.getId(), property.getLabel(), + property.getType(), copyValue(property.getDefaultValue()))) + .toList(); + } + + public static CustomTypeDefinition from(BlockType type) { + return new CustomTypeDefinition(type.id(), type.taskType(), type.baseTypeId(), type.label(), + type.description(), type.icon(), type.color(), type.inputs(), type.outputs(), + type.properties().stream().map(CustomPropertyState::of).toList()); + } + + /** Jeder Editor erhält eigene veränderbare Vorgabewerte, auch bei erneutem Öffnen eines Imports. */ + public BlockType toBlockType() { + return new BlockType(typeId, label, description, icon, color, taskType, inputs, outputs, + properties.stream().map(property -> new PropertyDefinition(property.getId(), + property.getLabel(), property.getType(), copyValue(property.getDefaultValue()))) + .toList(), + baseTypeId); + } + + private static Object copyValue(Object value) { + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(item -> copy.add(copyValue(item))); + return copy; + } + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, item) -> copy.put(key, copyValue(item))); + return copy; + } + return value; + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java index 5a3bd58..0a42084 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/TaskDto.java @@ -65,10 +65,12 @@ public class TaskDto { /** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */ private JsonNode script; + @JsonProperty("s_id") public int getSId() { return sId; } + @JsonProperty("s_id") public void setSId(int sId) { this.sId = sId; } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskListPayload.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskListPayload.java index a3e47bf..3820fcf 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/TaskListPayload.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/TaskListPayload.java @@ -1,5 +1,7 @@ package de.assecutor.tasklisteditor.model; +import com.fasterxml.jackson.databind.JsonNode; + import java.util.List; /** @@ -13,6 +15,9 @@ public class TaskListPayload { private String name; private List tasks; + private JsonNode metadata; + private String returnOrigin; + private List customTypes = List.of(); public String getName() { return name; @@ -29,4 +34,28 @@ public class TaskListPayload { public void setTasks(List tasks) { this.tasks = tasks; } + + public JsonNode getMetadata() { + return metadata; + } + + public void setMetadata(JsonNode metadata) { + this.metadata = metadata; + } + + public String getReturnOrigin() { + return returnOrigin; + } + + public void setReturnOrigin(String returnOrigin) { + this.returnOrigin = returnOrigin; + } + + public List getCustomTypes() { + return customTypes; + } + + public void setCustomTypes(List customTypes) { + this.customTypes = customTypes == null ? List.of() : List.copyOf(customTypes); + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/repository/CanvasRepository.java b/src/main/java/de/assecutor/tasklisteditor/repository/CanvasRepository.java deleted file mode 100644 index bf02663..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/repository/CanvasRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package de.assecutor.tasklisteditor.repository; - -import de.assecutor.tasklisteditor.document.CanvasDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -import java.util.List; - -/** - * Spring-Data-Repository für die in MongoDB abgelegten Canvas-Stände. - */ -public interface CanvasRepository extends MongoRepository { - - List findAllByOrderByCreatedAtDesc(); -} diff --git a/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java b/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java deleted file mode 100644 index 8af8430..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/repository/CustomTypeRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.assecutor.tasklisteditor.repository; - -import de.assecutor.tasklisteditor.document.CustomTypeDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -import java.util.List; - -/** - * Spring-Data-Repository für die in MongoDB abgelegten benutzerdefinierten Typen. - */ -public interface CustomTypeRepository extends MongoRepository { - - List findAllByOrderByTaskTypeAsc(); - - long deleteByTaskType(int taskType); -} diff --git a/src/main/java/de/assecutor/tasklisteditor/repository/TaskListRepository.java b/src/main/java/de/assecutor/tasklisteditor/repository/TaskListRepository.java deleted file mode 100644 index 6f4fb6c..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/repository/TaskListRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package de.assecutor.tasklisteditor.repository; - -import de.assecutor.tasklisteditor.document.TaskListDocument; -import org.springframework.data.mongodb.repository.MongoRepository; - -/** - * Spring-Data-Repository für die in MongoDB abgelegten Task-Listen. - */ -public interface TaskListRepository extends MongoRepository { -} diff --git a/src/main/java/de/assecutor/tasklisteditor/rest/EditorImportController.java b/src/main/java/de/assecutor/tasklisteditor/rest/EditorImportController.java index a16010b..c313285 100644 --- a/src/main/java/de/assecutor/tasklisteditor/rest/EditorImportController.java +++ b/src/main/java/de/assecutor/tasklisteditor/rest/EditorImportController.java @@ -3,8 +3,10 @@ package de.assecutor.tasklisteditor.rest; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; import de.assecutor.tasklisteditor.model.TaskDto; import de.assecutor.tasklisteditor.model.TaskListPayload; +import de.assecutor.tasklisteditor.service.CustomTypeValidator; import de.assecutor.tasklisteditor.service.PendingImportStore; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -13,7 +15,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import java.net.URI; +import java.net.URISyntaxException; import java.util.List; +import java.util.Locale; /** * Nimmt die per Deep-Link zu übergebenden Task-Daten als JSON-Body entgegen @@ -39,10 +44,13 @@ public class EditorImportController { private final PendingImportStore importStore; private final ObjectMapper objectMapper; + private final BlockTypeRegistry registry; - public EditorImportController(PendingImportStore importStore, ObjectMapper objectMapper) { + public EditorImportController(PendingImportStore importStore, ObjectMapper objectMapper, + BlockTypeRegistry registry) { this.importStore = importStore; this.objectMapper = objectMapper; + this.registry = registry; } public record ImportResponse(String token, String deepLink) { @@ -50,8 +58,10 @@ public class EditorImportController { @PostMapping("/import") public ResponseEntity importTasks(@RequestBody JsonNode body) { - TaskListPayload payload = toPayload(body); - if (payload.getTasks() == null || payload.getTasks().isEmpty()) { + TaskListPayload payload; + try { + payload = toPayload(body); + } catch (IllegalArgumentException exception) { return ResponseEntity.badRequest().build(); } @@ -66,16 +76,72 @@ public class EditorImportController { } private TaskListPayload toPayload(JsonNode body) { - if (body == null || body.isNull()) { - return new TaskListPayload(); + if (body == null || (!body.isArray() && !body.isObject())) { + throw new IllegalArgumentException("Expected an object or task array"); } + + JsonNode tasks = body.isArray() ? body : body.get("tasks"); + if (tasks != null && !tasks.isNull()) { + if (!tasks.isArray()) { + throw new IllegalArgumentException("tasks must be an array"); + } + for (JsonNode task : tasks) { + if (!task.isObject()) { + throw new IllegalArgumentException("Every task must be an object"); + } + } + } + + TaskListPayload payload; if (body.isArray()) { - TaskListPayload payload = new TaskListPayload(); + payload = new TaskListPayload(); payload.setTasks(objectMapper.convertValue(body, new TypeReference>() { })); - return payload; + } else { + CustomTypeValidator.validate(body.get("customTypes"), registry); + JsonNode returnOrigin = body.get("returnOrigin"); + if (returnOrigin != null && !returnOrigin.isNull() && !returnOrigin.isTextual()) { + throw new IllegalArgumentException("returnOrigin must be a string"); + } + payload = objectMapper.convertValue(body, TaskListPayload.class); + payload.setReturnOrigin(normalizeReturnOrigin(payload.getReturnOrigin())); + } + if (payload.getTasks() == null) { + payload.setTasks(List.of()); + } + for (TaskDto task : payload.getTasks()) { + if (task.getType() >= BlockTypeRegistry.CUSTOM_TYPE_START + && payload.getCustomTypes().stream().noneMatch(type -> type.taskType() == task.getType())) { + throw new IllegalArgumentException("Missing custom type definition for task " + task.getType()); + } + } + return payload; + } + + /** Eine feste Origin verhindert eine Rückgabe an beliebige Fenster per Wildcard. */ + private String normalizeReturnOrigin(String value) { + if (value == null) { + return null; + } + try { + URI uri = new URI(value); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + String path = uri.getRawPath(); + if ((!scheme.equals("http") && !scheme.equals("https")) + || uri.getHost() == null || uri.getRawUserInfo() != null + || (path != null && !path.isEmpty() && !path.equals("/")) + || uri.getRawQuery() != null || uri.getRawFragment() != null + || uri.getPort() > 65535) { + throw new IllegalArgumentException("returnOrigin must be an absolute HTTP(S) origin"); + } + int port = uri.getPort(); + if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) { + port = -1; + } + return new URI(scheme, null, uri.getHost().toLowerCase(Locale.ROOT), port, + null, null, null).toASCIIString(); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("Invalid returnOrigin", exception); } - // Objekt mit { name?, tasks: [...] } - return objectMapper.convertValue(body, TaskListPayload.class); } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/CanvasService.java b/src/main/java/de/assecutor/tasklisteditor/service/CanvasService.java deleted file mode 100644 index 6279913..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/service/CanvasService.java +++ /dev/null @@ -1,36 +0,0 @@ -package de.assecutor.tasklisteditor.service; - -import de.assecutor.tasklisteditor.document.CanvasDocument; -import de.assecutor.tasklisteditor.model.CanvasBlockState; -import de.assecutor.tasklisteditor.repository.CanvasRepository; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; - -/** - * Speichert und lädt Canvas-Stände (Drawflow-Layout + Block-Werte) in MongoDB. - */ -@Service -public class CanvasService { - - private final CanvasRepository repository; - - public CanvasService(CanvasRepository repository) { - this.repository = repository; - } - - public String save(String name, String layout, List blocks) { - CanvasDocument document = new CanvasDocument(name, Instant.now(), layout, blocks); - return repository.save(document).getId(); - } - - public List listNewestFirst() { - return repository.findAllByOrderByCreatedAtDesc(); - } - - public Optional load(String id) { - return repository.findById(id); - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java deleted file mode 100644 index 6529bba..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeService.java +++ /dev/null @@ -1,73 +0,0 @@ -package de.assecutor.tasklisteditor.service; - -import de.assecutor.tasklisteditor.document.CustomTypeDocument; -import de.assecutor.tasklisteditor.model.BlockType; -import de.assecutor.tasklisteditor.model.CustomPropertyState; -import de.assecutor.tasklisteditor.repository.CustomTypeRepository; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; - -/** - * Speichert benutzerdefinierte Typen in MongoDB und stellt sie als - * {@link BlockType} wieder her. - */ -@Service -public class CustomTypeService { - - private final CustomTypeRepository repository; - - public CustomTypeService(CustomTypeRepository repository) { - this.repository = repository; - } - - /** Persistiert einen benutzerdefinierten Typ mit allen Daten. */ - public String save(BlockType custom, String baseTypeId) { - CustomTypeDocument document = new CustomTypeDocument(); - document.setTaskType(custom.taskType()); - document.setTypeId(custom.id()); - document.setLabel(custom.label()); - document.setBaseTypeId(baseTypeId); - document.setDescription(custom.description()); - document.setIcon(custom.icon()); - document.setColor(custom.color()); - document.setInputs(custom.inputs()); - document.setOutputs(custom.outputs()); - document.setProperties(custom.properties().stream() - .map(CustomPropertyState::of) - .toList()); - document.setCreatedAt(Instant.now()); - return repository.save(document).getId(); - } - - /** Löscht den gespeicherten benutzerdefinierten Typ mit der angegebenen Typnummer. */ - public void deleteByTaskType(int taskType) { - repository.deleteByTaskType(taskType); - } - - /** Lädt alle gespeicherten Typen und baut sie als {@link BlockType} auf. */ - public List loadAll() { - return repository.findAllByOrderByTaskTypeAsc().stream() - .map(CustomTypeService::toBlockType) - .toList(); - } - - private static BlockType toBlockType(CustomTypeDocument doc) { - return new BlockType( - doc.getTypeId(), - doc.getLabel(), - doc.getDescription(), - doc.getIcon(), - doc.getColor(), - doc.getTaskType(), - doc.getInputs(), - doc.getOutputs(), - doc.getProperties() == null ? List.of() - : doc.getProperties().stream() - .map(prop -> prop.toDefinition()) - .toList(), - doc.getBaseTypeId() - ); - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeValidator.java b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeValidator.java new file mode 100644 index 0000000..1337de3 --- /dev/null +++ b/src/main/java/de/assecutor/tasklisteditor/service/CustomTypeValidator.java @@ -0,0 +1,120 @@ +package de.assecutor.tasklisteditor.service; + +import com.fasterxml.jackson.databind.JsonNode; +import de.assecutor.tasklisteditor.model.BlockType; +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.PropertyDefinition; +import de.assecutor.tasklisteditor.model.PropertyType; + +import java.util.HashSet; +import java.util.Set; + +/** Prüft eigene Typen vor der Jackson-Konvertierung, ohne deren Werte still zu konvertieren. */ +public final class CustomTypeValidator { + + private CustomTypeValidator() { + } + + public static void validate(JsonNode definitions, BlockTypeRegistry registry) { + if (definitions == null || definitions.isNull()) { + return; + } + require(definitions.isArray(), "customTypes must be an array"); + Set ids = new HashSet<>(); + Set taskTypes = new HashSet<>(); + for (JsonNode definition : definitions) { + require(definition.isObject(), "Every custom type must be an object"); + String id = text(definition, "typeId", false); + require(ids.add(id) && registry.find(id).isEmpty() && !id.equals("script"), + "Duplicate or reserved custom type ID"); + int taskType = integer(definition, "taskType"); + require(taskType >= BlockTypeRegistry.CUSTOM_TYPE_START && taskType < Integer.MAX_VALUE + && taskTypes.add(taskType), + "Custom taskType must be unique and between 1000 and 2147483646"); + String baseId = text(definition, "baseTypeId", false); + BlockType base = registry.baseTypes().stream().filter(type -> type.id().equals(baseId)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown baseTypeId")); + text(definition, "label", false); + text(definition, "description", true); + text(definition, "icon", false); + String color = text(definition, "color", false); + require(color.matches("#[0-9a-fA-F]{3}([0-9a-fA-F]|[0-9a-fA-F]{3}|[0-9a-fA-F]{5})?"), + "color must be a hexadecimal CSS color"); + require(integer(definition, "inputs") == base.inputs() + && integer(definition, "outputs") == base.outputs(), + "Custom ports must match the base type"); + validateProperties(definition.get("properties"), base); + } + } + + private static void validateProperties(JsonNode properties, BlockType base) { + require(properties != null && properties.isArray(), "properties must be an array"); + require(properties.size() == base.properties().size(), "Properties must match the base type"); + Set ids = new HashSet<>(); + for (JsonNode property : properties) { + require(property.isObject(), "Every property must be an object"); + String id = text(property, "id", false); + require(ids.add(id), "Duplicate property ID"); + PropertyDefinition expected = base.properties().stream().filter(def -> def.id().equals(id)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown property ID")); + text(property, "label", false); + require(text(property, "type", false).equals(expected.type().name()), + "Property type must match the base type"); + validateDefault(property.get("defaultValue"), expected.type()); + } + } + + private static void validateDefault(JsonNode value, PropertyType type) { + require(value != null, "defaultValue is required"); + // Leere Vorgaben sind erlaubt; die Eingabefelder behandeln sie wie einen leeren Wert. + if (value.isNull()) { + return; + } + switch (type) { + case TEXT, TEXTAREA -> require(value.isTextual(), "Text default must be a string"); + case NUMBER -> require(value.isIntegralNumber() && value.canConvertToInt(), + "Number default must be an integer"); + case BOOLEAN -> require(value.isBoolean(), "Boolean default must be a boolean"); + case FIELD_LIST, NAME_LIST, CHOICE_LIST -> { + // Früher gespeicherte Statusoptionen können noch als mehrzeiliger Text vorliegen. + if (type == PropertyType.CHOICE_LIST && value.isTextual()) { + return; + } + require(value.isArray(), "List default must be an array"); + for (JsonNode row : value) { + require(row.isObject(), "Every list row must be an object"); + text(row, "title", true); + optionalField(row, "placeholder", true); + optionalField(row, "required", false); + } + } + } + } + + private static void optionalField(JsonNode object, String name, boolean string) { + JsonNode value = object.get(name); + if (value != null && !value.isNull()) { + require(string ? value.isTextual() : value.isBoolean(), "Invalid list row field " + name); + } + } + + private static String text(JsonNode object, String field, boolean allowBlank) { + JsonNode value = object.get(field); + require(value != null && value.isTextual() && (allowBlank || !value.textValue().isBlank()), + field + " must be a string" + (allowBlank ? "" : " and not blank")); + return value.textValue(); + } + + private static int integer(JsonNode object, String field) { + JsonNode value = object.get(field); + require(value != null && value.isIntegralNumber() && value.canConvertToInt(), + field + " must be an integer"); + return value.intValue(); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java b/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java deleted file mode 100644 index eb0ae0f..0000000 --- a/src/main/java/de/assecutor/tasklisteditor/service/ElementSettingsService.java +++ /dev/null @@ -1,70 +0,0 @@ -package de.assecutor.tasklisteditor.service; - -import de.assecutor.tasklisteditor.config.ElementCollectionProperties; -import de.assecutor.tasklisteditor.document.ElementSettingsDocument; -import de.assecutor.tasklisteditor.model.BlockInstance; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.Collection; -import java.util.LinkedHashMap; - -/** - * Speichert die Einstellungen der einzelnen platzierten Elemente eines Canvas - * jeweils in einer eigenen, pro Element-Typ konfigurierten MongoDB-Collection. - * - *

Wird ergänzend zum {@link CanvasService} aufgerufen: Der Canvas-Stand - * (Layout + Block-Werte) bleibt unverändert im Canvas-Dokument; zusätzlich wird - * je Element ein {@link ElementSettingsDocument} in die Typ-Collection - * geschrieben und über die Canvas-ID verknüpft. - */ -@Service -public class ElementSettingsService { - - private final MongoTemplate mongoTemplate; - private final ElementCollectionProperties properties; - - public ElementSettingsService(MongoTemplate mongoTemplate, - ElementCollectionProperties properties) { - this.mongoTemplate = mongoTemplate; - this.properties = properties; - } - - /** - * Schreibt für jedes Element des Canvas ein Einstellungs-Dokument in die - * Collection seines Typs. - * - * @param canvasId ID des gespeicherten Canvas (Verknüpfung) - * @param canvasName Anzeigename des Canvas - * @param instances platzierte Block-Instanzen - */ - public void saveForCanvas(String canvasId, String canvasName, - Collection instances) { - Instant now = Instant.now(); - for (BlockInstance instance : instances) { - // Virtuelle Blöcke (Start/Abschluss) haben keine fachlichen - // Einstellungen und keine Collection – sie werden nicht persistiert. - if (instance.type().isVirtual()) { - continue; - } - String typeId = instance.type().id(); - String baseTypeId = instance.type().baseTypeId(); - // Benutzerdefinierte Typen landen in der Collection ihres statischen - // Basistyps; feste Typen (baseTypeId == null) in ihrer eigenen. - String collectionTypeId = (baseTypeId == null || baseTypeId.isBlank()) - ? typeId - : baseTypeId; - ElementSettingsDocument document = new ElementSettingsDocument( - canvasId, - canvasName, - instance.id(), - typeId, - baseTypeId, - instance.topic(), - new LinkedHashMap<>(instance.values()), - now); - mongoTemplate.save(document, properties.collectionFor(collectionTypeId)); - } - } -} diff --git a/src/main/java/de/assecutor/tasklisteditor/service/PendingImportStore.java b/src/main/java/de/assecutor/tasklisteditor/service/PendingImportStore.java index fc04228..65f2d16 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/PendingImportStore.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/PendingImportStore.java @@ -17,11 +17,12 @@ import java.util.concurrent.ConcurrentHashMap; * Request-Body tragen kann, nimmt der REST-Endpunkt die Daten per {@code POST} * entgegen, legt sie hier unter einem Token ab und liefert einen Deep-Link mit * diesem Token zurück. Der Editor liest die Daten beim Öffnen anhand des Tokens - * wieder aus (und entfernt sie dabei). + * wieder aus. Das Token bleibt bis zum Ablauf gültig, damit ein Neuladen + * des Editors denselben Aufruf wiederherstellen kann. * *

Bewusst In-Memory gehalten (transienter Hand-off). Für einen - * mehrinstanzigen Betrieb müsste hier ein geteilter Speicher (z. B. - * MongoDB mit TTL oder Redis) verwendet werden. + * mehrinstanzigen Betrieb müsste hier ein geteilter temporärer Speicher + * verwendet werden. */ @Component public class PendingImportStore { @@ -41,11 +42,21 @@ public class PendingImportStore { return token; } + /** Liest die Nutzlast bis zum Ablauf wiederholt aus, etwa bei Browser-Reloads. */ + public Optional find(String token) { + purgeExpired(); + Entry entry = entries.get(token); + if (entry == null || !entry.expiresAt().isAfter(Instant.now())) { + return Optional.empty(); + } + return Optional.of(entry.payload()); + } + /** Liest die Nutzlast einmalig aus und entfernt sie. */ public Optional consume(String token) { purgeExpired(); Entry entry = entries.remove(token); - if (entry == null || entry.expiresAt().isBefore(Instant.now())) { + if (entry == null || !entry.expiresAt().isAfter(Instant.now())) { return Optional.empty(); } return Optional.of(entry.payload()); @@ -53,6 +64,6 @@ public class PendingImportStore { private void purgeExpired() { Instant now = Instant.now(); - entries.entrySet().removeIf(e -> e.getValue().expiresAt().isBefore(now)); + entries.entrySet().removeIf(e -> !e.getValue().expiresAt().isAfter(now)); } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java index f861867..bb21906 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java @@ -37,12 +37,17 @@ public class TaskListImporter { } public List resolve(List tasks) { + return resolve(tasks, registry); + } + + /** Verwendet den Katalog des konkreten Editor-Aufrufs. */ + public List resolve(List tasks, BlockTypeRegistry editorRegistry) { List resolved = new ArrayList<>(); if (tasks == null) { return resolved; } for (TaskDto task : tasks) { - BlockType type = findByTaskType(task.getType()); + BlockType type = findByTaskType(task.getType(), editorRegistry); if (type == null) { continue; } @@ -54,8 +59,8 @@ public class TaskListImporter { return resolved; } - private BlockType findByTaskType(int taskType) { - return registry.all().stream() + private BlockType findByTaskType(int taskType, BlockTypeRegistry editorRegistry) { + return editorRegistry.all().stream() .filter(t -> t.taskType() == taskType) .findFirst() .orElse(null); @@ -68,9 +73,12 @@ public class TaskListImporter { // Property-Typs in die vom Zeilen-Editor erwartete Struktur // (Liste aus {title}/{title,placeholder}-Maps) zurückgewandelt. Object value = switch (def.type()) { - case FIELD_LIST -> fieldRows(task); - case NAME_LIST -> entryRows(task); - case CHOICE_LIST -> choiceRows(task); + case FIELD_LIST -> task.getFields() == null + ? copyValue(def.defaultValue()) : fieldRows(task); + case NAME_LIST -> task.getEntries() == null + ? copyValue(def.defaultValue()) : entryRows(task); + case CHOICE_LIST -> task.getChoices() == null && task.getEntries() == null + ? copyValue(def.defaultValue()) : choiceRows(task); default -> scalarValue(def, task); }; values.put(def.id(), value); @@ -78,6 +86,21 @@ public class TaskListImporter { return values; } + /** Vorgabelisten gehören zur Typdefinition und dürfen beim Bearbeiten nicht verändert werden. */ + private Object copyValue(Object value) { + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(item -> copy.add(copyValue(item))); + return copy; + } + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, item) -> copy.put(key, copyValue(item))); + return copy; + } + return value; + } + private Object scalarValue(PropertyDefinition def, TaskDto task) { return switch (def.id()) { case "opt" -> orDefault(task.getOpt(), def.defaultValue()); @@ -129,7 +152,7 @@ public class TaskListImporter { /** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */ private List> choiceRows(TaskDto task) { List> rows = new ArrayList<>(); - if (task.getChoices() != null && !task.getChoices().isEmpty()) { + if (task.getChoices() != null) { for (ChoiceDto choice : task.getChoices()) { Map row = new LinkedHashMap<>(); row.put("title", choice.getTxt() == null ? "" : choice.getTxt()); diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java index d931374..823db21 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListService.java @@ -2,33 +2,26 @@ package de.assecutor.tasklisteditor.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import de.assecutor.tasklisteditor.document.TaskListDocument; import de.assecutor.tasklisteditor.model.BlockInstance; import de.assecutor.tasklisteditor.model.TaskDto; -import de.assecutor.tasklisteditor.repository.TaskListRepository; import org.springframework.stereotype.Service; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; /** - * Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON und speichert - * die Liste in MongoDB. + * Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON für den Aufrufer. */ @Service public class TaskListService { private final TaskListExporter exporter; - private final TaskListRepository repository; private final ObjectMapper objectMapper; public TaskListService(TaskListExporter exporter, - TaskListRepository repository, ObjectMapper objectMapper) { this.exporter = exporter; - this.repository = repository; this.objectMapper = objectMapper; } @@ -102,13 +95,4 @@ public class TaskListService { } } - /** - * Speichert die Task-Liste in MongoDB. - * - * @return die vergebene Dokument-ID - */ - public String save(String name, List tasks, String layout) { - TaskListDocument document = new TaskListDocument(name, Instant.now(), tasks, layout); - return repository.save(document).getId(); - } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java b/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java index e23b734..16aad29 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/WorkflowAssistantService.java @@ -85,6 +85,12 @@ public class WorkflowAssistantService { * die Antwort nicht verwertbar ist */ public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson) { + return generateWorkflow(wish, currentName, currentTasksJson, registry.all()); + } + + /** Nutzt einen Snapshot der Typen des aufrufenden Editors, auch im Hintergrund. */ + public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson, + List availableTypes) { if (!isEnabled()) { throw new IllegalStateException( "KI-Assistent deaktiviert: AI_ENABLED=true und ANTHROPIC_API_KEY " @@ -98,7 +104,7 @@ public class WorkflowAssistantService { .model(model) .maxTokens(16000L) .thinking(ThinkingConfigAdaptive.builder().build()) - .system(buildSystemPrompt()) + .system(buildSystemPrompt(availableTypes)) .addUserMessage(buildUserMessage(wish, currentName, currentTasksJson)) .build(); @@ -193,7 +199,7 @@ public class WorkflowAssistantService { * {@link BlockTypeRegistry}, inkl. benutzerdefinierter Typen) und das * erwartete JSON-Format. */ - private String buildSystemPrompt() { + private String buildSystemPrompt(List availableTypes) { StringBuilder prompt = new StringBuilder(); prompt.append(""" Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows @@ -243,7 +249,7 @@ public class WorkflowAssistantService { Verfügbare Element-Typen: """); - for (BlockType type : registry.all()) { + for (BlockType type : availableTypes) { if ("script".equals(type.id())) { continue; // Script-Blöcke kann der Assistent nicht sinnvoll konfigurieren. } diff --git a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java index 410f0c3..47aed46 100644 --- a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java +++ b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java @@ -7,7 +7,6 @@ import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.dialog.Dialog; -import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H2; import com.vaadin.flow.component.html.H3; @@ -33,14 +32,10 @@ import de.assecutor.tasklisteditor.component.ScriptEditor; import de.assecutor.tasklisteditor.model.BlockInstance; import de.assecutor.tasklisteditor.model.BlockType; import de.assecutor.tasklisteditor.model.BlockTypeRegistry; -import de.assecutor.tasklisteditor.model.CanvasBlockState; +import de.assecutor.tasklisteditor.model.CustomTypeDefinition; import de.assecutor.tasklisteditor.model.PropertyDefinition; import de.assecutor.tasklisteditor.model.TaskDto; import de.assecutor.tasklisteditor.model.TaskListPayload; -import de.assecutor.tasklisteditor.document.CanvasDocument; -import de.assecutor.tasklisteditor.service.CanvasService; -import de.assecutor.tasklisteditor.service.CustomTypeService; -import de.assecutor.tasklisteditor.service.ElementSettingsService; import de.assecutor.tasklisteditor.service.PendingImportStore; import de.assecutor.tasklisteditor.service.TaskListImporter; import de.assecutor.tasklisteditor.service.TaskListService; @@ -50,10 +45,9 @@ import elemental.json.JsonArray; import elemental.json.JsonObject; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.springframework.beans.factory.annotation.Value; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -78,9 +72,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private final TaskListService taskListService; private final TaskListImporter taskListImporter; private final PendingImportStore importStore; - private final CanvasService canvasService; - private final CustomTypeService customTypeService; - private final ElementSettingsService elementSettingsService; private final WorkflowAssistantService workflowAssistantService; private final ObjectMapper objectMapper; private final NodeEditor nodeEditor; @@ -97,28 +88,25 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { return size() > 100; } }; - private Div palettePanel; + private Div customPalettePanel; private String currentListName; + private String importToken; + private String returnOrigin; + private JsonNode importMetadata; private final String appVersion; public MainView(BlockTypeRegistry registry, TaskListService taskListService, TaskListImporter taskListImporter, PendingImportStore importStore, - CanvasService canvasService, - CustomTypeService customTypeService, - ElementSettingsService elementSettingsService, WorkflowAssistantService workflowAssistantService, ObjectMapper objectMapper, @Value("${tasklist.app-version:}") String appVersion) { this.appVersion = appVersion; - this.registry = registry; + this.registry = registry.forEditor(); this.taskListService = taskListService; this.taskListImporter = taskListImporter; this.importStore = importStore; - this.canvasService = canvasService; - this.customTypeService = customTypeService; - this.elementSettingsService = elementSettingsService; this.workflowAssistantService = workflowAssistantService; this.objectMapper = objectMapper; setSizeFull(); @@ -126,8 +114,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { setSpacing(false); addClassName("tasklist-app"); - loadCustomTypes(); - add(buildHeader()); HorizontalLayout body = new HorizontalLayout(); @@ -143,10 +129,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { body.expand(nodeEditor); add(body); - nodeEditor.registerBlockTypes(registry.all()); + nodeEditor.registerBlockTypes(this.registry.all()); nodeEditor.addNodeAddedListener(e -> { - BlockType type = registry.find(e.typeId()).orElse(null); + BlockType type = this.registry.find(e.typeId()).orElse(null); if (type == null) return; instances.put(e.nodeId(), new BlockInstance(e.nodeId(), type)); }); @@ -200,6 +186,13 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } restored = new BlockInstance(id, type); } + if (isCustomType(restored.type()) && registry.find(typeId).isEmpty()) { + // Undo eines entfernten Blocks stellt auch seinen inzwischen + // gelöschten Typ wieder her, damit die Rückgabe vollständig bleibt. + registry.registerCustom(restored.type()); + addCustomPaletteItem(restored.type()); + nodeEditor.registerBlockTypes(registry.all()); + } instances.put(id, restored); }); } @@ -225,13 +218,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { title.add(version); } - Button assistant = new Button("KI-Assistent", new Icon(VaadinIcon.MAGIC)); - assistant.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - assistant.setTooltipText("Workflow aus einer Beschreibung erzeugen oder ändern (Claude)"); - assistant.addClickListener(e -> openAssistantDialog()); - // KI-Funktionalität per .env (AI_ENABLED) abschaltbar. - assistant.setVisible(workflowAssistantService.isEnabled()); - Button undo = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD)); undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY); undo.setTooltipText("Letzte Canvas-Änderung zurücknehmen (Strg+Z, max. 10 Schritte)"); @@ -241,39 +227,26 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } })); - Button clear = new Button("Workflow löschen", new Icon(VaadinIcon.TRASH)); - clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - clear.addClickListener(e -> { - nodeEditor.clear(); - // In den Papierkorb statt verwerfen: Undo kann das Leeren zurücknehmen. - removedInstances.putAll(instances); - instances.clear(); - Notification.show("Canvas geleert"); - }); + Button cancel = new Button("Abbrechen", new Icon(VaadinIcon.CLOSE)); + cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + cancel.setTooltipText("Bearbeitung ohne Übernahme abbrechen"); + cancel.addClickListener(e -> returnToCaller("cancelled", null)); - Button saveCanvas = new Button("Workflow speichern", new Icon(VaadinIcon.HARDDRIVE)); - saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - saveCanvas.addClickListener(e -> saveCanvas()); - - Button loadCanvas = new Button("Worklow laden", new Icon(VaadinIcon.FOLDER_OPEN)); - loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - loadCanvas.addClickListener(e -> openLoadCanvasDialog()); - - Button export = new Button("JSON erzeugen & speichern", new Icon(VaadinIcon.DOWNLOAD)); - export.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - export.addClickListener(e -> exportTasks()); + Button finish = new Button("Fertig", new Icon(VaadinIcon.CHECK)); + finish.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + finish.setTooltipText("Bearbeitung abschließen"); + finish.addClickListener(e -> exportTasks()); Span spacer = new Span(); spacer.getStyle().set("flex", "1"); - header.add(title, spacer, assistant, undo, clear, saveCanvas, loadCanvas, export); + header.add(title, spacer, undo, cancel, finish); return header; } private Div buildPalette() { Div panel = new Div(); panel.addClassName("palette-panel"); - palettePanel = panel; H3 heading = new H3("Funktionsblöcke"); heading.addClassName("panel-heading"); @@ -283,6 +256,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { hint.addClassName("panel-hint"); panel.add(hint); + Button assistant = new Button("KI-Assistent", new Icon(VaadinIcon.MAGIC)); + assistant.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + assistant.setWidthFull(); + assistant.setTooltipText("Workflow aus einer Beschreibung erzeugen oder ändern (Claude)"); + assistant.addClickListener(e -> openAssistantDialog()); + assistant.setVisible(workflowAssistantService.isEnabled()); + panel.add(assistant); + // Standardelemente for (BlockType type : registry.all()) { if (!isCustomType(type)) { @@ -301,6 +282,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { customType.addClickListener(e -> openCustomTypeDialog()); panel.add(customType); + customPalettePanel = new Div(); + customPalettePanel.getStyle().set("display", "contents"); + panel.add(customPalettePanel); + // Selbst definierte Elemente for (BlockType type : registry.all()) { if (isCustomType(type)) { @@ -316,7 +301,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { /** Fügt ein benutzerdefiniertes Element ans Ende der Palette an (nach dem Button). */ private void addCustomPaletteItem(BlockType type) { - palettePanel.add(buildPaletteItem(type)); + customPalettePanel.add(buildPaletteItem(type)); } private Component buildPaletteDivider() { @@ -328,24 +313,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { return divider; } - /** - * Lädt die in MongoDB gespeicherten benutzerdefinierten Typen und registriert - * sie, sofern sie noch nicht bekannt sind. Fehlt die Datenbank, startet der - * Editor trotzdem (nur ohne die gespeicherten Typen). - */ - private void loadCustomTypes() { - try { - for (BlockType custom : customTypeService.loadAll()) { - if (registry.find(custom.id()).isEmpty()) { - registry.registerCustom(custom); - } - } - } catch (Exception ex) { - Notification.show("Eigene Typen konnten nicht geladen werden (läuft MongoDB?): " - + ex.getMessage()); - } - } - /** * Dialog zum Anlegen eines benutzerdefinierten Typs: vergibt die nächste freie * Typnummer ab 1000, lässt einen der festen Typen (1–8) als Basis wählen und @@ -356,7 +323,8 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { dialog.setHeaderTitle("Eigenen Typ anlegen"); dialog.setWidth("440px"); - int number = registry.nextCustomTaskType(); + int number = registry.nextCustomTaskType(removedInstances.values().stream() + .map(instance -> instance.type().taskType()).toList()); IntegerField numberField = new IntegerField("Typnummer"); numberField.setWidthFull(); @@ -418,12 +386,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { value == null ? def.defaultValue() : value)); } BlockType custom = registry.registerCustom(number, base, label.trim(), props); - try { - customTypeService.save(custom, base.id()); - } catch (Exception ex) { - Notification.show("Typ angelegt, aber nicht gespeichert (läuft MongoDB?): " - + ex.getMessage()); - } addCustomPaletteItem(custom); nodeEditor.registerBlockTypes(registry.all()); Notification.show("Typ " + number + " „" + custom.label() + "“ angelegt."); @@ -498,16 +460,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } private void deleteCustomType(BlockType type, Component tile) { + if (instances.values().stream().anyMatch(instance -> instance.type().id().equals(type.id()))) { + Notification.show("Dieser Typ wird im Workflow verwendet. Bitte zuerst die zugehörigen Blöcke entfernen."); + return; + } if (!registry.removeCustom(type.id())) { return; } - try { - customTypeService.deleteByTaskType(type.taskType()); - } catch (Exception ex) { - Notification.show("Typ entfernt, aber nicht aus der DB gelöscht (läuft MongoDB?): " - + ex.getMessage()); - } - palettePanel.remove(tile); + customPalettePanel.remove(tile); nodeEditor.registerBlockTypes(registry.all()); Notification.show("Typ " + type.taskType() + " „" + type.label() + "“ gelöscht."); } @@ -850,7 +810,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { * aus einem Titel und – falls {@code withPlaceholder} – zusätzlich einem * Platzhalter sowie – falls {@code withRequired} – einer Pflichtfeld- * Checkbox. Der Wert wird als {@code List>} unter der - * Property-ID abgelegt (rundläuft über Mongo und Export). + * Property-ID abgelegt (bleibt bei Übergabe und Rückgabe erhalten). */ private Component buildRowList(BlockInstance instance, PropertyDefinition def, Object current, boolean withPlaceholder, boolean withRequired) { @@ -960,17 +920,56 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { @Override public void beforeEnter(BeforeEnterEvent event) { + // Auch bei Navigation innerhalb derselben View beginnt jeder Aufruf + // mit eigenem Kontext und darf keine vorherigen Tasks übernehmen. + importToken = null; + returnOrigin = null; + importMetadata = null; + currentListName = null; + instances.clear(); + removedInstances.clear(); + registry.clearCustom(); + customPalettePanel.removeAll(); + nodeEditor.registerBlockTypes(registry.all()); + nodeEditor.importGraph("{\"drawflow\":{\"Home\":{\"data\":{}}}}"); List tokens = event.getLocation().getQueryParameters() .getParameters().getOrDefault(IMPORT_PARAM, List.of()); if (tokens.isEmpty()) { return; } - importStore.consume(tokens.get(0)).ifPresent(this::loadPayload); + importToken = tokens.get(0); + importStore.find(importToken).ifPresentOrElse(this::loadPayload, + () -> Notification.show("Der Aufruf ist ungültig oder abgelaufen. Bitte den Editor erneut öffnen.")); } /** Baut den Canvas aus den per Deep-Link übergebenen Daten auf. */ private void loadPayload(TaskListPayload payload) { - buildCanvasFromTasks(payload.getTasks(), payload.getName(), "aus Deep-Link geladen"); + currentListName = payload.getName(); + importMetadata = payload.getMetadata(); + returnOrigin = payload.getReturnOrigin(); + for (CustomTypeDefinition definition : payload.getCustomTypes()) { + BlockType custom = registry.registerCustom(definition.toBlockType()); + addCustomPaletteItem(custom); + } + nodeEditor.registerBlockTypes(registry.all()); + if (payload.getTasks() == null || payload.getTasks().isEmpty()) { + return; + } + + // App-JSON enthält keine virtuellen Workflow-Markierungen. Sie werden + // beim Öffnen ergänzt, damit der importierte Ablauf sofort gültig ist. + List tasks = new ArrayList<>(payload.getTasks()); + if (tasks.stream().noneMatch(t -> t.getType() == BlockTypeRegistry.START_TASK_TYPE)) { + TaskDto start = new TaskDto(); + start.setType(BlockTypeRegistry.START_TASK_TYPE); + tasks.add(0, start); + } + if (tasks.stream().noneMatch(t -> t.getType() == BlockTypeRegistry.END_TASK_TYPE)) { + TaskDto end = new TaskDto(); + end.setType(BlockTypeRegistry.END_TASK_TYPE); + tasks.add(end); + } + buildCanvasFromTasks(tasks, payload.getName(), "aus Deep-Link geladen"); } /** @@ -979,7 +978,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { * Deep-Link-Import und vom KI-Assistenten genutzt. */ private void buildCanvasFromTasks(List tasks, String name, String successHint) { - List resolved = taskListImporter.resolve(tasks); + List resolved = taskListImporter.resolve(tasks, registry); if (resolved.isEmpty()) { Notification.show("Keine verwertbaren Elemente gefunden."); return; @@ -1100,6 +1099,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { progressLabel.setVisible(true); UI ui = UI.getCurrent(); + List availableTypes = registry.all(); ui.setPollInterval(500); if (modifying) { // Aktuellen Canvas als Task-JSON exportieren und als Kontext mitgeben. @@ -1118,12 +1118,12 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { } runAssistant(dialog, generate, wishes, progress, progressLabel, ui, "per KI-Assistent geändert", - () -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson)); + () -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson, availableTypes)); }); } else { runAssistant(dialog, generate, wishes, progress, progressLabel, ui, "per KI-Assistent erstellt", - () -> workflowAssistantService.generateWorkflow(wish)); + () -> workflowAssistantService.generateWorkflow(wish, null, null, availableTypes)); } }); @@ -1159,183 +1159,85 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { })); } - // --- Canvas speichern / laden (MongoDB) --- - - private void saveCanvas() { - if (instances.isEmpty()) { - Notification.show("Der Canvas ist leer – nichts zu speichern."); - return; - } - nodeEditor.exportGraph(layout -> openSaveCanvasDialog(layout)); - } - - private void openSaveCanvasDialog(String layout) { - Dialog dialog = new Dialog(); - dialog.setHeaderTitle("Canvas speichern"); - dialog.setWidth("440px"); - - TextField name = new TextField("Name"); - name.setWidthFull(); - name.setValue(currentListName != null && !currentListName.isBlank() - ? currentListName - : "Canvas " + instances.size() + " Blöcke"); - - dialog.add(name); - - Button save = new Button("Speichern", new Icon(VaadinIcon.HARDDRIVE), ev -> { - try { - String id = canvasService.save(name.getValue(), layout, collectBlocks()); - elementSettingsService.saveForCanvas(id, name.getValue(), instances.values()); - currentListName = name.getValue(); - Notification.show("Canvas gespeichert (ID: " + id + ")"); - dialog.close(); - } catch (Exception ex) { - Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage()); - } - }); - save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - Button cancel = new Button("Abbrechen", ev -> dialog.close()); - cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - - dialog.getFooter().add(cancel, save); - dialog.open(); - } - - private List collectBlocks() { - List blocks = new ArrayList<>(); - instances.forEach((id, instance) -> blocks.add(new CanvasBlockState( - id, - instance.type().id(), - instance.topic(), - new LinkedHashMap<>(instance.values())))); - return blocks; - } - - private void openLoadCanvasDialog() { - List saved; - try { - saved = canvasService.listNewestFirst(); - } catch (Exception ex) { - Notification.show("Laden fehlgeschlagen (läuft MongoDB?): " + ex.getMessage()); - return; - } - if (saved.isEmpty()) { - Notification.show("Keine gespeicherten Canvas-Stände vorhanden."); - return; - } - - Dialog dialog = new Dialog(); - dialog.setHeaderTitle("Canvas laden"); - dialog.setWidth("560px"); - - DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm") - .withZone(ZoneId.systemDefault()); - - Grid grid = new Grid<>(); - grid.addColumn(d -> d.getName()).setHeader("Name").setAutoWidth(true); - grid.addColumn(d -> d.getBlocks() == null ? 0 : d.getBlocks().size()).setHeader("Blöcke"); - grid.addColumn(d -> d.getCreatedAt() == null ? "" : fmt.format(d.getCreatedAt())) - .setHeader("Gespeichert"); - grid.setItems(saved); - grid.setHeight("320px"); - dialog.add(grid); - - Button load = new Button("Laden", new Icon(VaadinIcon.FOLDER_OPEN), ev -> { - CanvasDocument selected = grid.asSingleSelect().getValue(); - if (selected == null) { - Notification.show("Bitte einen Eintrag auswählen."); - return; - } - loadCanvas(selected); - dialog.close(); - }); - load.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - Button cancel = new Button("Abbrechen", ev -> dialog.close()); - cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - - dialog.getFooter().add(cancel, load); - dialog.open(); - } - - private void loadCanvas(CanvasDocument document) { - instances.clear(); - if (document.getBlocks() != null) { - for (CanvasBlockState block : document.getBlocks()) { - BlockType type = registry.find(block.getTypeId()).orElse(null); - if (type == null) { - continue; - } - BlockInstance instance = new BlockInstance(block.getNodeId(), type); - instance.setTopic(block.getTopic()); - if (block.getValues() != null) { - block.getValues().forEach(instance::set); - } - instances.put(block.getNodeId(), instance); - } - } - currentListName = document.getName(); - nodeEditor.importGraph(document.getLayout()); - Notification.show("Canvas „" + document.getName() + "“ geladen."); - } - private void exportTasks() { if (instances.isEmpty()) { - Notification.show("Es sind keine Funktionsblöcke auf dem Canvas."); + finishExport(new TaskListService.ExportResult(List.of(), "[]")); return; } nodeEditor.exportGraph(graphJson -> { try { TaskListService.ExportResult result = taskListService.build(graphJson, instances); - if (result.tasks().isEmpty()) { - Notification.show("Keine Tasks gefunden."); - return; - } - openExportDialog(result, graphJson); + finishExport(result); } catch (Exception ex) { Notification.show("Export fehlgeschlagen: " + ex.getMessage()); } }); } - private void openExportDialog(TaskListService.ExportResult result, String layout) { + private void finishExport(TaskListService.ExportResult result) { + if (returnOrigin != null) { + returnToCaller("completed", result.tasks()); + } else { + openExportDialog(result); + } + } + + private List customTypes() { + return registry.all().stream().filter(this::isCustomType) + .map(CustomTypeDefinition::from).toList(); + } + + /** Gibt das Ergebnis an das einbettende Fenster oder den Popup-Aufrufer zurück. */ + private void returnToCaller(String status, List tasks) { + if (returnOrigin == null || importToken == null) { + Notification.show("Kein Aufrufer verbunden. Bitte den Editor über die aufrufende Seite öffnen."); + return; + } + + ObjectNode result = objectMapper.createObjectNode(); + result.put("type", "tasklist-editor:result"); + result.put("token", importToken); + result.put("status", status); + result.put("name", currentListName); + result.set("metadata", importMetadata); + if (tasks != null) { + result.set("tasks", objectMapper.valueToTree(tasks)); + result.set("customTypes", objectMapper.valueToTree(customTypes())); + } + + getUI().ifPresent(ui -> ui.getPage().executeJs(""" + const caller = window.parent !== window ? window.parent : window.opener; + if (!caller || caller.closed) return false; + caller.postMessage(JSON.parse($0), $1); + return true; + """, result.toString(), returnOrigin).then(Boolean.class, sent -> { + if (!Boolean.TRUE.equals(sent)) { + Notification.show("Der Aufrufer ist nicht mehr erreichbar. Bitte den Editor dort erneut öffnen."); + } + })); + } + + private void openExportDialog(TaskListService.ExportResult result) { Dialog dialog = new Dialog(); - dialog.setHeaderTitle("Task-JSON (" + result.tasks().size() + " Tasks)"); + dialog.setHeaderTitle("JSON (" + result.tasks().size() + " Tasks)"); dialog.setWidth("680px"); - TextField name = new TextField("Name der Task-Liste"); - name.setWidthFull(); - name.setValue(currentListName != null && !currentListName.isBlank() - ? currentListName - : "Task-Liste (" + result.tasks().size() + " Tasks)"); + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("name", currentListName); + payload.set("metadata", importMetadata); + payload.set("tasks", objectMapper.valueToTree(result.tasks())); + payload.set("customTypes", objectMapper.valueToTree(customTypes())); - TextArea json = new TextArea("App-JSON (tasks-Array)"); + TextArea json = new TextArea("Tasks und eigene Typen"); json.setWidthFull(); json.setHeight("360px"); json.setReadOnly(true); - json.setValue(result.json()); - - VerticalLayout content = new VerticalLayout(name, json); - content.setPadding(false); - content.setSpacing(true); - dialog.add(content); - - Button save = new Button("In MongoDB speichern", new Icon(VaadinIcon.DATABASE), ev -> { - try { - String id = taskListService.save(name.getValue(), result.tasks(), layout); - Notification.show("In MongoDB gespeichert (ID: " + id + ")"); - dialog.close(); - } catch (Exception ex) { - Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage()); - } - }); - save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + json.setValue(payload.toPrettyString()); + dialog.add(json); Button close = new Button("Schließen", ev -> dialog.close()); close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - - dialog.getFooter().add(close, save); + dialog.getFooter().add(close); dialog.open(); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a8287af..99ef110 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,18 +17,6 @@ management.health.readinessstate.enabled=true logging.level.org.atmosphere=WARN -# MongoDB – Speicherung der erzeugten Task-Listen. -# Zugangsdaten werden aus der .env-Datei geladen (spring-dotenv). -# Verbindung wird erst beim ersten Speichern aufgebaut, d.h. die Anwendung -# startet auch ohne laufende MongoDB. -spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017} -spring.data.mongodb.database=${MONGODB_DATABASE:tasklist_editor} -# Legt die @Indexed-Indizes (u. a. eindeutige Typnummer) bei Bedarf an. -spring.data.mongodb.auto-index-creation=true - -# Health-Check soll nicht fehlschlagen, wenn keine MongoDB läuft. -management.health.mongo.enabled=false - # Script-Block (grafisches Logik-Element) im Editor ausblenden. # Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert. tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false} @@ -40,22 +28,3 @@ tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false} tasklist.ai-enabled=${AI_ENABLED:false} tasklist.anthropic-api-key=${ANTHROPIC_API_KEY:} tasklist.anthropic-model=${ANTHROPIC_MODEL:claude-opus-4-8} - -# --- Collections je Element-Typ --------------------------------------------- -# Einstellungen der einzelnen Elemente werden beim Canvas-Speichern zusätzlich -# je Element-Typ in eine eigene Collection geschrieben. Namen aus der .env -# (MONGODB_ELEMENT_*), mit Default. Benutzerdefinierte Typen werden auf ihren -# statischen Basistyp aufgelöst; für jeden Typ muss eine Zuordnung existieren. -tasklist.element-collections[ankunft-melden]=${MONGODB_ELEMENT_ARRIVAL_COLLECTION:element_arrival} -tasklist.element-collections[unterschrift-erfassen]=${MONGODB_ELEMENT_SIGNATURE_COLLECTION:element_signature} -tasklist.element-collections[fotos-aufnehmen]=${MONGODB_ELEMENT_PHOTOS_COLLECTION:element_photos} -tasklist.element-collections[quittungsgeber-erfassen]=${MONGODB_ELEMENT_RECEIPT_GIVER_COLLECTION:element_receipt_giver} -tasklist.element-collections[bemerkung-erfassen]=${MONGODB_ELEMENT_REMARK_COLLECTION:element_remark} -tasklist.element-collections[kommissionsnummer-erfassen]=${MONGODB_ELEMENT_COMMISSION_NUMBER_COLLECTION:element_commission_number} -tasklist.element-collections[checkbox]=${MONGODB_ELEMENT_CHECKBOX_COLLECTION:element_checkbox} -tasklist.element-collections[barcode-scannen]=${MONGODB_ELEMENT_BARCODE_COLLECTION:element_barcode} -tasklist.element-collections[statusauswahl]=${MONGODB_ELEMENT_STATUS_SELECTION_COLLECTION:element_status_selection} -tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LIST_COLLECTION:element_text_list} -tasklist.element-collections[zahl-liste-erfassen]=${MONGODB_ELEMENT_NUMBER_LIST_COLLECTION:element_number_list} -tasklist.element-collections[checkbox-liste-erfassen]=${MONGODB_ELEMENT_CHECKBOX_LIST_COLLECTION:element_checkbox_list} -tasklist.element-collections[script]=${MONGODB_ELEMENT_SCRIPT_COLLECTION:element_script} diff --git a/src/test/java/de/assecutor/tasklisteditor/model/CustomTypeDefinitionTest.java b/src/test/java/de/assecutor/tasklisteditor/model/CustomTypeDefinitionTest.java new file mode 100644 index 0000000..73c936a --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/model/CustomTypeDefinitionTest.java @@ -0,0 +1,53 @@ +package de.assecutor.tasklisteditor.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +class CustomTypeDefinitionTest { + + @Test + @SuppressWarnings("unchecked") + void snapshotsAndReopeningDoNotShareMutableDefaults() throws Exception { + Map row = new LinkedHashMap<>(); + row.put("title", "Referenz"); + row.put("placeholder", "Nummer eingeben"); + row.put("required", true); + List> rows = new ArrayList<>(); + rows.add(row); + BlockType custom = new BlockType("custom-1005", "Eigene Liste", "Beschreibung", "vaadin:input", + "#123456", 1005, 1, 1, + List.of(new PropertyDefinition("fields", "Felder", PropertyType.FIELD_LIST, rows)), + "text-liste-erfassen"); + + CustomTypeDefinition snapshot = CustomTypeDefinition.from(custom); + row.put("title", "Nachträglich geändert"); + ObjectMapper mapper = new ObjectMapper(); + CustomTypeDefinition received = mapper.readValue(mapper.writeValueAsString(snapshot), + CustomTypeDefinition.class); + BlockType firstEditor = received.toBlockType(); + BlockType secondEditor = received.toBlockType(); + List> firstRows = + (List>) firstEditor.properties().getFirst().defaultValue(); + List> secondRows = + (List>) secondEditor.properties().getFirst().defaultValue(); + + assertEquals("Referenz", firstRows.getFirst().get("title")); + firstRows.getFirst().put("title", "Nur im ersten Editor"); + firstRows.add(Map.of("title", "Zusatz")); + + assertNotSame(firstRows, secondRows); + assertEquals(1, secondRows.size()); + assertEquals("Referenz", secondRows.getFirst().get("title")); + assertEquals(mapper.valueToTree(snapshot), mapper.valueToTree(received)); + assertEquals("text-liste-erfassen", secondEditor.baseTypeId()); + assertEquals(1005, secondEditor.taskType()); + } +} diff --git a/src/test/java/de/assecutor/tasklisteditor/model/TaskDtoTest.java b/src/test/java/de/assecutor/tasklisteditor/model/TaskDtoTest.java new file mode 100644 index 0000000..7e2e451 --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/model/TaskDtoTest.java @@ -0,0 +1,23 @@ +package de.assecutor.tasklisteditor.model; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class TaskDtoTest { + + @Test + void preservesSequenceIdUsingOnlyTheAppFieldName() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + TaskDto task = mapper.readValue("{\"s_id\": 42, \"type\": 3}", TaskDto.class); + + assertEquals(42, task.getSId()); + JsonNode serialized = mapper.valueToTree(task); + assertEquals(42, serialized.path("s_id").asInt()); + assertFalse(serialized.has("sid")); + assertFalse(serialized.has("sId")); + } +} diff --git a/src/test/java/de/assecutor/tasklisteditor/rest/EditorImportControllerTest.java b/src/test/java/de/assecutor/tasklisteditor/rest/EditorImportControllerTest.java new file mode 100644 index 0000000..9c9eb43 --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/rest/EditorImportControllerTest.java @@ -0,0 +1,248 @@ +package de.assecutor.tasklisteditor.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.assecutor.tasklisteditor.model.BlockType; +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.CustomTypeDefinition; +import de.assecutor.tasklisteditor.model.TaskListPayload; +import de.assecutor.tasklisteditor.service.PendingImportStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class EditorImportControllerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final BlockTypeRegistry registry = new BlockTypeRegistry(false); + private RecordingStore store; + private MockMvc mvc; + + @BeforeEach + void setUp() { + store = new RecordingStore(); + mvc = MockMvcBuilders.standaloneSetup(new EditorImportController(store, mapper, registry)).build(); + } + + @Test + void preservesTasksAndNestedMetadataForReturnToCaller() throws Exception { + JsonNode request = mapper.readTree(""" + { + "name": "Tour 4711", + "tasks": [{"s_id": 1, "type": 3, "topic": "Ankunft"}], + "metadata": {"requestId": "req-42", "nested": [null, true, 17, {"value": "äöü"}]}, + "returnOrigin": "http://localhost:9000/" + } + """); + + TaskListPayload payload = importPayload(request.toString()); + assertEquals("Tour 4711", payload.getName()); + assertEquals(1, payload.getTasks().size()); + assertEquals("Ankunft", payload.getTasks().getFirst().getTopic()); + assertEquals(request.get("metadata"), mapper.valueToTree(payload).get("metadata")); + assertEquals("http://localhost:9000", payload.getReturnOrigin()); + } + + @Test + void acceptsLegacyTaskArrayWithoutReturnTarget() throws Exception { + TaskListPayload payload = importPayload("[{\"s_id\": 2, \"type\": 3, \"topic\": \"Ankunft\"}]"); + assertEquals(2, payload.getTasks().getFirst().getSId()); + assertNull(payload.getMetadata()); + assertNull(payload.getReturnOrigin()); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"tasks\": null}", "{\"tasks\": []}", "[]"}) + void acceptsNewWorkflow(String body) throws Exception { + assertTrue(importPayload(body).getTasks().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "[]", "{\"customTypes\": null}", "{\"customTypes\": []}"}) + void defaultsToAnEmptyCustomTypeCatalog(String body) throws Exception { + assertTrue(importPayload(body).getCustomTypes().isEmpty()); + } + + @Test + void preservesCustomDefinitionsDefaultsAndTasks() throws Exception { + ObjectNode definition = customType(); + definition.put("label", "Kundeneigene Ankunft – Büro & Straße"); + ((ObjectNode) definition.withArray("properties").get(0)).put("defaultValue", false); + ObjectNode request = mapper.createObjectNode(); + request.putArray("customTypes").add(definition); + request.putArray("tasks").addObject().put("type", 1000).put("topic", "Beim Kunden"); + + TaskListPayload payload = importPayload(request.toString()); + + assertEquals(definition, mapper.valueToTree(payload.getCustomTypes().getFirst())); + BlockType custom = payload.getCustomTypes().getFirst().toBlockType(); + assertEquals("ankunft-melden", custom.baseTypeId()); + assertEquals(false, custom.properties().getFirst().defaultValue()); + assertEquals(1000, payload.getTasks().getFirst().getType()); + assertEquals(definition, mapper.valueToTree(CustomTypeDefinition.from(custom))); + } + + @ParameterizedTest + @ValueSource(strings = { + "{\"customTypes\": {}}", "{\"customTypes\": true}", "{\"customTypes\": [null]}", + "{\"customTypes\": [17]}", "{\"customTypes\": [{}]}", + "{\"tasks\": [{\"type\": 1000}]}", "[{\"type\": 1000}]" + }) + void rejectsMalformedCustomCatalogsAndMissingDefinitions(String body) throws Exception { + rejectWithoutStoring(body); + } + + @ParameterizedTest + @ValueSource(strings = { + "{\"typeId\": \"start\"}", "{\"typeId\": \"script\"}", "{\"typeId\": \"ankunft-melden\"}", + "{\"typeId\": \" \"}", "{\"typeId\": 17}", "{\"taskType\": 999}", + "{\"taskType\": 2147483647}", "{\"taskType\": \"1000\"}", "{\"taskType\": 1000.5}", + "{\"baseTypeId\": \"start\"}", "{\"baseTypeId\": \"script\"}", + "{\"baseTypeId\": \"custom-1001\"}", "{\"baseTypeId\": \"unknown\"}", + "{\"label\": \" \"}", "{\"description\": []}", "{\"icon\": null}", + "{\"color\": \"red;display:none\"}", "{\"inputs\": 0}", "{\"outputs\": 2}", + "{\"inputs\": \"1\"}", "{\"properties\": null}", "{\"properties\": []}" + }) + void rejectsInvalidCustomDefinitions(String changes) throws Exception { + ObjectNode definition = customType(); + definition.setAll((ObjectNode) mapper.readTree(changes)); + rejectWithoutStoring(withCustomTypes(definition)); + } + + @ParameterizedTest + @ValueSource(strings = { + "{\"id\": \"unknown\"}", "{\"id\": \"opt\"}", "{\"label\": \" \"}", + "{\"type\": \"TEXT\"}", "{\"type\": null}", + "{\"defaultValue\": \"true\"}", "{\"defaultValue\": 1}", "{\"defaultValue\": []}" + }) + void rejectsInvalidPropertyDefinitions(String changes) throws Exception { + ObjectNode definition = customType(); + ((ObjectNode) definition.withArray("properties").get(0)) + .setAll((ObjectNode) mapper.readTree(changes)); + rejectWithoutStoring(withCustomTypes(definition)); + } + + @Test + void rejectsNullProperties() throws Exception { + ObjectNode definition = customType(); + definition.withArray("properties").set(0, mapper.nullNode()); + rejectWithoutStoring(withCustomTypes(definition)); + } + + @Test + void rejectsDuplicateCustomIds() throws Exception { + ObjectNode first = customType(); + ObjectNode second = customType(); + second.put("taskType", 1001); + rejectWithoutStoring(withCustomTypes(first, second)); + } + + @Test + void rejectsDuplicateCustomTaskNumbers() throws Exception { + ObjectNode first = customType(); + ObjectNode second = customType(); + second.put("typeId", "custom-1001"); + rejectWithoutStoring(withCustomTypes(first, second)); + } + + private ObjectNode customType() { + BlockType base = registry.find("ankunft-melden").orElseThrow(); + BlockType custom = new BlockType("custom-1000", "Eigene Ankunft", base.description(), + base.icon(), base.color(), 1000, base.inputs(), base.outputs(), base.properties(), base.id()); + return mapper.valueToTree(CustomTypeDefinition.from(custom)); + } + + private String withCustomTypes(ObjectNode... types) { + ObjectNode request = mapper.createObjectNode(); + ArrayNode definitions = request.putArray("customTypes"); + for (ObjectNode type : types) { + definitions.add(type); + } + return request.toString(); + } + + private void rejectWithoutStoring(String body) throws Exception { + mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + assertEquals(0, store.putCount); + } + + @ParameterizedTest + @ValueSource(strings = { + "null", "17", "true", "\"text\"", "{", "{\"tasks\": {}}", + "{\"tasks\": \"text\"}", "{\"tasks\": [null]}", "[null]", "[17]", + "{\"tasks\": [{\"type\": {}}]}", "{\"returnOrigin\": 17}", + "{\"returnOrigin\": true}", "{\"returnOrigin\": []}" + }) + void rejectsMalformedRequestsWithoutStoringThem(String body) throws Exception { + mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + + assertEquals(0, store.putCount); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "http://localhost:9000/|http://localhost:9000", + "HTTP://EXAMPLE.COM:80/|http://example.com", + "HTTPS://EXAMPLE.COM:443|https://example.com", + "https://example.com:8443|https://example.com:8443", + "http://[::1]:9000/|http://[::1]:9000" + }) + void normalizesValidReturnOrigins(String input, String expected) throws Exception { + String body = mapper.createObjectNode().put("returnOrigin", input).toString(); + assertEquals(expected, importPayload(body).getReturnOrigin()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", "*", "null", "//example.com", "/callback", "ftp://example.com", + "javascript:alert(1)", "https://user:password@example.com", "https://example.com/callback", + "https://example.com?query=value", "https://example.com#fragment", + "http://localhost:99999", "http://localhost:-1", "https://example.com/../", + " https://example.com", "https://example.com\n" + }) + void rejectsValuesThatAreNotHttpOrigins(String origin) throws Exception { + String body = mapper.createObjectNode().put("returnOrigin", origin).toString(); + mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + + assertEquals(0, store.putCount); + } + + private TaskListPayload importPayload(String body) throws Exception { + String response = mvc.perform(post("/api/editor/import") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + JsonNode result = mapper.readTree(response); + String token = result.path("token").asText(); + assertTrue(!token.isBlank()); + assertEquals("http://localhost/?import=" + token, result.path("deepLink").asText()); + assertEquals(1, store.putCount); + return store.find(token).orElseThrow(); + } + + private static class RecordingStore extends PendingImportStore { + private int putCount; + + @Override + public String put(TaskListPayload payload) { + putCount++; + return super.put(payload); + } + } +} diff --git a/src/test/java/de/assecutor/tasklisteditor/service/CustomTypeRoundTripTest.java b/src/test/java/de/assecutor/tasklisteditor/service/CustomTypeRoundTripTest.java new file mode 100644 index 0000000..5c9a3d0 --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/service/CustomTypeRoundTripTest.java @@ -0,0 +1,85 @@ +package de.assecutor.tasklisteditor.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.tasklisteditor.model.BlockInstance; +import de.assecutor.tasklisteditor.model.BlockType; +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.CustomTypeDefinition; +import de.assecutor.tasklisteditor.model.TaskDto; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomTypeRoundTripTest { + private final ObjectMapper mapper = new ObjectMapper(); + private final BlockTypeRegistry standards = new BlockTypeRegistry(false); + + @Test + void highImportedTypeNumberDoesNotOverflowAndUndoNumbersRemainReserved() { + BlockTypeRegistry editor = standards.forEditor(); + BlockType base = standards.find("fotos-aufnehmen").orElseThrow(); + editor.registerCustom(Integer.MAX_VALUE - 1, base, "Große Typnummer", base.properties()); + assertEquals(1002, editor.nextCustomTaskType(List.of(1000, 1001))); + } + + @Test + void simultaneousEditorsCanUseSameCustomNumberWithoutSharingChanges() { + BlockTypeRegistry first = standards.forEditor(); + BlockTypeRegistry second = standards.forEditor(); + BlockType base = standards.find("fotos-aufnehmen").orElseThrow(); + first.registerCustom(1000, base, "Erster Aufrufer", base.properties()); + second.registerCustom(1000, base, "Zweiter Aufrufer", base.properties()); + + TaskDto task = new TaskDto(); + task.setType(1000); + TaskListImporter importer = new TaskListImporter(standards); + assertEquals("Erster Aufrufer", importer.resolve(List.of(task), first).getFirst().topic()); + assertEquals("Zweiter Aufrufer", importer.resolve(List.of(task), second).getFirst().topic()); + assertTrue(standards.find("custom-1000").isEmpty()); + + first.clearCustom(); + assertTrue(first.find("custom-1000").isEmpty()); + assertTrue(first.find("fotos-aufnehmen").isPresent()); + assertTrue(second.find("custom-1000").isPresent()); + } + + @Test + void returnedTaskAndTypeDefinitionCanBeOpenedAgainWithEditedValues() throws Exception { + BlockTypeRegistry editor = standards.forEditor(); + BlockType base = standards.find("fotos-aufnehmen").orElseThrow(); + BlockType custom = editor.registerCustom(1000, base, "Zustellnachweis", base.properties()); + BlockInstance photo = new BlockInstance(2, custom); + photo.setTopic("Paket fotografieren"); + photo.set("min", 2); + photo.set("max", 6); + Map instances = Map.of( + 1, new BlockInstance(1, standards.find("start").orElseThrow()), + 2, photo, + 3, new BlockInstance(3, standards.find("abschluss").orElseThrow())); + String graph = """ + {"drawflow":{"Home":{"data":{ + "1":{"inputs":{},"outputs":{"output_1":{"connections":[{"node":"2"}]}}}, + "2":{"inputs":{"input_1":{"connections":[{"node":"1"}]}}, + "outputs":{"output_1":{"connections":[{"node":"3"}]}}}, + "3":{"inputs":{"input_1":{"connections":[{"node":"2"}]}},"outputs":{}} + }}}} + """; + List result = new TaskListExporter(mapper, standards).export(graph, instances); + assertEquals(1, result.size()); + assertEquals(1000, result.getFirst().getType()); + assertEquals(4, result.getFirst().getBaseType()); + + CustomTypeDefinition definition = mapper.readValue( + mapper.writeValueAsString(CustomTypeDefinition.from(custom)), CustomTypeDefinition.class); + BlockTypeRegistry reopened = standards.forEditor(); + reopened.registerCustom(definition.toBlockType()); + var imported = new TaskListImporter(standards).resolve(result, reopened).getFirst(); + assertEquals("Zustellnachweis", imported.type().label()); + assertEquals("Paket fotografieren", imported.topic()); + assertEquals(2, imported.values().get("min")); + assertEquals(6, imported.values().get("max")); + } +} diff --git a/src/test/java/de/assecutor/tasklisteditor/service/PendingImportStoreTest.java b/src/test/java/de/assecutor/tasklisteditor/service/PendingImportStoreTest.java new file mode 100644 index 0000000..ca779b4 --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/service/PendingImportStoreTest.java @@ -0,0 +1,38 @@ +package de.assecutor.tasklisteditor.service; + +import de.assecutor.tasklisteditor.model.TaskListPayload; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PendingImportStoreTest { + + @Test + void supportsReloadsUntilPayloadIsConsumed() { + PendingImportStore store = new PendingImportStore(); + TaskListPayload payload = new TaskListPayload(); + String token = store.put(payload); + + assertSame(payload, store.find(token).orElseThrow()); + assertSame(payload, store.find(token).orElseThrow()); + assertSame(payload, store.consume(token).orElseThrow()); + assertTrue(store.find(token).isEmpty()); + assertTrue(store.consume(token).isEmpty()); + } + + @Test + void keepsSeparateImportsIndependent() { + PendingImportStore store = new PendingImportStore(); + TaskListPayload first = new TaskListPayload(); + TaskListPayload second = new TaskListPayload(); + String firstToken = store.put(first); + String secondToken = store.put(second); + + assertNotEquals(firstToken, secondToken); + store.consume(firstToken); + assertSame(second, store.find(secondToken).orElseThrow()); + assertTrue(store.find("missing-token").isEmpty()); + } +} diff --git a/src/test/java/de/assecutor/tasklisteditor/service/TaskListImporterTest.java b/src/test/java/de/assecutor/tasklisteditor/service/TaskListImporterTest.java new file mode 100644 index 0000000..f56ee00 --- /dev/null +++ b/src/test/java/de/assecutor/tasklisteditor/service/TaskListImporterTest.java @@ -0,0 +1,95 @@ +package de.assecutor.tasklisteditor.service; + +import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.PropertyDefinition; +import de.assecutor.tasklisteditor.model.PropertyType; +import de.assecutor.tasklisteditor.model.TaskDto; +import de.assecutor.tasklisteditor.model.TaskEntryDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TaskListImporterTest { + + @ParameterizedTest + @EnumSource(value = PropertyType.class, names = {"FIELD_LIST", "NAME_LIST", "CHOICE_LIST"}) + @SuppressWarnings("unchecked") + void missingTaskListsUseIndependentCopiesOfCustomDefaults(PropertyType propertyType) { + List> defaults = new ArrayList<>(); + defaults.add(new LinkedHashMap<>(Map.of("title", "Vorgabe", "required", true))); + BlockTypeRegistry registry = customRegistry(propertyType, defaults); + TaskListImporter importer = new TaskListImporter(registry); + + var imported = importer.resolve(List.of(task(), task()), registry); + List> first = (List>) imported.get(0).values().get("items"); + assertThat(first).isEqualTo(defaults); + + first.get(0).put("title", "Geändert"); + first.add(new LinkedHashMap<>(Map.of("title", "Neu"))); + + assertThat(imported.get(1).values().get("items")).isEqualTo(defaults); + assertThat(defaults).containsExactly(Map.of("title", "Vorgabe", "required", true)); + } + + @ParameterizedTest + @EnumSource(value = PropertyType.class, names = {"FIELD_LIST", "NAME_LIST", "CHOICE_LIST"}) + void explicitlyEmptyTaskListsOverrideCustomDefaults(PropertyType propertyType) { + BlockTypeRegistry registry = customRegistry(propertyType, List.of(Map.of("title", "Vorgabe"))); + TaskDto task = task(); + task.setFields(List.of()); + task.setEntries(List.of()); + task.setChoices(List.of()); + + var imported = new TaskListImporter(registry).resolve(List.of(task), registry); + + assertThat(imported.getFirst().values().get("items")).isEqualTo(List.of()); + } + + @Test + void missingChoicesStillImportLegacyEntries() { + BlockTypeRegistry registry = customRegistry(PropertyType.CHOICE_LIST, List.of(Map.of("title", "Vorgabe"))); + TaskDto task = task(); + TaskEntryDto entry = new TaskEntryDto(); + entry.setText("Alte Option"); + task.setEntries(List.of(entry)); + + var imported = new TaskListImporter(registry).resolve(List.of(task), registry); + + assertThat(imported.getFirst().values().get("items")) + .isEqualTo(List.of(Map.of("title", "Alte Option", "required", false))); + } + + @Test + void explicitlyEmptyChoicesDoNotRestoreLegacyEntries() { + BlockTypeRegistry registry = customRegistry(PropertyType.CHOICE_LIST, List.of(Map.of("title", "Vorgabe"))); + TaskDto task = task(); + TaskEntryDto entry = new TaskEntryDto(); + entry.setText("Alte Option"); + task.setEntries(List.of(entry)); + task.setChoices(List.of()); + + var imported = new TaskListImporter(registry).resolve(List.of(task), registry); + + assertThat(imported.getFirst().values().get("items")).isEqualTo(List.of()); + } + + private BlockTypeRegistry customRegistry(PropertyType type, Object defaults) { + BlockTypeRegistry registry = new BlockTypeRegistry(false).forEditor(); + registry.registerCustom(1000, registry.find("statusauswahl").orElseThrow(), "Eigener Typ", + List.of(new PropertyDefinition("items", "Einträge", type, defaults))); + return registry; + } + + private TaskDto task() { + TaskDto task = new TaskDto(); + task.setType(1000); + return task; + } +} diff --git a/tools/editor_test_server.html b/tools/editor_test_server.html new file mode 100644 index 0000000..57f89ef --- /dev/null +++ b/tools/editor_test_server.html @@ -0,0 +1,203 @@ + + + + + + TaskList Editor · Testaufrufer + + + +
+

TaskList Editor testen

+

Tasks, eigene Typen und Metadaten übergeben, im Editor bearbeiten und das Ergebnis in der Python-Konsole lesen.

+

Verbindung wird vorbereitet …

+
+ + +

Ein tasks-Array oder ein Objekt mit name, tasks, dem vollständigen Katalog eigener Typen in customTypes und beliebigen metadata. Für eine neue Liste: "tasks": []. Nach „Fertig“ wird das Eingabefeld mit den zurückgegebenen Tasks und Typen aktualisiert.

+
+ + + +
+

+
+ +
+ + + + diff --git a/tools/editor_test_server.py b/tools/editor_test_server.py new file mode 100644 index 0000000..0e81164 --- /dev/null +++ b/tools/editor_test_server.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Lokaler Aufrufer für den TaskList Editor; benötigt nur Python 3.9+. + +Start: python3 tools/editor_test_server.py --json tools/example_tasks.json +Der Editor muss separat laufen (standardmäßig http://127.0.0.1:8080). +""" + +import argparse +import json +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +MAX_BODY_BYTES = 2 * 1024 * 1024 +TOKEN_TTL_SECONDS = 60 * 60 +RESULT_TYPE = "tasklist-editor:result" +HTML_PATH = Path(__file__).with_suffix(".html") +PRINT_LOCK = threading.Lock() + + +class RequestError(Exception): + def __init__(self, status, message): + super().__init__(message) + self.status = status + + +def reject_constant(value): + raise ValueError("Ungültiger JSON-Zahlenwert: " + value) + + +def decode_json(raw): + try: + return json.loads(raw.decode("utf-8-sig"), parse_constant=reject_constant) + except (UnicodeError, ValueError, RecursionError) as exc: + raise RequestError(400, "Ungültiges JSON: " + str(exc)) from exc + + +def origin_for(url): + parsed = urllib.parse.urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise ValueError("Die Editor-URL muss eine vollständige http(s)-URL sein.") + if parsed.username or parsed.password: + raise ValueError("Zugangsdaten in der Editor-URL werden nicht unterstützt.") + port = parsed.port + host = parsed.hostname.lower() + if ":" in host: + host = "[" + host + "]" + if port and port != {"http": 80, "https": 443}[parsed.scheme]: + host += ":" + str(port) + return parsed.scheme + "://" + host + + +def normalize_payload(value): + if isinstance(value, list): + value = {"tasks": value} + if not isinstance(value, dict): + raise RequestError(400, "Erwartet wird ein tasks-Array oder ein Objekt mit name, tasks, customTypes und metadata.") + tasks = value.get("tasks") + if tasks is None: + tasks = [] + if not isinstance(tasks, list) or any(not isinstance(task, dict) for task in tasks): + raise RequestError(400, "tasks muss ein Array aus Task-Objekten sein; [] legt eine neue Liste an.") + custom_types = value.get("customTypes") + if custom_types is None: + custom_types = [] + if not isinstance(custom_types, list) or any(not isinstance(item, dict) for item in custom_types): + raise RequestError(400, "customTypes muss ein Array aus Typ-Objekten sein; [] übergibt keine eigenen Typen.") + if value.get("name") is not None and not isinstance(value["name"], str): + raise RequestError(400, "name muss eine Zeichenkette oder null sein.") + payload = {"tasks": tasks, "customTypes": custom_types} + for key in ("name", "metadata"): + if key in value: + payload[key] = value[key] + return payload + + +class EditorTestServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, port, editor_url, initial_payload): + editor_url = editor_url.rstrip("/") + self.editor_origin = origin_for(editor_url) + parsed = urllib.parse.urlsplit(editor_url) + if parsed.query or parsed.fragment: + raise ValueError("Die Editor-URL darf keine Query-Parameter oder Fragmente enthalten.") + self.editor_url = editor_url + self.initial_payload = normalize_payload(initial_payload) + self.sessions = {} + self.session_lock = threading.Lock() + super().__init__(("127.0.0.1", port), EditorTestHandler) + self.local_origin = "http://127.0.0.1:" + str(self.server_port) + + def open_editor(self, value): + payload = normalize_payload(value) + payload["returnOrigin"] = self.local_origin + request = urllib.request.Request( + self.editor_url + "/api/editor/import", + data=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"), + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + raw = response.read(MAX_BODY_BYTES + 1) + except urllib.error.HTTPError as exc: + detail = exc.read(4096).decode("utf-8", errors="replace").strip() + raise RequestError(502, "Editor lehnt den Import ab (HTTP {}): {}".format( + exc.code, detail or exc.reason)) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise RequestError(502, "Editor unter {} nicht erreichbar: {}".format(self.editor_url, exc)) from exc + if len(raw) > MAX_BODY_BYTES: + raise RequestError(502, "Die Antwort des Editors ist zu groß.") + try: + result = decode_json(raw) + token = result.get("token") if isinstance(result, dict) else None + deep_link = result.get("deepLink") if isinstance(result, dict) else None + if not isinstance(token, str) or not token or len(token) > 512: + raise ValueError("token fehlt oder ist ungültig") + if not isinstance(deep_link, str) or origin_for(deep_link) != self.editor_origin: + raise ValueError("deepLink muss auf den konfigurierten Editor-Origin zeigen") + except (RequestError, ValueError) as exc: + raise RequestError(502, "Ungültige Import-Antwort des Editors: " + str(exc)) from exc + with self.session_lock: + now = time.monotonic() + self.sessions = {key: session for key, session in self.sessions.items() + if now - session["created"] < TOKEN_TTL_SECONDS} + if len(self.sessions) >= 1000: + raise RequestError(503, "Zu viele offene Tests. Bitte den Testserver neu starten.") + self.sessions[token] = {"created": now, "result": None} + return {"token": token, "deepLink": deep_link} + + def receive_result(self, result): + if not isinstance(result, dict) or result.get("type") != RESULT_TYPE: + raise RequestError(400, "Ungültiger Rückgabetyp.") + token = result.get("token") + if not isinstance(token, str): + raise RequestError(400, "Die Rückgabe benötigt ein token.") + status = result.get("status") + if status not in ("completed", "cancelled"): + raise RequestError(400, "status muss completed oder cancelled sein.") + if status == "completed" and (not isinstance(result.get("tasks"), list) + or any(not isinstance(task, dict) for task in result["tasks"])): + raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt ein tasks-Array.") + if status == "completed" and (not isinstance(result.get("customTypes"), list) + or any(not isinstance(item, dict) for item in result["customTypes"])): + raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt den vollständigen customTypes-Katalog als Array.") + if status == "cancelled" and ("tasks" in result or "customTypes" in result): + raise RequestError(400, "Eine abgebrochene Bearbeitung darf keine tasks oder customTypes zurückgeben.") + if result.get("name") is not None and not isinstance(result["name"], str): + raise RequestError(400, "name muss eine Zeichenkette oder null sein.") + with self.session_lock: + session = self.sessions.get(token) + if session is None or time.monotonic() - session["created"] >= TOKEN_TTL_SECONDS: + raise RequestError(404, "Unbekanntes oder abgelaufenes token. Bitte einen neuen Test öffnen.") + # Ein Retry nach verlorener HTTP-Antwort darf dieselbe Rückgabe erneut bestätigen. + if session["result"] is not None: + if session["result"] != result: + raise RequestError(409, "Für dieses token wurde bereits ein anderes Ergebnis empfangen.") + return {"ok": True, "duplicate": True} + with PRINT_LOCK: + print("\n--- Editor-Rückgabe ---", flush=True) + print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False), flush=True) + session["result"] = result + return {"ok": True} + + +class EditorTestHandler(BaseHTTPRequestHandler): + server_version = "TaskListTestServer/1.0" + + def setup(self): + super().setup() + self.connection.settimeout(20) + + def log_message(self, fmt, *args): + with PRINT_LOCK: + print("[{}] {}".format(self.log_date_time_string(), fmt % args), file=sys.stderr, flush=True) + + def send_bytes(self, status, body, content_type): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(body) + + def send_json(self, status, value): + self.send_bytes(status, json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8"), + "application/json; charset=utf-8") + + def check_host(self): + expected = urllib.parse.urlsplit(self.server.local_origin).netloc + if self.headers.get("Host") != expected: + raise RequestError(403, "Bitte den Testserver über {} öffnen.".format(self.server.local_origin)) + + def read_json(self): + if self.headers.get("Origin") != self.server.local_origin: + raise RequestError(403, "Anfragen müssen von der lokalen Testseite stammen.") + if self.headers.get_content_type() != "application/json": + raise RequestError(415, "Content-Type application/json wird benötigt.") + if self.headers.get("Transfer-Encoding"): + raise RequestError(400, "Transfer-Encoding wird nicht unterstützt.") + try: + length = int(self.headers["Content-Length"]) + except (TypeError, ValueError) as exc: + raise RequestError(411, "Content-Length wird benötigt.") from exc + if length <= 0 or length > MAX_BODY_BYTES: + raise RequestError(413, "Der JSON-Body muss zwischen 1 Byte und 2 MiB groß sein.") + raw = self.rfile.read(length) + if len(raw) != length: + raise RequestError(400, "Unvollständiger Request-Body.") + return decode_json(raw) + + def do_GET(self): + try: + self.check_host() + if self.path in ("/", "/index.html"): + self.send_bytes(200, HTML_PATH.read_bytes(), "text/html; charset=utf-8") + elif self.path == "/api/config": + self.send_json(200, {"editorUrl": self.server.editor_url, + "editorOrigin": self.server.editor_origin, + "initialPayload": self.server.initial_payload}) + else: + raise RequestError(404, "Nicht gefunden.") + except RequestError as exc: + self.send_json(exc.status, {"error": str(exc)}) + except OSError as exc: + self.send_json(500, {"error": "Testseite konnte nicht geladen werden: " + str(exc)}) + + def do_POST(self): + try: + self.check_host() + if self.path not in ("/api/open", "/api/result"): + raise RequestError(404, "Nicht gefunden.") + value = self.read_json() + result = self.server.open_editor(value) if self.path == "/api/open" else self.server.receive_result(value) + self.send_json(200, result) + except RequestError as exc: + self.send_json(exc.status, {"error": str(exc)}) + except (TimeoutError, OSError) as exc: + self.send_json(408, {"error": "Zeitüberschreitung beim Lesen der Anfrage: " + str(exc)}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8090, help="Lokaler Port (Standard: 8090)") + parser.add_argument("--editor-url", default="http://127.0.0.1:8080", help="Basis-URL des laufenden Editors") + parser.add_argument("--json", type=Path, help="JSON-Datei zum Vorbelegen (Standard: tools/example_tasks.json)") + parser.add_argument("--no-browser", action="store_true", help="Browser nicht automatisch öffnen") + args = parser.parse_args() + if not 0 <= args.port <= 65535: + parser.error("--port muss zwischen 0 und 65535 liegen.") + try: + json_path = args.json or Path(__file__).with_name("example_tasks.json") + with json_path.open("rb") as source: + raw = source.read(MAX_BODY_BYTES + 1) + if len(raw) > MAX_BODY_BYTES: + raise ValueError("Die JSON-Datei darf höchstens 2 MiB groß sein.") + initial_payload = decode_json(raw) + server = EditorTestServer(args.port, args.editor_url, initial_payload) + except (RequestError, ValueError, OSError) as exc: + parser.error(str(exc)) + print("Testseite: " + server.local_origin + "/", flush=True) + print("Editor: " + server.editor_url, flush=True) + print("Rückgaben erscheinen hier als JSON. Beenden mit Strg+C.", flush=True) + if not args.no_browser: + webbrowser.open(server.local_origin + "/") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nTestserver beendet.", flush=True) + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tools/example_tasks.json b/tools/example_tasks.json new file mode 100644 index 0000000..9be4fc3 --- /dev/null +++ b/tools/example_tasks.json @@ -0,0 +1,47 @@ +{ + "name": "Testtour 4711", + "tasks": [ + { + "s_id": 1, + "type": 3, + "topic": "Ankunft melden", + "track": true, + "txt": "Bitte Ankunft am Einsatzort bestätigen.", + "opt": false, + "reenter": false + }, + { + "s_id": 2, + "type": 1000, + "topic": "Zustellung fotografieren", + "min": 1, + "max": 3, + "opt": false, + "reenter": true + } + ], + "customTypes": [ + { + "typeId": "custom-1000", + "taskType": 1000, + "baseTypeId": "fotos-aufnehmen", + "label": "Zustellnachweis", + "description": "Fotos zur Dokumentation einer Zustellung.", + "icon": "vaadin:camera", + "color": "#059669", + "inputs": 1, + "outputs": 1, + "properties": [ + { "id": "min", "label": "Mindestanzahl Fotos", "type": "NUMBER", "defaultValue": 1 }, + { "id": "max", "label": "Maximale Anzahl Fotos", "type": "NUMBER", "defaultValue": 3 }, + { "id": "opt", "label": "Optionale Aufgabe", "type": "BOOLEAN", "defaultValue": false }, + { "id": "reenter", "label": "Erneut bearbeitbar", "type": "BOOLEAN", "defaultValue": true } + ] + } + ], + "metadata": { + "tourId": 4711, + "requestId": "test-001", + "source": "python-testserver" + } +} diff --git a/tools/test_editor_test_server.py b/tools/test_editor_test_server.py new file mode 100644 index 0000000..bd13cc6 --- /dev/null +++ b/tools/test_editor_test_server.py @@ -0,0 +1,219 @@ +"""HTTP-Roundtrip-Tests ohne laufende Java-App oder externe Python-Pakete. + +Ausführen: python3 -B -m unittest discover -s tools -p 'test_*.py' -v +""" + +import contextlib +import http.client +import io +import json +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +from editor_test_server import EditorTestServer, MAX_BODY_BYTES, RESULT_TYPE + + +class FakeEditorHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + self.server.imports.append(json.loads(self.rfile.read(int(self.headers["Content-Length"])))) + token = "test-token-" + str(len(self.server.imports)) + response = {"token": token, "deepLink": self.server.origin + "/?import=" + token} + if self.server.external_link: + response["deepLink"] = "https://unexpected.example/?import=" + token + body = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class TestEditorTestServer(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.editor = ThreadingHTTPServer(("127.0.0.1", 0), FakeEditorHandler) + cls.editor.origin = "http://127.0.0.1:" + str(cls.editor.server_port) + cls.server = EditorTestServer(0, cls.editor.origin, {"tasks": []}) + cls.threads = [] + for server in (cls.editor, cls.server): + thread = threading.Thread(target=lambda s=server: s.serve_forever(poll_interval=0.05), daemon=True) + thread.start() + cls.threads.append(thread) + + @classmethod + def tearDownClass(cls): + for server in (cls.server, cls.editor): + server.shutdown() + server.server_close() + for thread in cls.threads: + thread.join(timeout=2) + + def setUp(self): + self.editor.imports = [] + self.editor.external_link = False + self.server.sessions.clear() + + def request(self, method, path, value=None, origin="local", raw=None, extra_headers=None): + connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2) + headers = {"Content-Type": "application/json"} + if origin: + headers["Origin"] = self.server.local_origin if origin == "local" else origin + if extra_headers: + headers.update(extra_headers) + body = raw if raw is not None else json.dumps(value).encode("utf-8") if value is not None else None + try: + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + body = response.read() + if response.getheader("Content-Type", "").startswith("application/json"): + body = json.loads(body) + return response.status, body + finally: + connection.close() + + def open_editor(self, payload=None): + status, result = self.request("POST", "/api/open", {"tasks": []} if payload is None else payload) + self.assertEqual(status, 200, result) + return result["token"] + + def test_page_and_config(self): + status, body = self.request("GET", "/") + self.assertEqual(status, 200) + self.assertIn(b"TaskList Editor testen", body) + status, config = self.request("GET", "/api/config") + self.assertEqual(status, 200) + self.assertEqual(config["editorOrigin"], self.editor.origin) + self.assertEqual(config["initialPayload"], {"tasks": [], "customTypes": []}) + + def test_import_sets_local_return_origin_and_preserves_metadata(self): + payload = {"name": "Grüße", "tasks": [{"type": 3}], + "metadata": {"id": 42, "nested": [True, None]}, "returnOrigin": "https://untrusted.example"} + self.open_editor(payload) + imported = self.editor.imports[0] + self.assertEqual(imported["returnOrigin"], self.server.local_origin) + self.assertEqual(imported["tasks"], payload["tasks"]) + self.assertEqual(imported["metadata"], payload["metadata"]) + self.assertEqual(imported["name"], payload["name"]) + + def test_bare_array_and_missing_tasks_create_new_lists(self): + self.open_editor([]) + self.open_editor({"name": "Neu"}) + self.open_editor({"tasks": None}) + self.assertEqual([value["tasks"] for value in self.editor.imports], [[], [], []]) + self.assertEqual([value["customTypes"] for value in self.editor.imports], [[], [], []]) + + def test_custom_types_roundtrip_preserves_full_catalog_and_deletions(self): + payload = json.loads(Path(__file__).with_name("example_tasks.json").read_text(encoding="utf-8")) + original_types = payload["customTypes"] + self.assertEqual(payload["tasks"][1]["type"], original_types[0]["taskType"]) + token = self.open_editor(payload) + self.assertEqual(self.editor.imports[0]["customTypes"], original_types) + + result = {"type": RESULT_TYPE, "token": token, "status": "completed", **payload} + with contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True})) + printed = json.loads(output.getvalue().split("--- Editor-Rückgabe ---", 1)[1]) + self.assertEqual(printed["customTypes"], original_types) + + # Der Aufrufer ersetzt den Katalog durch die gesamte Rückgabe, auch [] nach einer Löschung. + token = self.open_editor({"tasks": [], "customTypes": original_types}) + result = {"type": RESULT_TYPE, "token": token, "status": "completed", "tasks": [], "customTypes": []} + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True})) + self.open_editor({"tasks": result["tasks"], "customTypes": result["customTypes"]}) + self.assertEqual(self.editor.imports[-1]["customTypes"], []) + + def test_new_list_can_receive_custom_types_without_tasks(self): + custom_types = [{"typeId": "custom-1000", "taskType": 1000}] + self.open_editor({"customTypes": custom_types}) + self.assertEqual(self.editor.imports[0]["tasks"], []) + self.assertEqual(self.editor.imports[0]["customTypes"], custom_types) + + def test_completed_and_retry_print_once(self): + token = self.open_editor() + result = {"type": RESULT_TYPE, "token": token, "status": "completed", "name": "Grüße", + "tasks": [{"type": 3}], "customTypes": [], "metadata": {"id": 42}} + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True})) + self.assertEqual(self.request("POST", "/api/result", result), (200, {"ok": True, "duplicate": True})) + self.assertEqual(output.getvalue().count("--- Editor-Rückgabe ---"), 1) + self.assertIn('"name": "Grüße"', output.getvalue()) + printed = output.getvalue().split("--- Editor-Rückgabe ---", 1)[1] + self.assertEqual(json.loads(printed), result) + result["tasks"] = [] + self.assertEqual(self.request("POST", "/api/result", result)[0], 409) + + def test_cancelled_has_no_tasks_or_custom_types(self): + result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "cancelled", "metadata": {"id": 42}} + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.request("POST", "/api/result", result)[0], 200) + for key in ("tasks", "customTypes"): + with self.subTest(key=key): + self.assertEqual(self.request("POST", "/api/result", {**result, key: []})[0], 400) + + def test_invalid_result_and_unknown_token(self): + result = {"type": RESULT_TYPE, "token": "unknown", "status": "completed", "tasks": [], "customTypes": []} + self.assertEqual(self.request("POST", "/api/result", result)[0], 404) + result["token"] = self.open_editor() + result["tasks"] = "bad" + self.assertEqual(self.request("POST", "/api/result", result)[0], 400) + result["status"] = "invalid" + self.assertEqual(self.request("POST", "/api/result", result)[0], 400) + + def test_completed_requires_full_custom_type_catalog(self): + result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "completed", "tasks": []} + self.assertEqual(self.request("POST", "/api/result", result)[0], 400) + for invalid_types in (None, False, {}, [1]): + with self.subTest(custom_types=invalid_types): + result["customTypes"] = invalid_types + self.assertEqual(self.request("POST", "/api/result", result)[0], 400) + + def test_expired_token(self): + result = {"type": RESULT_TYPE, "token": self.open_editor(), "status": "cancelled"} + self.server.sessions[result["token"]]["created"] -= 3601 + self.assertEqual(self.request("POST", "/api/result", result)[0], 404) + + def test_untrusted_origin_and_host(self): + for origin in (None, "https://untrusted.example"): + with self.subTest(origin=origin): + self.assertEqual(self.request("POST", "/api/open", [], origin=origin)[0], 403) + self.assertEqual(self.request("GET", "/api/config", extra_headers={"Host": "untrusted.example"})[0], 403) + self.assertFalse(self.editor.imports) + + def test_invalid_payloads(self): + for payload in (42, "bad", {"tasks": False}, {"tasks": [1]}, {"name": 42}, + {"customTypes": {}}, {"customTypes": [1]}): + with self.subTest(payload=payload): + self.assertEqual(self.request("POST", "/api/open", payload)[0], 400) + for raw in (b'{"tasks": [}', b'{"tasks": [], "metadata": NaN}', b'\xff'): + with self.subTest(raw=raw): + self.assertEqual(self.request("POST", "/api/open", raw=raw)[0], 400) + self.assertFalse(self.editor.imports) + + def test_body_limit_and_content_type(self): + self.assertEqual(self.request("POST", "/api/open", [], + extra_headers={"Content-Length": str(MAX_BODY_BYTES + 1)})[0], 413) + self.assertEqual(self.request("POST", "/api/open", [], + extra_headers={"Content-Type": "text/plain"})[0], 415) + + def test_external_deep_link_rejected(self): + self.editor.external_link = True + self.assertEqual(self.request("POST", "/api/open", [])[0], 502) + self.assertFalse(self.server.sessions) + + def test_unreachable_editor_has_useful_error(self): + with patch("editor_test_server.urllib.request.urlopen", side_effect=OSError("Connection refused")): + status, body = self.request("POST", "/api/open", []) + self.assertEqual(status, 502) + self.assertIn("nicht erreichbar", body["error"]) + + +if __name__ == "__main__": + unittest.main()