Compare commits

...
5 Commits
Author SHA1 Message Date
Sven 850b1e3199 fix: Null-Sicherheitswarnungen bei eigenen Typen behoben 2026-11-10 17:07:19 +01:00
Sven a839096582 fix: Canvas-Verschieben stabilisiert und Version auf 0.9.5 erhöht 2026-11-10 17:03:12 +01:00
Sven 19d97a47b5 chore: Projekt und Build-Artefakte in Workflow Editor umbenannt 2026-10-08 17:47:02 +02:00
Sven f979c797ba feat: Editor-Aufruf mit Task- und Typenrückgabe statt MongoDB
Tasks, eigene Typen und Metadaten pro Editor-Aufruf laden und an den Aufrufer zurückgeben. MongoDB-Persistenz entfernen und Header auf Rückgängig, Abbrechen und Fertig umstellen.

Python-Testserver und Regressionstests ergänzen; Verbindungslinien nach Import und Layoutänderungen neu berechnen.
2026-10-07 21:06:28 +02:00
Sven 93ba40c476 fix: Task-Typen und Export für App-Kompatibilität korrigiert
Historische Typnummern angeglichen und base_type für benutzerdefinierte Tasks ergänzt. React im Vaadin-Dev-Modus deaktiviert und Lockdatei bereinigt. Version auf 0.9.3 und Docker-Image auf wfe aktualisiert sowie Windows-Zeilenenden im Maven-Wrapper übernommen.
2026-10-07 13:46:05 +02:00
44 changed files with 2092 additions and 2079 deletions

No files matched your search

