Compare commits
3
Commits
525e775d1c
...
19d97a47b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d97a47b5 | ||
|
|
f979c797ba | ||
|
|
93ba40c476 |
No files matched your search
+1
-37
@@ -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
|
# 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:
|
# Script-Block (grafisches Logik-Element) ausblenden:
|
||||||
# true = Script-Element wird im Editor nicht angezeigt
|
# true = Script-Element wird im Editor nicht angezeigt
|
||||||
# false = Script-Element ist verfügbar (Standard)
|
# false = Script-Element ist verfügbar (Standard)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM eclipse-temurin:21-jre
|
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
|
# Zeitzone auf Berlin setzen und 24h-Format konfigurieren
|
||||||
ENV TZ=Europe/Berlin
|
ENV TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Workflow Editor (TaskList Editor)
|
# Workflow Editor
|
||||||
|
|
||||||
n8n-inspirierter Editor zum Erstellen von Aufgabenlisten (Tasks) für die
|
n8n-inspirierter Editor zum Erstellen von Aufgabenlisten (Tasks) für die
|
||||||
stadtbote-App. Funktionsblöcke werden per Drag & Drop auf einem Canvas
|
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,
|
- Funktionsblöcke für die stadtbote-Task-Typen (Ankunft, Unterschrift, Fotos,
|
||||||
Quittungsgeber, Stationshinweis, Kommissionsnummer, Abhol-/Lieferstatus)
|
Quittungsgeber, Stationshinweis, Kommissionsnummer, Abhol-/Lieferstatus)
|
||||||
- Erzeugen des app-konformen Task-JSON und Speichern in MongoDB
|
- Erzeugen des app-konformen Task-JSON
|
||||||
- Speichern und Wiederherstellen des Canvas-Standes in MongoDB
|
- Eigene Typen mit konfigurierbaren Vorgaben
|
||||||
- Aufruf per Deep-Link mit Übergabe von Task-Daten als JSON-Body
|
- 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
|
## Technik
|
||||||
|
|
||||||
- Java 21, Spring Boot 3, Vaadin 24
|
- Java 21, Spring Boot 3, Vaadin 24
|
||||||
- MongoDB (Spring Data) zur Persistenz
|
|
||||||
- Drawflow als Canvas-/Node-Editor
|
- Drawflow als Canvas-/Node-Editor
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Zugangsdaten und Collections werden über eine `.env`-Datei gesetzt
|
Für den Editor ist keine Datenbank erforderlich. Tasks und eigene Typen werden
|
||||||
(Vorlage: `.env.example`):
|
beim Aufruf übergeben und bei **Fertig** an den Aufrufer zurückgegeben. Der
|
||||||
|
Aufrufer ist für deren dauerhafte Speicherung verantwortlich.
|
||||||
|
|
||||||
```
|
Optionale Einstellungen sind in `.env.example` beschrieben.
|
||||||
MONGODB_URI=mongodb://localhost:27017
|
|
||||||
MONGODB_DATABASE=tasklist_editor
|
|
||||||
MONGODB_COLLECTION=tasks_json
|
|
||||||
MONGODB_CANVAS_COLLECTION=canvas_layouts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
@@ -37,3 +33,126 @@ mvn spring-boot:run
|
|||||||
```
|
```
|
||||||
|
|
||||||
Die Anwendung läuft anschließend unter http://localhost:8080/.
|
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
@@ -3,7 +3,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
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}"
|
readonly BACKEND_DIR="${SCRIPT_DIR}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -67,7 +67,7 @@ echo "Verwende Release-Version ${VERSION}."
|
|||||||
echo "Baue Production-JAR für Version ${VERSION} ..."
|
echo "Baue Production-JAR für Version ${VERSION} ..."
|
||||||
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests package)
|
(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}"
|
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
|
||||||
[[ -f "${JAR_FILE_ABS}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
|
[[ -f "${JAR_FILE_ABS}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-895
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "no-name",
|
"name": "workflow-editor",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>de.assecutor</groupId>
|
<groupId>de.assecutor</groupId>
|
||||||
<artifactId>tasklist-editor</artifactId>
|
<artifactId>workflow-editor</artifactId>
|
||||||
<version>0.9.2</version>
|
<version>0.9.3</version>
|
||||||
<name>TaskList Editor</name>
|
<name>Workflow Editor</name>
|
||||||
<description>n8n-inspired editor for task list workflows</description>
|
<description>n8n-inspired editor for task list workflows</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
@@ -45,11 +45,6 @@
|
|||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</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. -->
|
<!-- Lädt Variablen aus einer .env-Datei in die Spring-Umgebung. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>me.paulschwarz</groupId>
|
<groupId>me.paulschwarz</groupId>
|
||||||
|
|||||||
@@ -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. */
|
/** Liefert true, wenn mindestens einer der vorhandenen Ports keine Verbindung hat. */
|
||||||
function hasOpenPort(ports) {
|
function hasOpenPort(ports) {
|
||||||
if (!ports) return false;
|
if (!ports) return false;
|
||||||
@@ -329,7 +347,8 @@ function attach(host) {
|
|||||||
registry: new Map(),
|
registry: new Map(),
|
||||||
history: [],
|
history: [],
|
||||||
undoSuppressed: false,
|
undoSuppressed: false,
|
||||||
undoScheduled: false
|
undoScheduled: false,
|
||||||
|
connectionRefreshFrame: null
|
||||||
};
|
};
|
||||||
editors.set(host, state);
|
editors.set(host, state);
|
||||||
resetHistory(state);
|
resetHistory(state);
|
||||||
@@ -422,13 +441,19 @@ function attach(host) {
|
|||||||
|
|
||||||
// Unvollständig verdrahtete Elemente (offener Ein- oder Ausgang) hellrot
|
// Unvollständig verdrahtete Elemente (offener Ein- oder Ausgang) hellrot
|
||||||
// markieren. Nach jeder strukturellen Änderung neu bewerten.
|
// markieren. Nach jeder strukturellen Änderung neu bewerten.
|
||||||
const refresh = () => requestAnimationFrame(() => refreshValidity(state));
|
const refresh = () => scheduleConnectionRefresh(state);
|
||||||
editor.on('connectionCreated', refresh);
|
editor.on('connectionCreated', refresh);
|
||||||
editor.on('connectionRemoved', refresh);
|
editor.on('connectionRemoved', refresh);
|
||||||
editor.on('nodeCreated', refresh);
|
editor.on('nodeCreated', refresh);
|
||||||
editor.on('nodeRemoved', refresh);
|
editor.on('nodeRemoved', refresh);
|
||||||
editor.on('import', 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.
|
// Undo-Historie: jede strukturelle Änderung erzeugt einen Schnappschuss.
|
||||||
const record = () => recordHistory(state);
|
const record = () => recordHistory(state);
|
||||||
editor.on('nodeCreated', record);
|
editor.on('nodeCreated', record);
|
||||||
@@ -566,6 +591,11 @@ function attach(host) {
|
|||||||
document.addEventListener('keydown', undoKeyHandler);
|
document.addEventListener('keydown', undoKeyHandler);
|
||||||
|
|
||||||
state.cleanup = () => {
|
state.cleanup = () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (state.connectionRefreshFrame != null) {
|
||||||
|
cancelAnimationFrame(state.connectionRefreshFrame);
|
||||||
|
state.connectionRefreshFrame = null;
|
||||||
|
}
|
||||||
document.removeEventListener('keydown', deleteSelectedConnection);
|
document.removeEventListener('keydown', deleteSelectedConnection);
|
||||||
document.removeEventListener('keydown', undoKeyHandler);
|
document.removeEventListener('keydown', undoKeyHandler);
|
||||||
document.removeEventListener('mousemove', onRightMove);
|
document.removeEventListener('mousemove', onRightMove);
|
||||||
@@ -668,6 +698,7 @@ window.taskListEditor = {
|
|||||||
attach,
|
attach,
|
||||||
registerBlockTypes(host, types) {
|
registerBlockTypes(host, types) {
|
||||||
const state = attach(host);
|
const state = attach(host);
|
||||||
|
state.registry.clear();
|
||||||
for (const t of types) {
|
for (const t of types) {
|
||||||
state.registry.set(t.id, t);
|
state.registry.set(t.id, t);
|
||||||
}
|
}
|
||||||
@@ -694,7 +725,7 @@ window.taskListEditor = {
|
|||||||
current--;
|
current--;
|
||||||
}
|
}
|
||||||
recordHistory(state);
|
recordHistory(state);
|
||||||
requestAnimationFrame(() => refreshValidity(state));
|
scheduleConnectionRefresh(state);
|
||||||
},
|
},
|
||||||
updateNodeLabel(host, nodeId, label) {
|
updateNodeLabel(host, nodeId, label) {
|
||||||
const state = editors.get(host);
|
const state = editors.get(host);
|
||||||
@@ -709,6 +740,7 @@ window.taskListEditor = {
|
|||||||
if (nodeData) nodeData.html = html;
|
if (nodeData) nodeData.html = html;
|
||||||
const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`);
|
const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`);
|
||||||
if (el) el.innerHTML = html;
|
if (el) el.innerHTML = html;
|
||||||
|
scheduleConnectionRefresh(state);
|
||||||
recordHistory(state);
|
recordHistory(state);
|
||||||
},
|
},
|
||||||
undo(host) {
|
undo(host) {
|
||||||
@@ -761,6 +793,7 @@ window.taskListEditor = {
|
|||||||
|
|
||||||
// Frisch geladener Stand ist der neue Basiszustand der Undo-Historie.
|
// Frisch geladener Stand ist der neue Basiszustand der Undo-Historie.
|
||||||
resetHistory(state);
|
resetHistory(state);
|
||||||
|
scheduleConnectionRefresh(state);
|
||||||
return JSON.stringify(ids);
|
return JSON.stringify(ids);
|
||||||
},
|
},
|
||||||
importGraph(host, graphJson) {
|
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;
|
package de.assecutor.tasklisteditor.model;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -28,6 +31,7 @@ public class BlockTypeRegistry {
|
|||||||
/** Typnummer der virtuellen Abschluss-Markierung (negativ = nicht exportiert). */
|
/** Typnummer der virtuellen Abschluss-Markierung (negativ = nicht exportiert). */
|
||||||
public static final int END_TASK_TYPE = -2;
|
public static final int END_TASK_TYPE = -2;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) {
|
public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) {
|
||||||
// Rein virtuelle Workflow-Markierungen: Start (nur Ausgang) und Abschluss
|
// Rein virtuelle Workflow-Markierungen: Start (nur Ausgang) und Abschluss
|
||||||
// (nur Eingang). Sie dienen ausschließlich der Darstellung eines
|
// (nur Eingang). Sie dienen ausschließlich der Darstellung eines
|
||||||
@@ -55,13 +59,17 @@ public class BlockTypeRegistry {
|
|||||||
List.of()
|
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(
|
register(new BlockType(
|
||||||
"ankunft-melden",
|
"ankunft-melden",
|
||||||
"Ankunft melden",
|
"Ankunft melden",
|
||||||
"Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).",
|
"Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).",
|
||||||
"vaadin:map-marker",
|
"vaadin:map-marker",
|
||||||
"#2563eb",
|
"#2563eb",
|
||||||
1,
|
3,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.bool("track", "GPS-Position erfassen", true),
|
PropertyDefinition.bool("track", "GPS-Position erfassen", true),
|
||||||
@@ -92,7 +100,7 @@ public class BlockTypeRegistry {
|
|||||||
"Nimmt eine Anzahl von Fotos auf.",
|
"Nimmt eine Anzahl von Fotos auf.",
|
||||||
"vaadin:camera",
|
"vaadin:camera",
|
||||||
"#059669",
|
"#059669",
|
||||||
3,
|
4,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.number("min", "Mindestanzahl Fotos", 1),
|
PropertyDefinition.number("min", "Mindestanzahl Fotos", 1),
|
||||||
@@ -155,7 +163,7 @@ public class BlockTypeRegistry {
|
|||||||
"Einfache Bestätigung per Häkchen ohne weitere Eingabe.",
|
"Einfache Bestätigung per Häkchen ohne weitere Eingabe.",
|
||||||
"vaadin:check-square-o",
|
"vaadin:check-square-o",
|
||||||
"#16a34a",
|
"#16a34a",
|
||||||
6,
|
12,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
|
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
|
||||||
@@ -194,17 +202,16 @@ public class BlockTypeRegistry {
|
|||||||
)
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Listen-Tasks: erfassen mehrerer gleichartiger Werte. Die Anzahl wird
|
// Listen-Tasks: erfassen mehrerer gleichartiger Werte über
|
||||||
// analog zu "Fotos aufnehmen" über min/max gesteuert. Diese Typen haben
|
// vordefinierte Eingabefelder. Wie alle im Editor neu eingeführten
|
||||||
// (noch) keinen Handler in der stadtbote-App und liegen daher oberhalb
|
// Typen liegen sie oberhalb des historischen App-Bereichs (2–11).
|
||||||
// des dort genutzten Bereichs (1–11).
|
|
||||||
register(new BlockType(
|
register(new BlockType(
|
||||||
"text-liste-erfassen",
|
"text-liste-erfassen",
|
||||||
"Liste von Texten erfassen",
|
"Liste von Texten erfassen",
|
||||||
"Erfasst mehrere Texteinträge über vordefinierte Eingabefelder.",
|
"Erfasst mehrere Texteinträge über vordefinierte Eingabefelder.",
|
||||||
"vaadin:lines-list",
|
"vaadin:lines-list",
|
||||||
"#4338ca",
|
"#4338ca",
|
||||||
12,
|
13,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"),
|
PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"),
|
||||||
@@ -218,7 +225,7 @@ public class BlockTypeRegistry {
|
|||||||
"Erfasst mehrere numerische Werte über vordefinierte Eingabefelder.",
|
"Erfasst mehrere numerische Werte über vordefinierte Eingabefelder.",
|
||||||
"vaadin:list-ol",
|
"vaadin:list-ol",
|
||||||
"#0369a1",
|
"#0369a1",
|
||||||
13,
|
14,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"),
|
PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"),
|
||||||
@@ -232,7 +239,7 @@ public class BlockTypeRegistry {
|
|||||||
"Lässt mehrere vordefinierte Optionen per Checkbox ankreuzen.",
|
"Lässt mehrere vordefinierte Optionen per Checkbox ankreuzen.",
|
||||||
"vaadin:tasks",
|
"vaadin:tasks",
|
||||||
"#15803d",
|
"#15803d",
|
||||||
14,
|
15,
|
||||||
1, 1,
|
1, 1,
|
||||||
List.of(
|
List.of(
|
||||||
PropertyDefinition.nameList("entries", "Name der Checkboxen"),
|
PropertyDefinition.nameList("entries", "Name der Checkboxen"),
|
||||||
@@ -258,6 +265,21 @@ public class BlockTypeRegistry {
|
|||||||
types.put(type.id(), type);
|
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() {
|
public List<BlockType> all() {
|
||||||
return List.copyOf(types.values());
|
return List.copyOf(types.values());
|
||||||
}
|
}
|
||||||
@@ -266,7 +288,7 @@ public class BlockTypeRegistry {
|
|||||||
return Optional.ofNullable(types.get(id));
|
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() {
|
public List<BlockType> baseTypes() {
|
||||||
return types.values().stream()
|
return types.values().stream()
|
||||||
.filter(t -> t.taskType() >= 1 && t.taskType() < CUSTOM_TYPE_START)
|
.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}. */
|
/** Nächste freie Typnummer ab {@value #CUSTOM_TYPE_START}. */
|
||||||
public synchronized int nextCustomTaskType() {
|
public synchronized int nextCustomTaskType() {
|
||||||
int next = CUSTOM_TYPE_START;
|
return nextCustomTaskType(List.of());
|
||||||
for (BlockType type : types.values()) {
|
}
|
||||||
if (type.taskType() >= next) {
|
|
||||||
next = type.taskType() + 1;
|
/** 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(Integer::intValue).max().orElse(CUSTOM_TYPE_START - 1);
|
||||||
|
int next = highest < Integer.MAX_VALUE - 1 ? Math.max(CUSTOM_TYPE_START, highest + 1) : CUSTOM_TYPE_START;
|
||||||
|
while (occupied.contains(next)) next++;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +344,7 @@ public class BlockTypeRegistry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registriert einen bereits vollständig aufgebauten benutzerdefinierten Typ
|
* 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.
|
* werden überschrieben.
|
||||||
*/
|
*/
|
||||||
public synchronized BlockType registerCustom(BlockType custom) {
|
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;
|
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
|
* benutzerdefinierten Typs (inkl. der erfassten Vorgabe). Beim Laden wird
|
||||||
* daraus wieder eine {@link PropertyDefinition} aufgebaut.
|
* 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.
|
* erscheinen.
|
||||||
*/
|
*/
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@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"})
|
"max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "choices", "fields", "script"})
|
||||||
public class TaskDto {
|
public class TaskDto {
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ public class TaskDto {
|
|||||||
|
|
||||||
private int type;
|
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 String topic;
|
||||||
|
|
||||||
private Boolean opt;
|
private Boolean opt;
|
||||||
@@ -47,19 +56,21 @@ public class TaskDto {
|
|||||||
|
|
||||||
private List<TaskEntryDto> entries;
|
private List<TaskEntryDto> entries;
|
||||||
|
|
||||||
/** Auswahloptionen eines Status-Tasks (Typ 6). */
|
/** Auswahloptionen eines Status-Tasks (Typ 11). */
|
||||||
private List<ChoiceDto> choices;
|
private List<ChoiceDto> choices;
|
||||||
|
|
||||||
/** Eingabefelder eines Listen-Text-Tasks (Typ 12). */
|
/** Eingabefelder einer Text-/Zahlen-Liste (Typ 13/14). */
|
||||||
private List<FieldDto> fields;
|
private List<FieldDto> fields;
|
||||||
|
|
||||||
/** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */
|
/** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */
|
||||||
private JsonNode script;
|
private JsonNode script;
|
||||||
|
|
||||||
|
@JsonProperty("s_id")
|
||||||
public int getSId() {
|
public int getSId() {
|
||||||
return sId;
|
return sId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonProperty("s_id")
|
||||||
public void setSId(int sId) {
|
public void setSId(int sId) {
|
||||||
this.sId = sId;
|
this.sId = sId;
|
||||||
}
|
}
|
||||||
@@ -72,6 +83,14 @@ public class TaskDto {
|
|||||||
this.type = type;
|
this.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Integer getBaseType() {
|
||||||
|
return baseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBaseType(Integer baseType) {
|
||||||
|
this.baseType = baseType;
|
||||||
|
}
|
||||||
|
|
||||||
public String getTopic() {
|
public String getTopic() {
|
||||||
return topic;
|
return topic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.assecutor.tasklisteditor.model;
|
package de.assecutor.tasklisteditor.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +15,9 @@ public class TaskListPayload {
|
|||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
private List<TaskDto> tasks;
|
private List<TaskDto> tasks;
|
||||||
|
private JsonNode metadata;
|
||||||
|
private String returnOrigin;
|
||||||
|
private List<CustomTypeDefinition> customTypes = List.of();
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
@@ -29,4 +34,28 @@ public class TaskListPayload {
|
|||||||
public void setTasks(List<TaskDto> tasks) {
|
public void setTasks(List<TaskDto> tasks) {
|
||||||
this.tasks = 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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
||||||
|
import de.assecutor.tasklisteditor.service.CustomTypeValidator;
|
||||||
import de.assecutor.tasklisteditor.service.PendingImportStore;
|
import de.assecutor.tasklisteditor.service.PendingImportStore;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt die per Deep-Link zu übergebenden Task-Daten als JSON-Body entgegen
|
* 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 PendingImportStore importStore;
|
||||||
private final ObjectMapper objectMapper;
|
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.importStore = importStore;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.registry = registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ImportResponse(String token, String deepLink) {
|
public record ImportResponse(String token, String deepLink) {
|
||||||
@@ -50,8 +58,10 @@ public class EditorImportController {
|
|||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
public ResponseEntity<ImportResponse> importTasks(@RequestBody JsonNode body) {
|
public ResponseEntity<ImportResponse> importTasks(@RequestBody JsonNode body) {
|
||||||
TaskListPayload payload = toPayload(body);
|
TaskListPayload payload;
|
||||||
if (payload.getTasks() == null || payload.getTasks().isEmpty()) {
|
try {
|
||||||
|
payload = toPayload(body);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +76,72 @@ public class EditorImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TaskListPayload toPayload(JsonNode body) {
|
private TaskListPayload toPayload(JsonNode body) {
|
||||||
if (body == null || body.isNull()) {
|
if (body == null || (!body.isArray() && !body.isObject())) {
|
||||||
return new TaskListPayload();
|
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()) {
|
if (body.isArray()) {
|
||||||
TaskListPayload payload = new TaskListPayload();
|
payload = new TaskListPayload();
|
||||||
payload.setTasks(objectMapper.convertValue(body, new TypeReference<List<TaskDto>>() {
|
payload.setTasks(objectMapper.convertValue(body, new TypeReference<List<TaskDto>>() {
|
||||||
}));
|
}));
|
||||||
return payload;
|
} else {
|
||||||
|
CustomTypeValidator.validate(body.get("customTypes"), registry);
|
||||||
|
JsonNode returnOrigin = body.get("returnOrigin");
|
||||||
|
if (returnOrigin != null && !returnOrigin.isNull() && !returnOrigin.isTextual()) {
|
||||||
|
throw new IllegalArgumentException("returnOrigin must be a string");
|
||||||
|
}
|
||||||
|
payload = objectMapper.convertValue(body, TaskListPayload.class);
|
||||||
|
payload.setReturnOrigin(normalizeReturnOrigin(payload.getReturnOrigin()));
|
||||||
|
}
|
||||||
|
if (payload.getTasks() == null) {
|
||||||
|
payload.setTasks(List.of());
|
||||||
|
}
|
||||||
|
for (TaskDto task : payload.getTasks()) {
|
||||||
|
if (task.getType() >= BlockTypeRegistry.CUSTOM_TYPE_START
|
||||||
|
&& payload.getCustomTypes().stream().noneMatch(type -> type.taskType() == task.getType())) {
|
||||||
|
throw new IllegalArgumentException("Missing custom type definition for task " + task.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine feste Origin verhindert eine Rückgabe an beliebige Fenster per Wildcard. */
|
||||||
|
private String normalizeReturnOrigin(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = new URI(value);
|
||||||
|
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||||
|
String path = uri.getRawPath();
|
||||||
|
if ((!scheme.equals("http") && !scheme.equals("https"))
|
||||||
|
|| uri.getHost() == null || uri.getRawUserInfo() != null
|
||||||
|
|| (path != null && !path.isEmpty() && !path.equals("/"))
|
||||||
|
|| uri.getRawQuery() != null || uri.getRawFragment() != null
|
||||||
|
|| uri.getPort() > 65535) {
|
||||||
|
throw new IllegalArgumentException("returnOrigin must be an absolute HTTP(S) origin");
|
||||||
|
}
|
||||||
|
int port = uri.getPort();
|
||||||
|
if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
|
||||||
|
port = -1;
|
||||||
|
}
|
||||||
|
return new URI(scheme, null, uri.getHost().toLowerCase(Locale.ROOT), port,
|
||||||
|
null, null, null).toASCIIString();
|
||||||
|
} catch (URISyntaxException exception) {
|
||||||
|
throw new IllegalArgumentException("Invalid returnOrigin", exception);
|
||||||
}
|
}
|
||||||
// Objekt mit { name?, tasks: [...] }
|
|
||||||
return objectMapper.convertValue(body, TaskListPayload.class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,120 @@
|
|||||||
|
package de.assecutor.tasklisteditor.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import de.assecutor.tasklisteditor.model.BlockType;
|
||||||
|
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||||
|
import de.assecutor.tasklisteditor.model.PropertyDefinition;
|
||||||
|
import de.assecutor.tasklisteditor.model.PropertyType;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Prüft eigene Typen vor der Jackson-Konvertierung, ohne deren Werte still zu konvertieren. */
|
||||||
|
public final class CustomTypeValidator {
|
||||||
|
|
||||||
|
private CustomTypeValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validate(JsonNode definitions, BlockTypeRegistry registry) {
|
||||||
|
if (definitions == null || definitions.isNull()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
require(definitions.isArray(), "customTypes must be an array");
|
||||||
|
Set<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) {
|
||||||
|
require(properties != null && properties.isArray(), "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) {
|
||||||
|
require(value != null, "defaultValue is required");
|
||||||
|
// Leere Vorgaben sind erlaubt; die Eingabefelder behandeln sie wie einen leeren Wert.
|
||||||
|
if (value.isNull()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case TEXT, TEXTAREA -> require(value.isTextual(), "Text default must be a string");
|
||||||
|
case NUMBER -> require(value.isIntegralNumber() && value.canConvertToInt(),
|
||||||
|
"Number default must be an integer");
|
||||||
|
case BOOLEAN -> require(value.isBoolean(), "Boolean default must be a boolean");
|
||||||
|
case FIELD_LIST, NAME_LIST, CHOICE_LIST -> {
|
||||||
|
// Früher gespeicherte Statusoptionen können noch als mehrzeiliger Text vorliegen.
|
||||||
|
if (type == PropertyType.CHOICE_LIST && value.isTextual()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
require(value.isArray(), "List default must be an array");
|
||||||
|
for (JsonNode row : value) {
|
||||||
|
require(row.isObject(), "Every list row must be an object");
|
||||||
|
text(row, "title", true);
|
||||||
|
optionalField(row, "placeholder", true);
|
||||||
|
optionalField(row, "required", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void optionalField(JsonNode object, String name, boolean string) {
|
||||||
|
JsonNode value = object.get(name);
|
||||||
|
if (value != null && !value.isNull()) {
|
||||||
|
require(string ? value.isTextual() : value.isBoolean(), "Invalid list row field " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode object, String field, boolean allowBlank) {
|
||||||
|
JsonNode value = object.get(field);
|
||||||
|
require(value != null && value.isTextual() && (allowBlank || !value.textValue().isBlank()),
|
||||||
|
field + " must be a string" + (allowBlank ? "" : " and not blank"));
|
||||||
|
return value.textValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int integer(JsonNode object, String field) {
|
||||||
|
JsonNode value = object.get(field);
|
||||||
|
require(value != null && value.isIntegralNumber() && value.canConvertToInt(),
|
||||||
|
field + " must be an integer");
|
||||||
|
return value.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new IllegalArgumentException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
* 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
|
* 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
|
* 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
|
* <p>Bewusst In-Memory gehalten (transienter Hand-off). Für einen
|
||||||
* mehrinstanzigen Betrieb müsste hier ein geteilter Speicher (z. B.
|
* mehrinstanzigen Betrieb müsste hier ein geteilter temporärer Speicher
|
||||||
* MongoDB mit TTL oder Redis) verwendet werden.
|
* verwendet werden.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PendingImportStore {
|
public class PendingImportStore {
|
||||||
@@ -41,11 +42,21 @@ public class PendingImportStore {
|
|||||||
return token;
|
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. */
|
/** Liest die Nutzlast einmalig aus und entfernt sie. */
|
||||||
public Optional<TaskListPayload> consume(String token) {
|
public Optional<TaskListPayload> consume(String token) {
|
||||||
purgeExpired();
|
purgeExpired();
|
||||||
Entry entry = entries.remove(token);
|
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.empty();
|
||||||
}
|
}
|
||||||
return Optional.of(entry.payload());
|
return Optional.of(entry.payload());
|
||||||
@@ -53,6 +64,6 @@ public class PendingImportStore {
|
|||||||
|
|
||||||
private void purgeExpired() {
|
private void purgeExpired() {
|
||||||
Instant now = Instant.now();
|
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 {
|
public class TaskListExporter {
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final BlockTypeRegistry registry;
|
||||||
|
|
||||||
public TaskListExporter(ObjectMapper objectMapper) {
|
public TaskListExporter(ObjectMapper objectMapper, BlockTypeRegistry registry) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.registry = registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TaskDto> export(String drawflowJson, Map<Integer, BlockInstance> instances) {
|
public List<TaskDto> export(String drawflowJson, Map<Integer, BlockInstance> instances) {
|
||||||
@@ -225,6 +227,13 @@ public class TaskListExporter {
|
|||||||
private TaskDto toTask(BlockInstance instance) {
|
private TaskDto toTask(BlockInstance instance) {
|
||||||
TaskDto task = new TaskDto();
|
TaskDto task = new TaskDto();
|
||||||
task.setType(instance.type().taskType());
|
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();
|
String topic = instance.topic();
|
||||||
task.setTopic(topic == null || topic.isBlank() ? instance.type().label() : 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) {
|
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<>();
|
List<ResolvedTask> resolved = new ArrayList<>();
|
||||||
if (tasks == null) {
|
if (tasks == null) {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
for (TaskDto task : tasks) {
|
for (TaskDto task : tasks) {
|
||||||
BlockType type = findByTaskType(task.getType());
|
BlockType type = findByTaskType(task.getType(), editorRegistry);
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -54,8 +59,8 @@ public class TaskListImporter {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BlockType findByTaskType(int taskType) {
|
private BlockType findByTaskType(int taskType, BlockTypeRegistry editorRegistry) {
|
||||||
return registry.all().stream()
|
return editorRegistry.all().stream()
|
||||||
.filter(t -> t.taskType() == taskType)
|
.filter(t -> t.taskType() == taskType)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
@@ -64,13 +69,16 @@ public class TaskListImporter {
|
|||||||
private Map<String, Object> valuesFor(BlockType type, TaskDto task) {
|
private Map<String, Object> valuesFor(BlockType type, TaskDto task) {
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
Map<String, Object> values = new LinkedHashMap<>();
|
||||||
for (PropertyDefinition def : type.properties()) {
|
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
|
// Property-Typs in die vom Zeilen-Editor erwartete Struktur
|
||||||
// (Liste aus {title}/{title,placeholder}-Maps) zurückgewandelt.
|
// (Liste aus {title}/{title,placeholder}-Maps) zurückgewandelt.
|
||||||
Object value = switch (def.type()) {
|
Object value = switch (def.type()) {
|
||||||
case FIELD_LIST -> fieldRows(task);
|
case FIELD_LIST -> task.getFields() == null
|
||||||
case NAME_LIST -> entryRows(task);
|
? copyValue(def.defaultValue()) : fieldRows(task);
|
||||||
case CHOICE_LIST -> choiceRows(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);
|
default -> scalarValue(def, task);
|
||||||
};
|
};
|
||||||
values.put(def.id(), value);
|
values.put(def.id(), value);
|
||||||
@@ -78,6 +86,21 @@ public class TaskListImporter {
|
|||||||
return values;
|
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) {
|
private Object scalarValue(PropertyDefinition def, TaskDto task) {
|
||||||
return switch (def.id()) {
|
return switch (def.id()) {
|
||||||
case "opt" -> orDefault(task.getOpt(), def.defaultValue());
|
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) {
|
private List<Map<String, Object>> fieldRows(TaskDto task) {
|
||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
if (task.getFields() == null) {
|
if (task.getFields() == null) {
|
||||||
@@ -111,7 +134,7 @@ public class TaskListImporter {
|
|||||||
return rows;
|
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) {
|
private List<Map<String, Object>> entryRows(TaskDto task) {
|
||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
if (task.getEntries() == null) {
|
if (task.getEntries() == null) {
|
||||||
@@ -129,7 +152,7 @@ public class TaskListImporter {
|
|||||||
/** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */
|
/** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */
|
||||||
private List<Map<String, Object>> choiceRows(TaskDto task) {
|
private List<Map<String, Object>> choiceRows(TaskDto task) {
|
||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
if (task.getChoices() != null && !task.getChoices().isEmpty()) {
|
if (task.getChoices() != null) {
|
||||||
for (ChoiceDto choice : task.getChoices()) {
|
for (ChoiceDto choice : task.getChoices()) {
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("title", choice.getTxt() == null ? "" : choice.getTxt());
|
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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import de.assecutor.tasklisteditor.document.TaskListDocument;
|
|
||||||
import de.assecutor.tasklisteditor.model.BlockInstance;
|
import de.assecutor.tasklisteditor.model.BlockInstance;
|
||||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||||
import de.assecutor.tasklisteditor.repository.TaskListRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON und speichert
|
* Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON für den Aufrufer.
|
||||||
* die Liste in MongoDB.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TaskListService {
|
public class TaskListService {
|
||||||
|
|
||||||
private final TaskListExporter exporter;
|
private final TaskListExporter exporter;
|
||||||
private final TaskListRepository repository;
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public TaskListService(TaskListExporter exporter,
|
public TaskListService(TaskListExporter exporter,
|
||||||
TaskListRepository repository,
|
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.exporter = exporter;
|
this.exporter = exporter;
|
||||||
this.repository = repository;
|
|
||||||
this.objectMapper = objectMapper;
|
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
|
* die Antwort nicht verwertbar ist
|
||||||
*/
|
*/
|
||||||
public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson) {
|
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()) {
|
if (!isEnabled()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"KI-Assistent deaktiviert: AI_ENABLED=true und ANTHROPIC_API_KEY "
|
"KI-Assistent deaktiviert: AI_ENABLED=true und ANTHROPIC_API_KEY "
|
||||||
@@ -98,7 +104,7 @@ public class WorkflowAssistantService {
|
|||||||
.model(model)
|
.model(model)
|
||||||
.maxTokens(16000L)
|
.maxTokens(16000L)
|
||||||
.thinking(ThinkingConfigAdaptive.builder().build())
|
.thinking(ThinkingConfigAdaptive.builder().build())
|
||||||
.system(buildSystemPrompt())
|
.system(buildSystemPrompt(availableTypes))
|
||||||
.addUserMessage(buildUserMessage(wish, currentName, currentTasksJson))
|
.addUserMessage(buildUserMessage(wish, currentName, currentTasksJson))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -193,7 +199,7 @@ public class WorkflowAssistantService {
|
|||||||
* {@link BlockTypeRegistry}, inkl. benutzerdefinierter Typen) und das
|
* {@link BlockTypeRegistry}, inkl. benutzerdefinierter Typen) und das
|
||||||
* erwartete JSON-Format.
|
* erwartete JSON-Format.
|
||||||
*/
|
*/
|
||||||
private String buildSystemPrompt() {
|
private String buildSystemPrompt(List<BlockType> availableTypes) {
|
||||||
StringBuilder prompt = new StringBuilder();
|
StringBuilder prompt = new StringBuilder();
|
||||||
prompt.append("""
|
prompt.append("""
|
||||||
Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows
|
Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows
|
||||||
@@ -230,9 +236,9 @@ public class WorkflowAssistantService {
|
|||||||
(boolean) erlaubt erneutes Bearbeiten.
|
(boolean) erlaubt erneutes Bearbeiten.
|
||||||
- Listenfelder:
|
- Listenfelder:
|
||||||
* "fields": Array aus {"title": "...", "placeholder": "...", "required": true|false}
|
* "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}
|
* "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>}
|
* "choices": Array aus {"txt": "...", "ret": <fortlaufend ab 1>}
|
||||||
(Statusauswahl, Typ 11).
|
(Statusauswahl, Typ 11).
|
||||||
Kennzeichne Einträge nur dann mit "required": true, wenn der Benutzer sie
|
Kennzeichne Einträge nur dann mit "required": true, wenn der Benutzer sie
|
||||||
@@ -243,7 +249,7 @@ public class WorkflowAssistantService {
|
|||||||
Verfügbare Element-Typen:
|
Verfügbare Element-Typen:
|
||||||
""");
|
""");
|
||||||
|
|
||||||
for (BlockType type : registry.all()) {
|
for (BlockType type : availableTypes) {
|
||||||
if ("script".equals(type.id())) {
|
if ("script".equals(type.id())) {
|
||||||
continue; // Script-Blöcke kann der Assistent nicht sinnvoll konfigurieren.
|
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.checkbox.Checkbox;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
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.Div;
|
||||||
import com.vaadin.flow.component.html.H2;
|
import com.vaadin.flow.component.html.H2;
|
||||||
import com.vaadin.flow.component.html.H3;
|
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.BlockInstance;
|
||||||
import de.assecutor.tasklisteditor.model.BlockType;
|
import de.assecutor.tasklisteditor.model.BlockType;
|
||||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
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.PropertyDefinition;
|
||||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
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.PendingImportStore;
|
||||||
import de.assecutor.tasklisteditor.service.TaskListImporter;
|
import de.assecutor.tasklisteditor.service.TaskListImporter;
|
||||||
import de.assecutor.tasklisteditor.service.TaskListService;
|
import de.assecutor.tasklisteditor.service.TaskListService;
|
||||||
@@ -50,10 +45,9 @@ import elemental.json.JsonArray;
|
|||||||
import elemental.json.JsonObject;
|
import elemental.json.JsonObject;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
@@ -78,9 +72,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
private final TaskListService taskListService;
|
private final TaskListService taskListService;
|
||||||
private final TaskListImporter taskListImporter;
|
private final TaskListImporter taskListImporter;
|
||||||
private final PendingImportStore importStore;
|
private final PendingImportStore importStore;
|
||||||
private final CanvasService canvasService;
|
|
||||||
private final CustomTypeService customTypeService;
|
|
||||||
private final ElementSettingsService elementSettingsService;
|
|
||||||
private final WorkflowAssistantService workflowAssistantService;
|
private final WorkflowAssistantService workflowAssistantService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final NodeEditor nodeEditor;
|
private final NodeEditor nodeEditor;
|
||||||
@@ -97,28 +88,25 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
return size() > 100;
|
return size() > 100;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
private Div palettePanel;
|
private Div customPalettePanel;
|
||||||
private String currentListName;
|
private String currentListName;
|
||||||
|
private String importToken;
|
||||||
|
private String returnOrigin;
|
||||||
|
private JsonNode importMetadata;
|
||||||
private final String appVersion;
|
private final String appVersion;
|
||||||
|
|
||||||
public MainView(BlockTypeRegistry registry,
|
public MainView(BlockTypeRegistry registry,
|
||||||
TaskListService taskListService,
|
TaskListService taskListService,
|
||||||
TaskListImporter taskListImporter,
|
TaskListImporter taskListImporter,
|
||||||
PendingImportStore importStore,
|
PendingImportStore importStore,
|
||||||
CanvasService canvasService,
|
|
||||||
CustomTypeService customTypeService,
|
|
||||||
ElementSettingsService elementSettingsService,
|
|
||||||
WorkflowAssistantService workflowAssistantService,
|
WorkflowAssistantService workflowAssistantService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${tasklist.app-version:}") String appVersion) {
|
@Value("${tasklist.app-version:}") String appVersion) {
|
||||||
this.appVersion = appVersion;
|
this.appVersion = appVersion;
|
||||||
this.registry = registry;
|
this.registry = registry.forEditor();
|
||||||
this.taskListService = taskListService;
|
this.taskListService = taskListService;
|
||||||
this.taskListImporter = taskListImporter;
|
this.taskListImporter = taskListImporter;
|
||||||
this.importStore = importStore;
|
this.importStore = importStore;
|
||||||
this.canvasService = canvasService;
|
|
||||||
this.customTypeService = customTypeService;
|
|
||||||
this.elementSettingsService = elementSettingsService;
|
|
||||||
this.workflowAssistantService = workflowAssistantService;
|
this.workflowAssistantService = workflowAssistantService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
@@ -126,8 +114,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
setSpacing(false);
|
setSpacing(false);
|
||||||
addClassName("tasklist-app");
|
addClassName("tasklist-app");
|
||||||
|
|
||||||
loadCustomTypes();
|
|
||||||
|
|
||||||
add(buildHeader());
|
add(buildHeader());
|
||||||
|
|
||||||
HorizontalLayout body = new HorizontalLayout();
|
HorizontalLayout body = new HorizontalLayout();
|
||||||
@@ -143,10 +129,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
body.expand(nodeEditor);
|
body.expand(nodeEditor);
|
||||||
add(body);
|
add(body);
|
||||||
|
|
||||||
nodeEditor.registerBlockTypes(registry.all());
|
nodeEditor.registerBlockTypes(this.registry.all());
|
||||||
|
|
||||||
nodeEditor.addNodeAddedListener(e -> {
|
nodeEditor.addNodeAddedListener(e -> {
|
||||||
BlockType type = registry.find(e.typeId()).orElse(null);
|
BlockType type = this.registry.find(e.typeId()).orElse(null);
|
||||||
if (type == null) return;
|
if (type == null) return;
|
||||||
instances.put(e.nodeId(), new BlockInstance(e.nodeId(), type));
|
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);
|
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);
|
instances.put(id, restored);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -225,13 +218,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
title.add(version);
|
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));
|
Button undo = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||||
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
undo.setTooltipText("Letzte Canvas-Änderung zurücknehmen (Strg+Z, max. 10 Schritte)");
|
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));
|
Button cancel = new Button("Abbrechen", new Icon(VaadinIcon.CLOSE));
|
||||||
clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
clear.addClickListener(e -> {
|
cancel.setTooltipText("Bearbeitung ohne Übernahme abbrechen");
|
||||||
nodeEditor.clear();
|
cancel.addClickListener(e -> returnToCaller("cancelled", null));
|
||||||
// In den Papierkorb statt verwerfen: Undo kann das Leeren zurücknehmen.
|
|
||||||
removedInstances.putAll(instances);
|
|
||||||
instances.clear();
|
|
||||||
Notification.show("Canvas geleert");
|
|
||||||
});
|
|
||||||
|
|
||||||
Button saveCanvas = new Button("Workflow speichern", new Icon(VaadinIcon.HARDDRIVE));
|
Button finish = new Button("Fertig", new Icon(VaadinIcon.CHECK));
|
||||||
saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
finish.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
saveCanvas.addClickListener(e -> saveCanvas());
|
finish.setTooltipText("Bearbeitung abschließen");
|
||||||
|
finish.addClickListener(e -> exportTasks());
|
||||||
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());
|
|
||||||
|
|
||||||
Span spacer = new Span();
|
Span spacer = new Span();
|
||||||
spacer.getStyle().set("flex", "1");
|
spacer.getStyle().set("flex", "1");
|
||||||
|
|
||||||
header.add(title, spacer, assistant, undo, clear, saveCanvas, loadCanvas, export);
|
header.add(title, spacer, undo, cancel, finish);
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Div buildPalette() {
|
private Div buildPalette() {
|
||||||
Div panel = new Div();
|
Div panel = new Div();
|
||||||
panel.addClassName("palette-panel");
|
panel.addClassName("palette-panel");
|
||||||
palettePanel = panel;
|
|
||||||
|
|
||||||
H3 heading = new H3("Funktionsblöcke");
|
H3 heading = new H3("Funktionsblöcke");
|
||||||
heading.addClassName("panel-heading");
|
heading.addClassName("panel-heading");
|
||||||
@@ -283,6 +256,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
hint.addClassName("panel-hint");
|
hint.addClassName("panel-hint");
|
||||||
panel.add(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
|
// Standardelemente
|
||||||
for (BlockType type : registry.all()) {
|
for (BlockType type : registry.all()) {
|
||||||
if (!isCustomType(type)) {
|
if (!isCustomType(type)) {
|
||||||
@@ -301,6 +282,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
customType.addClickListener(e -> openCustomTypeDialog());
|
customType.addClickListener(e -> openCustomTypeDialog());
|
||||||
panel.add(customType);
|
panel.add(customType);
|
||||||
|
|
||||||
|
customPalettePanel = new Div();
|
||||||
|
customPalettePanel.getStyle().set("display", "contents");
|
||||||
|
panel.add(customPalettePanel);
|
||||||
|
|
||||||
// Selbst definierte Elemente
|
// Selbst definierte Elemente
|
||||||
for (BlockType type : registry.all()) {
|
for (BlockType type : registry.all()) {
|
||||||
if (isCustomType(type)) {
|
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). */
|
/** Fügt ein benutzerdefiniertes Element ans Ende der Palette an (nach dem Button). */
|
||||||
private void addCustomPaletteItem(BlockType type) {
|
private void addCustomPaletteItem(BlockType type) {
|
||||||
palettePanel.add(buildPaletteItem(type));
|
customPalettePanel.add(buildPaletteItem(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Component buildPaletteDivider() {
|
private Component buildPaletteDivider() {
|
||||||
@@ -328,24 +313,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
return divider;
|
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
|
* 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
|
* 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.setHeaderTitle("Eigenen Typ anlegen");
|
||||||
dialog.setWidth("440px");
|
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");
|
IntegerField numberField = new IntegerField("Typnummer");
|
||||||
numberField.setWidthFull();
|
numberField.setWidthFull();
|
||||||
@@ -418,12 +386,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
value == null ? def.defaultValue() : value));
|
value == null ? def.defaultValue() : value));
|
||||||
}
|
}
|
||||||
BlockType custom = registry.registerCustom(number, base, label.trim(), props);
|
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);
|
addCustomPaletteItem(custom);
|
||||||
nodeEditor.registerBlockTypes(registry.all());
|
nodeEditor.registerBlockTypes(registry.all());
|
||||||
Notification.show("Typ " + number + " „" + custom.label() + "“ angelegt.");
|
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) {
|
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())) {
|
if (!registry.removeCustom(type.id())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
customPalettePanel.remove(tile);
|
||||||
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);
|
|
||||||
nodeEditor.registerBlockTypes(registry.all());
|
nodeEditor.registerBlockTypes(registry.all());
|
||||||
Notification.show("Typ " + type.taskType() + " „" + type.label() + "“ gelöscht.");
|
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
|
* aus einem Titel und – falls {@code withPlaceholder} – zusätzlich einem
|
||||||
* Platzhalter sowie – falls {@code withRequired} – einer Pflichtfeld-
|
* Platzhalter sowie – falls {@code withRequired} – einer Pflichtfeld-
|
||||||
* Checkbox. Der Wert wird als {@code List<Map<String,Object>>} unter der
|
* 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,
|
private Component buildRowList(BlockInstance instance, PropertyDefinition def, Object current,
|
||||||
boolean withPlaceholder, boolean withRequired) {
|
boolean withPlaceholder, boolean withRequired) {
|
||||||
@@ -960,17 +920,56 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void beforeEnter(BeforeEnterEvent event) {
|
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()
|
List<String> tokens = event.getLocation().getQueryParameters()
|
||||||
.getParameters().getOrDefault(IMPORT_PARAM, List.of());
|
.getParameters().getOrDefault(IMPORT_PARAM, List.of());
|
||||||
if (tokens.isEmpty()) {
|
if (tokens.isEmpty()) {
|
||||||
return;
|
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. */
|
/** Baut den Canvas aus den per Deep-Link übergebenen Daten auf. */
|
||||||
private void loadPayload(TaskListPayload payload) {
|
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.
|
* Deep-Link-Import und vom KI-Assistenten genutzt.
|
||||||
*/
|
*/
|
||||||
private void buildCanvasFromTasks(List<TaskDto> tasks, String name, String successHint) {
|
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()) {
|
if (resolved.isEmpty()) {
|
||||||
Notification.show("Keine verwertbaren Elemente gefunden.");
|
Notification.show("Keine verwertbaren Elemente gefunden.");
|
||||||
return;
|
return;
|
||||||
@@ -1100,6 +1099,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
|||||||
progressLabel.setVisible(true);
|
progressLabel.setVisible(true);
|
||||||
|
|
||||||
UI ui = UI.getCurrent();
|
UI ui = UI.getCurrent();
|
||||||
|
List<BlockType> availableTypes = registry.all();
|
||||||
ui.setPollInterval(500);
|
ui.setPollInterval(500);
|
||||||
if (modifying) {
|
if (modifying) {
|
||||||
// Aktuellen Canvas als Task-JSON exportieren und als Kontext mitgeben.
|
// 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,
|
runAssistant(dialog, generate, wishes, progress, progressLabel, ui,
|
||||||
"per KI-Assistent geändert",
|
"per KI-Assistent geändert",
|
||||||
() -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson));
|
() -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson, availableTypes));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
runAssistant(dialog, generate, wishes, progress, progressLabel, ui,
|
runAssistant(dialog, generate, wishes, progress, progressLabel, ui,
|
||||||
"per KI-Assistent erstellt",
|
"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() {
|
private void exportTasks() {
|
||||||
if (instances.isEmpty()) {
|
if (instances.isEmpty()) {
|
||||||
Notification.show("Es sind keine Funktionsblöcke auf dem Canvas.");
|
finishExport(new TaskListService.ExportResult(List.of(), "[]"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nodeEditor.exportGraph(graphJson -> {
|
nodeEditor.exportGraph(graphJson -> {
|
||||||
try {
|
try {
|
||||||
TaskListService.ExportResult result = taskListService.build(graphJson, instances);
|
TaskListService.ExportResult result = taskListService.build(graphJson, instances);
|
||||||
if (result.tasks().isEmpty()) {
|
finishExport(result);
|
||||||
Notification.show("Keine Tasks gefunden.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openExportDialog(result, graphJson);
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show("Export fehlgeschlagen: " + ex.getMessage());
|
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 dialog = new Dialog();
|
||||||
dialog.setHeaderTitle("Task-JSON (" + result.tasks().size() + " Tasks)");
|
dialog.setHeaderTitle("JSON (" + result.tasks().size() + " Tasks)");
|
||||||
dialog.setWidth("680px");
|
dialog.setWidth("680px");
|
||||||
|
|
||||||
TextField name = new TextField("Name der Task-Liste");
|
ObjectNode payload = objectMapper.createObjectNode();
|
||||||
name.setWidthFull();
|
payload.put("name", currentListName);
|
||||||
name.setValue(currentListName != null && !currentListName.isBlank()
|
payload.set("metadata", importMetadata);
|
||||||
? currentListName
|
payload.set("tasks", objectMapper.valueToTree(result.tasks()));
|
||||||
: "Task-Liste (" + result.tasks().size() + " 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.setWidthFull();
|
||||||
json.setHeight("360px");
|
json.setHeight("360px");
|
||||||
json.setReadOnly(true);
|
json.setReadOnly(true);
|
||||||
json.setValue(result.json());
|
json.setValue(payload.toPrettyString());
|
||||||
|
dialog.add(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);
|
|
||||||
|
|
||||||
Button close = new Button("Schließen", ev -> dialog.close());
|
Button close = new Button("Schließen", ev -> dialog.close());
|
||||||
close.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
close.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
dialog.getFooter().add(close);
|
||||||
dialog.getFooter().add(close, save);
|
|
||||||
dialog.open();
|
dialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
server.port=8080
|
server.port=8080
|
||||||
|
spring.application.name=workflow-editor
|
||||||
|
|
||||||
# Anwendungsversion: wird beim Build per Maven-Resource-Filtering
|
# Anwendungsversion: wird beim Build per Maven-Resource-Filtering
|
||||||
# (spring-boot-starter-parent, @...@-Delimiter) aus der pom.xml gefüllt.
|
# (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.launch-browser=true
|
||||||
vaadin.allowed-packages=de.assecutor.tasklisteditor
|
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.endpoints.web.exposure.include=health,info
|
||||||
management.endpoint.health.probes.enabled=true
|
management.endpoint.health.probes.enabled=true
|
||||||
@@ -14,18 +18,6 @@ management.health.readinessstate.enabled=true
|
|||||||
|
|
||||||
logging.level.org.atmosphere=WARN
|
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.
|
# Script-Block (grafisches Logik-Element) im Editor ausblenden.
|
||||||
# Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert.
|
# Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert.
|
||||||
tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false}
|
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.ai-enabled=${AI_ENABLED:false}
|
||||||
tasklist.anthropic-api-key=${ANTHROPIC_API_KEY:}
|
tasklist.anthropic-api-key=${ANTHROPIC_API_KEY:}
|
||||||
tasklist.anthropic-model=${ANTHROPIC_MODEL:claude-opus-4-8}
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user