+1 -37
View File
@@ -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)
+1 -1
View File
@@ -1,6 +1,6 @@
FROM eclipse-temurin:21-jre
ARG JAR_FILE=target/*.jar
ARG JAR_FILE=target/workflow-editor-*.jar
# Zeitzone auf Berlin setzen und 24h-Format konfigurieren
ENV TZ=Europe/Berlin
+132 -13
View File
@@ -1,4 +1,4 @@
# Workflow Editor (TaskList Editor)
# Workflow Editor
n8n-inspirierter Editor zum Erstellen von Aufgabenlisten (Tasks) für die
stadtbote-App. Funktionsblöcke werden per Drag & Drop auf einem Canvas
@@ -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": "<Token aus der Importantwort>",
"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.
+2 -2
View File
@@ -3,7 +3,7 @@
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/ait"
readonly REGISTRY_IMAGE="registry.assecutor.org/wfe"
readonly BACKEND_DIR="${SCRIPT_DIR}"
usage() {
@@ -67,7 +67,7 @@ echo "Verwende Release-Version ${VERSION}."
echo "Baue Production-JAR für Version ${VERSION} ..."
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests package)
JAR_FILE_REL="target/tasklist-editor-${POM_VERSION}.jar"
JAR_FILE_REL="target/workflow-editor-${POM_VERSION}.jar"
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
[[ -f "${JAR_FILE_ABS}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
+2 -895
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "no-name",
"name": "workflow-editor",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
+3 -8
View File
@@ -12,9 +12,9 @@
</parent>
<groupId>de.assecutor</groupId>
<artifactId>tasklist-editor</artifactId>
<version>0.9.2</version>
<name>TaskList Editor</name>
<artifactId>workflow-editor</artifactId>
<version>0.9.5</version>
<name>Workflow Editor</name>
<description>n8n-inspired editor for task list workflows</description>
<properties>
@@ -45,11 +45,6 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Lädt Variablen aus einer .env-Datei in die Spring-Umgebung. -->
<dependency>
<groupId>me.paulschwarz</groupId>
+81 -21
View File
@@ -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;
@@ -311,7 +329,9 @@ function attach(host) {
host.classList.add('task-list-editor');
host.innerHTML = '';
const container = document.createElement('div');
container.className = 'task-list-editor-container';
// Drawflow prüft beim Drag-Start nur classList[0]. Nach dem Verschieben
// liegt der freie Hintergrund oft außerhalb des transformierten .drawflow.
container.className = 'parent-drawflow task-list-editor-container';
container.style.width = '100%';
container.style.height = '100%';
host.appendChild(container);
@@ -329,7 +349,8 @@ function attach(host) {
registry: new Map(),
history: [],
undoSuppressed: false,
undoScheduled: false
undoScheduled: false,
connectionRefreshFrame: null
};
editors.set(host, state);
resetHistory(state);
@@ -422,13 +443,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);
@@ -451,11 +478,18 @@ function attach(host) {
selectConnection(state, path);
}, true);
// Rechte Maustaste: auf einem Knoten wird der Knoten verschoben (Drawflow
// zieht Knoten nur mit links), auf dem freien Canvas wird die gesamte
// Ansicht verschoben (Panning).
// Panning mit links und rechts über dieselben dokumentweiten Handler:
// Drawflow hört selbst nur am Container und verliert beim Verlassen des
// Canvas insbesondere das mouseup. Seine Auswahl-Logik bleibt erhalten.
let rightDrag = null;
let rightPan = null;
let canvasPan = null;
editor.on('clickEnd', e => {
if (e.type !== 'mousedown' || e.button !== 0 || !editor.editor_selected) return;
editor.editor_selected = false;
canvasPan = { lastX: e.clientX, lastY: e.clientY, button: 0 };
});
// Rechte Maustaste: Knoten verschieben oder auf freiem Canvas pannen.
container.addEventListener('mousedown', e => {
if (e.button !== 2) return;
e.preventDefault();
@@ -469,18 +503,30 @@ function attach(host) {
lastY: e.clientY
};
} else {
rightPan = { lastX: e.clientX, lastY: e.clientY };
canvasPan = { lastX: e.clientX, lastY: e.clientY, button: 2 };
}
}, true);
const onRightMove = e => {
if (rightPan) {
editor.canvas_x += e.clientX - rightPan.lastX;
editor.canvas_y += e.clientY - rightPan.lastY;
rightPan.lastX = e.clientX;
rightPan.lastY = e.clientY;
const moveCanvas = e => {
editor.canvas_x += e.clientX - canvasPan.lastX;
editor.canvas_y += e.clientY - canvasPan.lastY;
canvasPan.lastX = e.clientX;
canvasPan.lastY = e.clientY;
editor.precanvas.style.transform =
'translate(' + editor.canvas_x + 'px, ' + editor.canvas_y + 'px) scale(' + editor.zoom + ')';
editor.dispatch('translate', { x: editor.canvas_x, y: editor.canvas_y });
};
const onDragMove = e => {
if (!canvasPan && !rightDrag) return;
// Auch ein außerhalb des Browserfensters verlorenes mouseup abfangen.
const buttonMask = canvasPan?.button === 0 ? 1 : 2;
if (!(e.buttons & buttonMask)) {
onDragEnd();
return;
}
if (canvasPan) {
e.preventDefault();
moveCanvas(e);
return;
}
if (!rightDrag) return;
@@ -496,15 +542,20 @@ function attach(host) {
if (data) { data.pos_x = newLeft; data.pos_y = newTop; }
editor.updateConnectionNodes('node-' + rightDrag.id);
};
const onRightUp = () => {
const onDragEnd = e => {
if (e?.type === 'mouseup') {
if (e.button !== (canvasPan?.button ?? 2)) return;
if (canvasPan) moveCanvas(e);
}
// Ein per Rechts-Drag verschobener Knoten löst Drawflows nodeMoved
// nicht aus -> Undo-Schnappschuss hier ablegen.
if (rightDrag && rightDrag.moved) recordHistory(state);
rightDrag = null;
rightPan = null;
canvasPan = null;
};
document.addEventListener('mousemove', onRightMove);
document.addEventListener('mouseup', onRightUp);
document.addEventListener('mousemove', onDragMove, true);
document.addEventListener('mouseup', onDragEnd, true);
window.addEventListener('blur', onDragEnd);
// Rechts dient nur zum Verschieben: Browser-Kontextmenü unterdrücken und
// das Event in der Capture-Phase stoppen, bevor Drawflows eigener
@@ -566,10 +617,16 @@ 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);
document.removeEventListener('mouseup', onRightUp);
document.removeEventListener('mousemove', onDragMove, true);
document.removeEventListener('mouseup', onDragEnd, true);
window.removeEventListener('blur', onDragEnd);
container.removeEventListener('wheel', onWheelZoom, { capture: true });
};
@@ -668,6 +725,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 +752,7 @@ window.taskListEditor = {
current--;
}
recordHistory(state);
requestAnimationFrame(() => refreshValidity(state));
scheduleConnectionRefresh(state);
},
updateNodeLabel(host, nodeId, label) {
const state = editors.get(host);
@@ -709,6 +767,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 +820,7 @@ window.taskListEditor = {
// Frisch geladener Stand ist der neue Basiszustand der Undo-Historie.
resetHistory(state);
scheduleConnectionRefresh(state);
return JSON.stringify(ids);
},
importGraph(host, graphJson) {
@@ -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.
*
* <p>Die Collection-Namen werden aus {@code application.properties} gebunden,
* deren Werte wiederum aus den {@code MONGODB_ELEMENT_*}-Variablen der
* {@code .env} stammen:
* <pre>
* tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LISTE_COLLECTION:element_text_liste_erfassen}
* </pre>
*
* <p>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<String, String> elementCollections = new HashMap<>();
public Map<String, String> 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;
}
}
@@ -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.
*
* <p>{@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<CanvasBlockState> blocks;
public CanvasDocument() {
}
public CanvasDocument(String name, Instant createdAt, String layout, List<CanvasBlockState> 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<CanvasBlockState> getBlocks() {
return blocks;
}
public void setBlocks(List<CanvasBlockState> blocks) {
this.blocks = blocks;
}
}
@@ -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<CustomPropertyState> 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<CustomPropertyState> getProperties() {
return properties;
}
public void setProperties(List<CustomPropertyState> properties) {
this.properties = properties;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -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}).
*
* <p>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<String, Object> values;
private Instant createdAt;
public ElementSettingsDocument() {
}
public ElementSettingsDocument(String canvasId, String canvasName, int nodeId,
String typeId, String baseTypeId, String topic,
Map<String, Object> 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<String, Object> getValues() {
return values;
}
public void setValues(Map<String, Object> values) {
this.values = values;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -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<TaskDto> tasks;
private String layout;
public TaskListDocument() {
}
public TaskListDocument(String name, Instant createdAt, List<TaskDto> 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<TaskDto> getTasks() {
return tasks;
}
public void setTasks(List<TaskDto> tasks) {
this.tasks = tasks;
}
public String getLayout() {
return layout;
}
public void setLayout(String layout) {
this.layout = layout;
}
}
@@ -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
@@ -55,13 +59,17 @@ public class BlockTypeRegistry {
List.of()
));
// Die Typnummern 2–11 entsprechen der historischen Task-Typ-Belegung
// der stadtbote-App (2 = Unterschrift, 3 = Ankunft, 4 = Fotos,
// 5 = Barcode, 7–9 = Texteingaben, 11 = Statusauswahl). Neue, im
// Editor eingeführte Typen werden ab 12 aufsteigend nummeriert.
register(new BlockType(
"ankunft-melden",
"Ankunft melden",
"Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).",
"vaadin:map-marker",
"#2563eb",
1,
3,
1, 1,
List.of(
PropertyDefinition.bool("track", "GPS-Position erfassen", true),
@@ -92,7 +100,7 @@ public class BlockTypeRegistry {
"Nimmt eine Anzahl von Fotos auf.",
"vaadin:camera",
"#059669",
3,
4,
1, 1,
List.of(
PropertyDefinition.number("min", "Mindestanzahl Fotos", 1),
@@ -155,7 +163,7 @@ public class BlockTypeRegistry {
"Einfache Bestätigung per Häkchen ohne weitere Eingabe.",
"vaadin:check-square-o",
"#16a34a",
6,
12,
1, 1,
List.of(
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
@@ -194,17 +202,16 @@ public class BlockTypeRegistry {
)
));
// Listen-Tasks: erfassen mehrerer gleichartiger Werte. Die Anzahl wird
// analog zu "Fotos aufnehmen" über min/max gesteuert. Diese Typen haben
// (noch) keinen Handler in der stadtbote-App und liegen daher oberhalb
// des dort genutzten Bereichs (1–11).
// Listen-Tasks: erfassen mehrerer gleichartiger Werte über
// vordefinierte Eingabefelder. Wie alle im Editor neu eingeführten
// Typen liegen sie oberhalb des historischen App-Bereichs (2–11).
register(new BlockType(
"text-liste-erfassen",
"Liste von Texten erfassen",
"Erfasst mehrere Texteinträge über vordefinierte Eingabefelder.",
"vaadin:lines-list",
"#4338ca",
12,
13,
1, 1,
List.of(
PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"),
@@ -218,7 +225,7 @@ public class BlockTypeRegistry {
"Erfasst mehrere numerische Werte über vordefinierte Eingabefelder.",
"vaadin:list-ol",
"#0369a1",
13,
14,
1, 1,
List.of(
PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"),
@@ -232,7 +239,7 @@ public class BlockTypeRegistry {
"Lässt mehrere vordefinierte Optionen per Checkbox ankreuzen.",
"vaadin:tasks",
"#15803d",
14,
15,
1, 1,
List.of(
PropertyDefinition.nameList("entries", "Name der Checkboxen"),
@@ -258,6 +265,21 @@ public class BlockTypeRegistry {
types.put(type.id(), type);
}
private BlockTypeRegistry(List<BlockType> 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<BlockType> all() {
return List.copyOf(types.values());
}
@@ -266,7 +288,7 @@ public class BlockTypeRegistry {
return Optional.ofNullable(types.get(id));
}
/** Die festen Standard-Typen (Typnummer 1–8), auf denen eigene Typen aufbauen. */
/** Die festen Standard-Typen (Typnummer < 1000), auf denen eigene Typen aufbauen. */
public List<BlockType> baseTypes() {
return types.values().stream()
.filter(t -> t.taskType() >= 1 && t.taskType() < CUSTOM_TYPE_START)
@@ -278,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<Integer> reserved) {
var occupied = new HashSet<>(reserved);
types.values().forEach(type -> occupied.add(type.taskType()));
int highest = occupied.stream().mapToInt(value -> value.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;
}
@@ -318,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) {
@@ -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<String, Object> values;
public CanvasBlockState() {
}
public CanvasBlockState(int nodeId, String typeId, String topic, Map<String, Object> 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<String, Object> getValues() {
return values;
}
public void setValues(Map<String, Object> values) {
this.values = values;
}
}
@@ -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.
*/
@@ -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<CustomPropertyState> 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<Object> copy = new ArrayList<>(list.size());
list.forEach(item -> copy.add(copyValue(item)));
return copy;
}
if (value instanceof Map<?, ?> map) {
Map<Object, Object> copy = new LinkedHashMap<>();
map.forEach((key, item) -> copy.put(key, copyValue(item)));
return copy;
}
return value;
}
}
@@ -16,7 +16,7 @@ import java.util.List;
* erscheinen.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({"s_id", "type", "topic", "opt", "reenter", "min", "max",
@JsonPropertyOrder({"s_id", "type", "base_type", "topic", "opt", "reenter", "min", "max",
"max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "choices", "fields", "script"})
public class TaskDto {
@@ -25,6 +25,15 @@ public class TaskDto {
private int type;
/**
* Typnummer des Basis-Typs bei benutzerdefinierten Typen
* ({@code type >= }{@link BlockTypeRegistry#CUSTOM_TYPE_START}); die App
* stellt solche Tasks wie ihren Basis-Typ dar. Bei Standard-Typen
* {@code null} und damit nicht im JSON enthalten.
*/
@JsonProperty("base_type")
private Integer baseType;
private String topic;
private Boolean opt;
@@ -47,19 +56,21 @@ public class TaskDto {
private List<TaskEntryDto> entries;
/** Auswahloptionen eines Status-Tasks (Typ 6). */
/** Auswahloptionen eines Status-Tasks (Typ 11). */
private List<ChoiceDto> choices;
/** Eingabefelder eines Listen-Text-Tasks (Typ 12). */
/** Eingabefelder einer Text-/Zahlen-Liste (Typ 13/14). */
private List<FieldDto> fields;
/** 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;
}
@@ -72,6 +83,14 @@ public class TaskDto {
this.type = type;
}
public Integer getBaseType() {
return baseType;
}
public void setBaseType(Integer baseType) {
this.baseType = baseType;
}
public String getTopic() {
return topic;
}
@@ -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<TaskDto> tasks;
private JsonNode metadata;
private String returnOrigin;
private List<CustomTypeDefinition> customTypes = List.of();
public String getName() {
return name;
@@ -29,4 +34,28 @@ public class TaskListPayload {
public void setTasks(List<TaskDto> 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<CustomTypeDefinition> getCustomTypes() {
return customTypes;
}
public void setCustomTypes(List<CustomTypeDefinition> customTypes) {
this.customTypes = customTypes == null ? List.of() : List.copyOf(customTypes);
}
}
@@ -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<CanvasDocument, String> {
List<CanvasDocument> findAllByOrderByCreatedAtDesc();
}
@@ -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<CustomTypeDocument, String> {
List<CustomTypeDocument> findAllByOrderByTaskTypeAsc();
long deleteByTaskType(int taskType);
}
@@ -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<TaskListDocument, String> {
}
@@ -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<ImportResponse> 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<List<TaskDto>>() {
}));
} 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;
}
// Objekt mit { name?, tasks: [...] }
return objectMapper.convertValue(body, TaskListPayload.class);
/** 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);
}
}
}
@@ -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<CanvasBlockState> blocks) {
CanvasDocument document = new CanvasDocument(name, Instant.now(), layout, blocks);
return repository.save(document).getId();
}
public List<CanvasDocument> listNewestFirst() {
return repository.findAllByOrderByCreatedAtDesc();
}
public Optional<CanvasDocument> load(String id) {
return repository.findById(id);
}
}
@@ -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<BlockType> 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()
);
}
}
@@ -0,0 +1,126 @@
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<String> ids = new HashSet<>();
Set<Integer> 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) {
if (properties == null || !properties.isArray()) {
throw new IllegalArgumentException("properties must be an array");
}
require(properties.size() == base.properties().size(), "Properties must match the base type");
Set<String> 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) {
if (value == null) {
throw new IllegalArgumentException("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);
if (value == null || !value.isTextual() || (!allowBlank && value.textValue().isBlank())) {
throw new IllegalArgumentException(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);
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) {
throw new IllegalArgumentException(field + " must be an integer");
}
return value.intValue();
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new IllegalArgumentException(message);
}
}
}
@@ -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.
*
* <p>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<BlockInstance> 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));
}
}
}
@@ -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.
*
* <p>Bewusst In-Memory gehalten (transienter Hand-off). Für einen
* mehrinstanzigen Betrieb müsste hier ein geteilter Speicher (z.&nbsp;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<TaskListPayload> 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<TaskListPayload> 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));
}
}
@@ -32,9 +32,11 @@ import java.util.Set;
public class TaskListExporter {
private final ObjectMapper objectMapper;
private final BlockTypeRegistry registry;
public TaskListExporter(ObjectMapper objectMapper) {
public TaskListExporter(ObjectMapper objectMapper, BlockTypeRegistry registry) {
this.objectMapper = objectMapper;
this.registry = registry;
}
public List<TaskDto> export(String drawflowJson, Map<Integer, BlockInstance> instances) {
@@ -225,6 +227,13 @@ public class TaskListExporter {
private TaskDto toTask(BlockInstance instance) {
TaskDto task = new TaskDto();
task.setType(instance.type().taskType());
// Benutzerdefinierte Typen verhalten sich in der App wie ihr
// Basis-Typ; dessen Typnummer wird als base_type mit exportiert.
if (instance.type().taskType() >= BlockTypeRegistry.CUSTOM_TYPE_START
&& instance.type().baseTypeId() != null) {
registry.find(instance.type().baseTypeId())
.ifPresent(base -> task.setBaseType(base.taskType()));
}
String topic = instance.topic();
task.setTopic(topic == null || topic.isBlank() ? instance.type().label() : topic);
@@ -37,12 +37,17 @@ public class TaskListImporter {
}
public List<ResolvedTask> resolve(List<TaskDto> tasks) {
return resolve(tasks, registry);
}
/** Verwendet den Katalog des konkreten Editor-Aufrufs. */
public List<ResolvedTask> resolve(List<TaskDto> tasks, BlockTypeRegistry editorRegistry) {
List<ResolvedTask> 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);
@@ -64,13 +69,16 @@ public class TaskListImporter {
private Map<String, Object> valuesFor(BlockType type, TaskDto task) {
Map<String, Object> values = new LinkedHashMap<>();
for (PropertyDefinition def : type.properties()) {
// Listen-Eigenschaften (Typ 12–14) werden anhand ihres
// Listen-Eigenschaften (Typ 13–15) werden anhand ihres
// 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<Object> copy = new ArrayList<>(list.size());
list.forEach(item -> copy.add(copyValue(item)));
return copy;
}
if (value instanceof Map<?, ?> map) {
Map<Object, Object> 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());
@@ -95,7 +118,7 @@ public class TaskListImporter {
};
}
/** {@code fields}-Array (Typ 12/13) zurück in Zeilen mit Titel + Platzhalter. */
/** {@code fields}-Array (Typ 13/14) zurück in Zeilen mit Titel + Platzhalter. */
private List<Map<String, Object>> fieldRows(TaskDto task) {
List<Map<String, Object>> rows = new ArrayList<>();
if (task.getFields() == null) {
@@ -111,7 +134,7 @@ public class TaskListImporter {
return rows;
}
/** {@code entries}-Array (Typ 14) zurück in Zeilen mit Namen ({@code title}). */
/** {@code entries}-Array (Typ 15) zurück in Zeilen mit Namen ({@code title}). */
private List<Map<String, Object>> entryRows(TaskDto task) {
List<Map<String, Object>> rows = new ArrayList<>();
if (task.getEntries() == null) {
@@ -129,7 +152,7 @@ public class TaskListImporter {
/** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */
private List<Map<String, Object>> choiceRows(TaskDto task) {
List<Map<String, Object>> rows = new ArrayList<>();
if (task.getChoices() != null && !task.getChoices().isEmpty()) {
if (task.getChoices() != null) {
for (ChoiceDto choice : task.getChoices()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("title", choice.getTxt() == null ? "" : choice.getTxt());
@@ -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<TaskDto> tasks, String layout) {
TaskListDocument document = new TaskListDocument(name, Instant.now(), tasks, layout);
return repository.save(document).getId();
}
}
@@ -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<BlockType> 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<BlockType> availableTypes) {
StringBuilder prompt = new StringBuilder();
prompt.append("""
Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows
@@ -230,9 +236,9 @@ public class WorkflowAssistantService {
(boolean) erlaubt erneutes Bearbeiten.
- Listenfelder:
* "fields": Array aus {"title": "...", "placeholder": "...", "required": true|false}
(Text-/Zahlenlisten, Typ 12/13).
(Text-/Zahlenlisten, Typ 13/14).
* "entries": Array aus {"code": <fortlaufend ab 1>, "text": "...", "required": true|false}
(Checkbox-Liste, Typ 14).
(Checkbox-Liste, Typ 15).
* "choices": Array aus {"txt": "...", "ret": <fortlaufend ab 1>}
(Statusauswahl, Typ 11).
Kennzeichne Einträge nur dann mit "required": true, wenn der Benutzer sie
@@ -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.
}
@@ -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<Map<String,Object>>} 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<String> 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<TaskDto> 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<TaskDto> tasks, String name, String successHint) {
List<TaskListImporter.ResolvedTask> resolved = taskListImporter.resolve(tasks);
List<TaskListImporter.ResolvedTask> 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<BlockType> 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<CanvasBlockState> collectBlocks() {
List<CanvasBlockState> 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<CanvasDocument> 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<CanvasDocument> 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<CustomTypeDefinition> 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<TaskDto> 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();
}
+4 -31
View File
@@ -1,4 +1,5 @@
server.port=8080
spring.application.name=workflow-editor
# Anwendungsversion: wird beim Build per Maven-Resource-Filtering
# (spring-boot-starter-parent, @...@-Delimiter) aus der pom.xml gefüllt.
@@ -6,6 +7,9 @@ tasklist.app-version=@project.version@
vaadin.launch-browser=true
vaadin.allowed-packages=de.assecutor.tasklisteditor
# Lit-/Router-Modus statt React (muss zum reactEnable=false im pom.xml passen,
# damit der Dev-Modus beim IDE-Start kein React-Frontend generiert).
vaadin.react.enable=false
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true
@@ -14,18 +18,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}
@@ -37,22 +29,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}
@@ -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<String, Object> row = new LinkedHashMap<>();
row.put("title", "Referenz");
row.put("placeholder", "Nummer eingeben");
row.put("required", true);
List<Map<String, Object>> 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<Map<String, Object>> firstRows =
(List<Map<String, Object>>) firstEditor.properties().getFirst().defaultValue();
List<Map<String, Object>> secondRows =
(List<Map<String, Object>>) 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());
}
}
@@ -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"));
}
}
@@ -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);
}
}
}
@@ -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<Integer, BlockInstance> 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<TaskDto> 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"));
}
}
@@ -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());
}
}
@@ -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<Map<String, Object>> 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<Map<String, Object>> first = (List<Map<String, Object>>) 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;
}
}
+203
View File
@@ -0,0 +1,203 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workflow Editor · Testaufrufer</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #172033; background: #f3f5f9; }
* { box-sizing: border-box; }
body { margin: 0; }
main { max-width: 1100px; padding: 32px 24px; margin: auto; }
h1 { font-size: 27px; margin: 0 0 10px; }
p { line-height: 1.5; }
.muted { color: #566278; }
section { padding: 24px; margin-top: 24px; border: 1px solid #dfe5ef; border-radius: 12px; background: white; }
label { display: block; font-weight: 600; margin-bottom: 10px; }
textarea { display: block; width: 100%; min-height: 300px; padding: 16px; border: 1px solid #bac5d6; border-radius: 8px; font: 14px/1.5 ui-monospace, monospace; resize: vertical; }
button, .file-label { font: inherit; cursor: pointer; border: 1px solid #bac5d6; border-radius: 6px; padding: 10px 16px; color: #172033; background: #fff; margin: 0; }
button.primary { background: #2563eb; color: white; border-color: #2563eb; }
button:disabled { cursor: wait; opacity: .55; }
input[type=file] { max-width: 100%; font: inherit; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 16px; }
#status { min-height: 24px; margin-top: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
#status.error { color: #b42318; }
#result { background: #f4f6fa; padding: 16px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow-y: auto; }
#editor-panel { position: fixed; inset: 0; background: #fff; z-index: 5; display: flex; flex-direction: column; }
#editor-panel[hidden], [hidden] { display: none !important; }
#editor-header { padding: 10px 20px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; border-bottom: 1px solid #dfe5ef; background: #f3f5f9; }
#editor-status { flex: 1; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
iframe { width: 100%; border: 0; flex: 1; min-height: 0; }
code { font: .93em ui-monospace, monospace; }
</style>
</head>
<body>
<main>
<h1>Workflow Editor testen</h1>
<p class="muted">Tasks, eigene Typen und Metadaten übergeben, im Editor bearbeiten und das Ergebnis in der Python-Konsole lesen.</p>
<p class="muted" id="editor-url">Verbindung wird vorbereitet …</p>
<section>
<label for="payload">JSON für den Editor</label>
<textarea id="payload" spellcheck="false" aria-describedby="payload-help"></textarea>
<p class="muted" id="payload-help">Ein <code>tasks</code>-Array oder ein Objekt mit <code>name</code>, <code>tasks</code>, dem vollständigen Katalog eigener Typen in <code>customTypes</code> und beliebigen <code>metadata</code>. Für eine neue Liste: <code>"tasks": []</code>. Nach „Fertig“ wird das Eingabefeld mit den zurückgegebenen Tasks und Typen aktualisiert.</p>
<div class="actions">
<button class="primary" id="open" disabled>Im Editor öffnen</button>
<button id="new" disabled>Neue Liste</button>
<input id="file" type="file" accept=".json,application/json" aria-label="JSON-Datei laden" disabled>
</div>
<p id="status" role="status" aria-live="polite"></p>
</section>
<section id="result-section" hidden>
<h2>Empfangene Rückgabe</h2>
<pre id="result"></pre>
</section>
</main>
<div id="editor-panel" hidden>
<div id="editor-header">
<p id="editor-status" role="status" aria-live="polite">Mit „Fertig“ übernehmen oder mit „Abbrechen“ ohne Änderungen zurückkehren.</p>
<button id="retry" hidden>Rückgabe erneut senden</button>
</div>
<iframe id="editor" title="Workflow Editor"></iframe>
</div>
<script>
'use strict';
const payloadInput = document.querySelector('#payload');
const status = document.querySelector('#status');
const openButton = document.querySelector('#open');
const newButton = document.querySelector('#new');
const fileInput = document.querySelector('#file');
const panel = document.querySelector('#editor-panel');
const frame = document.querySelector('#editor');
const editorStatus = document.querySelector('#editor-status');
const retryButton = document.querySelector('#retry');
let config;
let activeToken = null;
let pendingResult = null;
let submitting = false;
function setStatus(message, error = false) {
status.textContent = message;
status.classList.toggle('error', error);
}
async function request(path, value) {
let response;
try {
response = await fetch(path, value === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(value)
});
} catch (error) {
throw new Error(`Verbindung zum Python-Testserver unter ${window.location.origin} fehlgeschlagen. ` +
'Starte ihn mit „python3 tools/editor_test_server.py“ und lasse das Terminal geöffnet. ' +
'Versuche es anschließend erneut.');
}
const result = await response.json();
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
return result;
}
openButton.addEventListener('click', async () => {
openButton.disabled = true;
setStatus('JSON wird an den Editor übergeben …');
try {
const payload = JSON.parse(payloadInput.value);
const result = await request('/api/open', payload);
if (new URL(result.deepLink).origin !== config.editorOrigin) {
throw new Error('Der Editor hat einen unerwarteten Origin zurückgegeben.');
}
activeToken = result.token;
pendingResult = null;
retryButton.hidden = true;
editorStatus.textContent = 'Mit „Fertig“ übernehmen oder mit „Abbrechen“ ohne Änderungen zurückkehren.';
frame.src = result.deepLink;
panel.hidden = false;
setStatus('Editor geöffnet.');
} catch (error) {
setStatus(error.message, true);
} finally {
openButton.disabled = false;
}
});
newButton.addEventListener('click', () => {
try {
const previous = JSON.parse(payloadInput.value);
const customTypes = previous?.customTypes ?? [];
payloadInput.value = JSON.stringify({name: 'Neue Testliste', tasks: [], customTypes,
metadata: {source: 'python-testserver'}}, null, 2);
setStatus('Neue leere Liste mit den vorhandenen eigenen Typen vorbereitet. Mit „Im Editor öffnen“ beginnen.');
} catch (error) {
setStatus('Die Eingabe enthält ungültiges JSON. Bitte korrigieren, damit eigene Typen übernommen werden können.', true);
}
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
try {
if (file.size > 2 * 1024 * 1024) throw new Error('Die JSON-Datei darf höchstens 2 MiB groß sein.');
const value = JSON.parse((await file.text()).replace(/^\uFEFF/, ''));
payloadInput.value = JSON.stringify(value, null, 2);
setStatus(`Datei „${file.name}“ geladen.`);
} catch (error) {
setStatus(error.message, true);
} finally {
fileInput.value = '';
}
});
async function submitResult() {
if (submitting || !pendingResult) return;
submitting = true;
retryButton.disabled = true;
editorStatus.textContent = 'Rückgabe wird an die Python-Konsole gesendet …';
try {
await request('/api/result', pendingResult);
document.querySelector('#result').textContent = JSON.stringify(pendingResult, null, 2);
document.querySelector('#result-section').hidden = false;
if (pendingResult.status === 'completed') {
const payload = {tasks: pendingResult.tasks, customTypes: pendingResult.customTypes};
for (const key of ['name', 'metadata']) {
if (Object.hasOwn(pendingResult, key)) payload[key] = pendingResult[key];
}
payloadInput.value = JSON.stringify(payload, null, 2);
}
setStatus(pendingResult.status === 'completed'
? 'Bearbeitung abgeschlossen. Tasks und eigene Typen wurden für den nächsten Aufruf übernommen und in der Python-Konsole ausgegeben.'
: 'Bearbeitung abgebrochen. Die Abbruchmeldung wurde in der Python-Konsole ausgegeben.');
activeToken = null;
pendingResult = null;
panel.hidden = true;
frame.src = 'about:blank';
openButton.focus();
} catch (error) {
editorStatus.textContent = `Rückgabe noch nicht angekommen: ${error.message}`;
retryButton.hidden = false;
} finally {
submitting = false;
retryButton.disabled = false;
}
}
window.addEventListener('message', event => {
if (!config || !activeToken || event.origin !== config.editorOrigin || event.source !== frame.contentWindow) return;
const result = event.data;
if (!result || typeof result !== 'object' || result.type !== 'tasklist-editor:result' || result.token !== activeToken) return;
if (!['completed', 'cancelled'].includes(result.status)) return;
if (result.status === 'completed' && (!Array.isArray(result.tasks) || !Array.isArray(result.customTypes))) return;
if (result.status === 'cancelled' && (Object.hasOwn(result, 'tasks') || Object.hasOwn(result, 'customTypes'))) return;
if (submitting) return;
pendingResult = result;
submitResult();
});
retryButton.addEventListener('click', submitResult);
request('/api/config').then(value => {
config = value;
payloadInput.value = JSON.stringify(config.initialPayload, null, 2);
document.querySelector('#editor-url').textContent = 'Editor: ' + config.editorUrl;
openButton.disabled = newButton.disabled = fileInput.disabled = false;
}).catch(error => setStatus(error.message, true));
</script>
</body>
</html>
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Lokaler Aufrufer für den Workflow 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 = "WorkflowEditorTestServer/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()
+47
View File
@@ -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"
}
}
+219
View File
@@ -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"Workflow 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()