Compare commits
3
Commits
| 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
|
||||
|
||||
# Verbindungs-URI (ohne Auth für lokale Entwicklung):
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
|
||||
# Mit Authentifizierung:
|
||||
# MONGODB_URI=mongodb://benutzer:passwort@host:27017/?authSource=admin
|
||||
|
||||
# Zieldatenbank:
|
||||
MONGODB_DATABASE=tasklist_editor
|
||||
|
||||
# Collection, in der das Task-JSON gespeichert wird:
|
||||
MONGODB_COLLECTION=tasks_json
|
||||
|
||||
# Collection, in der die Canvas-Stände (Layout + Werte) gespeichert werden:
|
||||
MONGODB_CANVAS_COLLECTION=canvas_layouts
|
||||
|
||||
# Collection, in der die benutzerdefinierten Typen gespeichert werden:
|
||||
MONGODB_CUSTOM_TYPE_COLLECTION=custom_types
|
||||
|
||||
# --- Collections je Element-Typ ---------------------------------------------
|
||||
# Beim Speichern eines Canvas werden die Einstellungen jedes platzierten
|
||||
# Elements zusätzlich je Typ in eine eigene Collection geschrieben.
|
||||
# (Benutzerdefinierte Typen nutzen die Collection ihres Basistyps.)
|
||||
MONGODB_ELEMENT_ARRIVAL_COLLECTION=element_arrival
|
||||
MONGODB_ELEMENT_SIGNATURE_COLLECTION=element_signature
|
||||
MONGODB_ELEMENT_PHOTOS_COLLECTION=element_photos
|
||||
MONGODB_ELEMENT_RECEIPT_GIVER_COLLECTION=element_receipt_giver
|
||||
MONGODB_ELEMENT_REMARK_COLLECTION=element_remark
|
||||
MONGODB_ELEMENT_COMMISSION_NUMBER_COLLECTION=element_commission_number
|
||||
MONGODB_ELEMENT_CHECKBOX_COLLECTION=element_checkbox
|
||||
MONGODB_ELEMENT_BARCODE_COLLECTION=element_barcode
|
||||
MONGODB_ELEMENT_STATUS_SELECTION_COLLECTION=element_status_selection
|
||||
MONGODB_ELEMENT_TEXT_LIST_COLLECTION=element_text_list
|
||||
MONGODB_ELEMENT_NUMBER_LIST_COLLECTION=element_number_list
|
||||
MONGODB_ELEMENT_CHECKBOX_LIST_COLLECTION=element_checkbox_list
|
||||
MONGODB_ELEMENT_SCRIPT_COLLECTION=element_script
|
||||
|
||||
# Script-Block (grafisches Logik-Element) ausblenden:
|
||||
# true = Script-Element wird im Editor nicht angezeigt
|
||||
# false = Script-Element ist verfügbar (Standard)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
ARG JAR_FILE=target/*.jar
|
||||
ARG JAR_FILE=target/workflow-editor-*.jar
|
||||
|
||||
# Zeitzone auf Berlin setzen und 24h-Format konfigurieren
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Workflow Editor (TaskList Editor)
|
||||
# Workflow Editor
|
||||
|
||||
n8n-inspirierter Editor zum Erstellen von Aufgabenlisten (Tasks) für die
|
||||
stadtbote-App. Funktionsblöcke werden per Drag & Drop auf einem Canvas
|
||||
@@ -8,27 +8,23 @@ angeordnet und verbunden; daraus wird das app-konforme `tasks`-JSON erzeugt.
|
||||
|
||||
- Funktionsblöcke für die stadtbote-Task-Typen (Ankunft, Unterschrift, Fotos,
|
||||
Quittungsgeber, Stationshinweis, Kommissionsnummer, Abhol-/Lieferstatus)
|
||||
- Erzeugen des app-konformen Task-JSON und Speichern in MongoDB
|
||||
- Speichern und Wiederherstellen des Canvas-Standes in MongoDB
|
||||
- Aufruf per Deep-Link mit Übergabe von Task-Daten als JSON-Body
|
||||
- Erzeugen des app-konformen Task-JSON
|
||||
- Eigene Typen mit konfigurierbaren Vorgaben
|
||||
- Aufruf per Deep-Link mit Übergabe von Tasks, eigenen Typen und Metadaten als JSON-Body
|
||||
- Rückgabe der bearbeiteten Tasks und des vollständigen eigenen Typkatalogs an den Aufrufer
|
||||
|
||||
## Technik
|
||||
|
||||
- Java 21, Spring Boot 3, Vaadin 24
|
||||
- MongoDB (Spring Data) zur Persistenz
|
||||
- Drawflow als Canvas-/Node-Editor
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Zugangsdaten und Collections werden über eine `.env`-Datei gesetzt
|
||||
(Vorlage: `.env.example`):
|
||||
Für den Editor ist keine Datenbank erforderlich. Tasks und eigene Typen werden
|
||||
beim Aufruf übergeben und bei **Fertig** an den Aufrufer zurückgegeben. Der
|
||||
Aufrufer ist für deren dauerhafte Speicherung verantwortlich.
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
MONGODB_DATABASE=tasklist_editor
|
||||
MONGODB_COLLECTION=tasks_json
|
||||
MONGODB_CANVAS_COLLECTION=canvas_layouts
|
||||
```
|
||||
Optionale Einstellungen sind in `.env.example` beschrieben.
|
||||
|
||||
## Start
|
||||
|
||||
@@ -37,3 +33,126 @@ mvn spring-boot:run
|
||||
```
|
||||
|
||||
Die Anwendung läuft anschließend unter http://localhost:8080/.
|
||||
|
||||
## Aufruf und Rückgabe mit dem Python-Testserver
|
||||
|
||||
Die Webapp muss mit dem aktuellen Code auf Port 8080 laufen. In einem zweiten
|
||||
Terminal den Testserver starten (Python 3, keine zusätzlichen Pakete):
|
||||
|
||||
```bash
|
||||
python3 tools/editor_test_server.py
|
||||
```
|
||||
|
||||
Der Testserver öffnet seine Aufruferseite unter `http://127.0.0.1:8090`.
|
||||
Dort das Beispiel bearbeiten oder eigenes JSON einfügen und den Editor öffnen.
|
||||
Der Editor erscheint eingebettet in der Seite. **Fertig** gibt die validierten
|
||||
Tasks und den vollständigen Katalog eigener Typen zurück; **Abbrechen** meldet
|
||||
den Abbruch ohne bearbeitete Tasks oder Typen.
|
||||
Beide Ergebnisse werden im Python-Terminal als JSON ausgegeben und auf der
|
||||
Testseite angezeigt. Nach **Fertig** enthält das JSON-Eingabefeld die Rückgabe
|
||||
für den nächsten Editor-Aufruf. **Neue Liste** leert die Tasks und behält den
|
||||
aktuellen eigenen Typkatalog. Nach **Abbrechen** bleibt die Eingabe unverändert.
|
||||
Die Testseite hält diese Daten nur bis zum Neuladen; die Konsolenausgabe bleibt
|
||||
im Terminal verfügbar.
|
||||
|
||||
Eine Datei vorgeben oder andere Ports verwenden:
|
||||
|
||||
```bash
|
||||
python3 tools/editor_test_server.py --json tools/example_tasks.json
|
||||
python3 tools/editor_test_server.py --editor-url http://127.0.0.1:8080 --port 8090 --no-browser
|
||||
```
|
||||
|
||||
Als Eingabe ist ein Task-Array oder ein Objekt erlaubt:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Testauftrag",
|
||||
"tasks": [{ "type": 3, "topic": "Ankunft melden", "track": true }],
|
||||
"customTypes": [],
|
||||
"metadata": { "auftragId": "TEST-4711", "revision": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
Für eine neue Liste `tasks` weglassen oder `[]` übergeben. Die Metadaten werden
|
||||
unverändert zurückgegeben. Zum Abschließen eines neu aufgebauten Workflows
|
||||
müssen Start, Aufgaben und Abschluss vollständig verbunden sein. Beim Import
|
||||
vorhandener Tasks ergänzt der Editor die internen Start-/Abschlussmarkierungen;
|
||||
sie sind nicht Teil des zurückgegebenen Task-Arrays.
|
||||
|
||||
### Eigene Typen
|
||||
|
||||
`customTypes` enthält den vollständigen Katalog für diesen Editor-Aufruf.
|
||||
Ohne dieses Feld oder mit `[]` stehen nur die eingebauten Typen zur Verfügung.
|
||||
Eigene Typen werden vor den Tasks geladen. Ein Task verweist mit `type` auf die
|
||||
`taskType`-Nummer seines eigenen Typs (ab 1000), beispielsweise:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Zustellnachweis",
|
||||
"tasks": [{ "type": 1000, "topic": "Zustellung fotografieren", "min": 1, "max": 3 }],
|
||||
"customTypes": [{
|
||||
"typeId": "custom-1000",
|
||||
"taskType": 1000,
|
||||
"baseTypeId": "fotos-aufnehmen",
|
||||
"label": "Zustellnachweis",
|
||||
"description": "Fotos zur Dokumentation einer Zustellung.",
|
||||
"icon": "vaadin:camera",
|
||||
"color": "#059669",
|
||||
"inputs": 1,
|
||||
"outputs": 1,
|
||||
"properties": [
|
||||
{ "id": "min", "label": "Mindestanzahl Fotos", "type": "NUMBER", "defaultValue": 1 },
|
||||
{ "id": "max", "label": "Maximale Anzahl Fotos", "type": "NUMBER", "defaultValue": 3 },
|
||||
{ "id": "opt", "label": "Optionale Aufgabe", "type": "BOOLEAN", "defaultValue": false },
|
||||
{ "id": "reenter", "label": "Erneut bearbeitbar", "type": "BOOLEAN", "defaultValue": true }
|
||||
]
|
||||
}],
|
||||
"metadata": { "auftragId": "TEST-4711" }
|
||||
}
|
||||
```
|
||||
|
||||
`properties` beschreibt die Eigenschaften samt Vorgabewerten; `type` ist dabei
|
||||
einer der Werte `TEXT`, `TEXTAREA`, `NUMBER`, `BOOLEAN`, `FIELD_LIST`, `NAME_LIST`
|
||||
oder `CHOICE_LIST`. Das mitgelieferte Beispiel `tools/example_tasks.json`
|
||||
enthält einen eigenen Foto-Typ und einen zugehörigen Task.
|
||||
|
||||
Bei **Fertig** wird `customTypes` immer vollständig zurückgegeben, einschließlich
|
||||
unveränderter und neu angelegter Typen. Der Aufrufer ersetzt seinen bisherigen
|
||||
Katalog durch dieses Array, damit auch gelöschte Typen berücksichtigt werden.
|
||||
`[]` bedeutet, dass keine eigenen Typen mehr vorhanden sind. Bei **Abbrechen**
|
||||
übernimmt der Aufrufer keine Änderungen.
|
||||
|
||||
### Übergabeprotokoll
|
||||
|
||||
Die Python-Seite ruft serverseitig `POST /api/editor/import` auf und ergänzt
|
||||
`returnOrigin`, z. B. `http://127.0.0.1:8090`. Die Antwort enthält `token` und
|
||||
`deepLink`. Der Aufrufer öffnet den Deep-Link in einem iframe oder Popup.
|
||||
Der Import bleibt für 30 Minuten im Speicher der Webapp verfügbar, sodass ein
|
||||
Neuladen während dieser Zeit möglich ist.
|
||||
|
||||
Der Editor sendet das Ergebnis per `postMessage` ausschließlich an diese Origin:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tasklist-editor:result",
|
||||
"token": "<Token aus der Importantwort>",
|
||||
"status": "completed",
|
||||
"name": "Testauftrag",
|
||||
"metadata": { "auftragId": "TEST-4711", "revision": 2 },
|
||||
"tasks": [{ "s_id": 1, "type": 3, "topic": "Ankunft melden", "track": true }],
|
||||
"customTypes": []
|
||||
}
|
||||
```
|
||||
|
||||
Bei Abbruch ist `status` gleich `cancelled`; die Felder `tasks` und `customTypes`
|
||||
entfallen.
|
||||
Die Testseite prüft Absender-Origin, Fenster und Token und leitet das Ergebnis
|
||||
an `POST /api/result` des Python-Servers weiter. Erst nach dessen Bestätigung
|
||||
wird der iframe geschlossen. Ohne `returnOrigin` zeigt der Editor einen
|
||||
schreibgeschützten JSON-Dialog zum Kopieren mit `name`, `metadata`, `tasks`
|
||||
und `customTypes`.
|
||||
|
||||
Der Testserver ist ein lokales Entwicklungswerkzeug und bindet an `127.0.0.1`.
|
||||
Er übernimmt bekannte Task-Felder des Editors; unbekannte Task-Typen werden vom
|
||||
bestehenden Importer übersprungen. Beliebige zusätzliche Daten daher in
|
||||
`metadata` übergeben.
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly REGISTRY_IMAGE="registry.assecutor.org/ait"
|
||||
readonly REGISTRY_IMAGE="registry.assecutor.org/wfe"
|
||||
readonly BACKEND_DIR="${SCRIPT_DIR}"
|
||||
|
||||
usage() {
|
||||
@@ -67,7 +67,7 @@ echo "Verwende Release-Version ${VERSION}."
|
||||
echo "Baue Production-JAR für Version ${VERSION} ..."
|
||||
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests package)
|
||||
|
||||
JAR_FILE_REL="target/tasklist-editor-${POM_VERSION}.jar"
|
||||
JAR_FILE_REL="target/workflow-editor-${POM_VERSION}.jar"
|
||||
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
|
||||
[[ -f "${JAR_FILE_ABS}" ]] || fail "Release-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
|
||||
|
||||
|
||||
@@ -1,189 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
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",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
</parent>
|
||||
|
||||
<groupId>de.assecutor</groupId>
|
||||
<artifactId>tasklist-editor</artifactId>
|
||||
<version>0.9.2</version>
|
||||
<name>TaskList Editor</name>
|
||||
<artifactId>workflow-editor</artifactId>
|
||||
<version>0.9.3</version>
|
||||
<name>Workflow Editor</name>
|
||||
<description>n8n-inspired editor for task list workflows</description>
|
||||
|
||||
<properties>
|
||||
@@ -45,11 +45,6 @@
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lädt Variablen aus einer .env-Datei in die Spring-Umgebung. -->
|
||||
<dependency>
|
||||
<groupId>me.paulschwarz</groupId>
|
||||
|
||||
@@ -276,6 +276,24 @@ function refreshValidity(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drawflow berechnet SVG-Pfade sofort anhand der Port-Geometrie. Beim Import
|
||||
// vor dem ersten Layout ist die Zeichenfläche noch 0 × 0 groß; diese Pfade
|
||||
// müssen nach dem Layout bzw. nach dem Einblenden neu berechnet werden.
|
||||
function scheduleConnectionRefresh(state) {
|
||||
if (state.connectionRefreshFrame != null) return;
|
||||
state.connectionRefreshFrame = requestAnimationFrame(() => {
|
||||
state.connectionRefreshFrame = null;
|
||||
const editor = state.editor;
|
||||
if (!state.container.isConnected
|
||||
|| editor.precanvas.clientWidth <= 0 || editor.precanvas.clientHeight <= 0) return;
|
||||
const data = editor.drawflow.drawflow[editor.module].data;
|
||||
for (const id of Object.keys(data)) {
|
||||
editor.updateConnectionNodes(`node-${id}`);
|
||||
}
|
||||
refreshValidity(state);
|
||||
});
|
||||
}
|
||||
|
||||
/** Liefert true, wenn mindestens einer der vorhandenen Ports keine Verbindung hat. */
|
||||
function hasOpenPort(ports) {
|
||||
if (!ports) return false;
|
||||
@@ -329,7 +347,8 @@ function attach(host) {
|
||||
registry: new Map(),
|
||||
history: [],
|
||||
undoSuppressed: false,
|
||||
undoScheduled: false
|
||||
undoScheduled: false,
|
||||
connectionRefreshFrame: null
|
||||
};
|
||||
editors.set(host, state);
|
||||
resetHistory(state);
|
||||
@@ -422,13 +441,19 @@ function attach(host) {
|
||||
|
||||
// Unvollständig verdrahtete Elemente (offener Ein- oder Ausgang) hellrot
|
||||
// markieren. Nach jeder strukturellen Änderung neu bewerten.
|
||||
const refresh = () => requestAnimationFrame(() => refreshValidity(state));
|
||||
const refresh = () => scheduleConnectionRefresh(state);
|
||||
editor.on('connectionCreated', refresh);
|
||||
editor.on('connectionRemoved', refresh);
|
||||
editor.on('nodeCreated', refresh);
|
||||
editor.on('nodeRemoved', refresh);
|
||||
editor.on('import', refresh);
|
||||
|
||||
// Erfasst insbesondere den Wechsel von einem noch nicht sichtbaren
|
||||
// iframe/Host zur tatsächlich verfügbaren Canvas-Größe.
|
||||
const resizeObserver = new ResizeObserver(refresh);
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(editor.precanvas);
|
||||
|
||||
// Undo-Historie: jede strukturelle Änderung erzeugt einen Schnappschuss.
|
||||
const record = () => recordHistory(state);
|
||||
editor.on('nodeCreated', record);
|
||||
@@ -566,6 +591,11 @@ function attach(host) {
|
||||
document.addEventListener('keydown', undoKeyHandler);
|
||||
|
||||
state.cleanup = () => {
|
||||
resizeObserver.disconnect();
|
||||
if (state.connectionRefreshFrame != null) {
|
||||
cancelAnimationFrame(state.connectionRefreshFrame);
|
||||
state.connectionRefreshFrame = null;
|
||||
}
|
||||
document.removeEventListener('keydown', deleteSelectedConnection);
|
||||
document.removeEventListener('keydown', undoKeyHandler);
|
||||
document.removeEventListener('mousemove', onRightMove);
|
||||
@@ -668,6 +698,7 @@ window.taskListEditor = {
|
||||
attach,
|
||||
registerBlockTypes(host, types) {
|
||||
const state = attach(host);
|
||||
state.registry.clear();
|
||||
for (const t of types) {
|
||||
state.registry.set(t.id, t);
|
||||
}
|
||||
@@ -694,7 +725,7 @@ window.taskListEditor = {
|
||||
current--;
|
||||
}
|
||||
recordHistory(state);
|
||||
requestAnimationFrame(() => refreshValidity(state));
|
||||
scheduleConnectionRefresh(state);
|
||||
},
|
||||
updateNodeLabel(host, nodeId, label) {
|
||||
const state = editors.get(host);
|
||||
@@ -709,6 +740,7 @@ window.taskListEditor = {
|
||||
if (nodeData) nodeData.html = html;
|
||||
const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`);
|
||||
if (el) el.innerHTML = html;
|
||||
scheduleConnectionRefresh(state);
|
||||
recordHistory(state);
|
||||
},
|
||||
undo(host) {
|
||||
@@ -761,6 +793,7 @@ window.taskListEditor = {
|
||||
|
||||
// Frisch geladener Stand ist der neue Basiszustand der Undo-Historie.
|
||||
resetHistory(state);
|
||||
scheduleConnectionRefresh(state);
|
||||
return JSON.stringify(ids);
|
||||
},
|
||||
importGraph(host, graphJson) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Konfiguration der MongoDB-Collections, in denen die Einstellungen der
|
||||
* einzelnen Element-Typen abgelegt werden.
|
||||
*
|
||||
* <p>Die Collection-Namen werden aus {@code application.properties} gebunden,
|
||||
* deren Werte wiederum aus den {@code MONGODB_ELEMENT_*}-Variablen der
|
||||
* {@code .env} stammen:
|
||||
* <pre>
|
||||
* tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LISTE_COLLECTION:element_text_liste_erfassen}
|
||||
* </pre>
|
||||
*
|
||||
* <p>Für jeden (festen wie benutzerdefinierten) Typ muss eine Zuordnung
|
||||
* existieren; benutzerdefinierte Typen werden zuvor auf ihren statischen
|
||||
* Basistyp aufgelöst. Fehlt ein Eintrag, ist das ein Konfigurationsfehler.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "tasklist")
|
||||
public class ElementCollectionProperties {
|
||||
|
||||
/** Typ-ID → Collection-Name. */
|
||||
private final Map<String, String> elementCollections = new HashMap<>();
|
||||
|
||||
public Map<String, String> getElementCollections() {
|
||||
return elementCollections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Collection-Namen für die angegebene Typ-ID.
|
||||
*
|
||||
* @throws IllegalStateException wenn für die Typ-ID keine Zuordnung
|
||||
* konfiguriert ist
|
||||
*/
|
||||
public String collectionFor(String typeId) {
|
||||
String configured = elementCollections.get(typeId);
|
||||
if (configured == null || configured.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"Keine Collection für Element-Typ '" + typeId + "' konfiguriert "
|
||||
+ "(tasklist.element-collections).");
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.document;
|
||||
|
||||
import de.assecutor.tasklisteditor.model.CanvasBlockState;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* In MongoDB gespeicherter Canvas-Stand für die spätere Weiterbearbeitung
|
||||
* im Editor.
|
||||
*
|
||||
* <p>{@link #layout} enthält den vollständigen Drawflow-Graphen (Knoten,
|
||||
* Positionen, Verbindungen) als JSON. {@link #blocks} hält die serverseitigen
|
||||
* Property-Werte je Knoten, damit der Canvas vollständig wiederhergestellt
|
||||
* werden kann.
|
||||
*/
|
||||
@Document(collection = "${MONGODB_CANVAS_COLLECTION:canvas_layouts}")
|
||||
public class CanvasDocument {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String name;
|
||||
private Instant createdAt;
|
||||
private String layout;
|
||||
private List<CanvasBlockState> blocks;
|
||||
|
||||
public CanvasDocument() {
|
||||
}
|
||||
|
||||
public CanvasDocument(String name, Instant createdAt, String layout, List<CanvasBlockState> blocks) {
|
||||
this.name = name;
|
||||
this.createdAt = createdAt;
|
||||
this.layout = layout;
|
||||
this.blocks = blocks;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getLayout() {
|
||||
return layout;
|
||||
}
|
||||
|
||||
public void setLayout(String layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
public List<CanvasBlockState> getBlocks() {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public void setBlocks(List<CanvasBlockState> blocks) {
|
||||
this.blocks = blocks;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.document;
|
||||
|
||||
import de.assecutor.tasklisteditor.model.CustomPropertyState;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* In MongoDB gespeicherter benutzerdefinierter Typ. Enthält alle Daten, um den
|
||||
* {@code BlockType} ohne Rückgriff auf den Basis-Typ wiederherstellen zu können
|
||||
* (Aussehen, Ein-/Ausgänge und die Eigenschaften mit ihren erfassten Vorgaben).
|
||||
*/
|
||||
@Document(collection = "${MONGODB_CUSTOM_TYPE_COLLECTION:custom_types}")
|
||||
public class CustomTypeDocument {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
/** Eindeutige Typnummer (>= 1000); dient zugleich als Schlüssel. */
|
||||
@Indexed(unique = true)
|
||||
private int taskType;
|
||||
|
||||
private String typeId;
|
||||
private String label;
|
||||
private String baseTypeId;
|
||||
private String description;
|
||||
private String icon;
|
||||
private String color;
|
||||
private int inputs;
|
||||
private int outputs;
|
||||
private List<CustomPropertyState> properties;
|
||||
private Instant createdAt;
|
||||
|
||||
public CustomTypeDocument() {
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public int getTaskType() {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
public void setTaskType(int taskType) {
|
||||
this.taskType = taskType;
|
||||
}
|
||||
|
||||
public String getTypeId() {
|
||||
return typeId;
|
||||
}
|
||||
|
||||
public void setTypeId(String typeId) {
|
||||
this.typeId = typeId;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getBaseTypeId() {
|
||||
return baseTypeId;
|
||||
}
|
||||
|
||||
public void setBaseTypeId(String baseTypeId) {
|
||||
this.baseTypeId = baseTypeId;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public void setIcon(String icon) {
|
||||
this.icon = icon;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public int getInputs() {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
public void setInputs(int inputs) {
|
||||
this.inputs = inputs;
|
||||
}
|
||||
|
||||
public int getOutputs() {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
public void setOutputs(int outputs) {
|
||||
this.outputs = outputs;
|
||||
}
|
||||
|
||||
public List<CustomPropertyState> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
public void setProperties(List<CustomPropertyState> properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.document;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Einstellungen eines einzelnen platzierten Elements (Block-Instanz) eines
|
||||
* Canvas. Wird beim Speichern eines Canvas je Element-Typ in eine eigene
|
||||
* MongoDB-Collection geschrieben (Collection-Name pro Typ konfigurierbar, siehe
|
||||
* {@code tasklist.element-collections} bzw. die {@code MONGODB_ELEMENT_*}-
|
||||
* Variablen in der {@code .env}).
|
||||
*
|
||||
* <p>Die Verknüpfung zum Canvas erfolgt über {@link #canvasId}; {@link #nodeId}
|
||||
* identifiziert den Knoten innerhalb des Canvas.
|
||||
*/
|
||||
public class ElementSettingsDocument {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
/** ID des zugehörigen {@link CanvasDocument}. */
|
||||
private String canvasId;
|
||||
|
||||
/** Anzeigename des Canvas zum Zeitpunkt des Speicherns. */
|
||||
private String canvasName;
|
||||
|
||||
/** Knoten-ID innerhalb des Canvas (Drawflow). */
|
||||
private int nodeId;
|
||||
|
||||
/** Typ-ID des Elements (z. B. {@code text-liste-erfassen} oder {@code custom-1000}). */
|
||||
private String typeId;
|
||||
|
||||
/**
|
||||
* Bei benutzerdefinierten Typen die Typ-ID des statischen Basistyps, in
|
||||
* dessen Collection das Dokument abgelegt wird; bei festen Typen {@code null}.
|
||||
*/
|
||||
private String baseTypeId;
|
||||
|
||||
/** Bezeichnung des Tasks (entspricht {@code topic}). */
|
||||
private String topic;
|
||||
|
||||
/** Erfasste Property-Werte des Elements. */
|
||||
private Map<String, Object> values;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
public ElementSettingsDocument() {
|
||||
}
|
||||
|
||||
public ElementSettingsDocument(String canvasId, String canvasName, int nodeId,
|
||||
String typeId, String baseTypeId, String topic,
|
||||
Map<String, Object> values, Instant createdAt) {
|
||||
this.canvasId = canvasId;
|
||||
this.canvasName = canvasName;
|
||||
this.nodeId = nodeId;
|
||||
this.typeId = typeId;
|
||||
this.baseTypeId = baseTypeId;
|
||||
this.topic = topic;
|
||||
this.values = values;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getCanvasId() {
|
||||
return canvasId;
|
||||
}
|
||||
|
||||
public void setCanvasId(String canvasId) {
|
||||
this.canvasId = canvasId;
|
||||
}
|
||||
|
||||
public String getCanvasName() {
|
||||
return canvasName;
|
||||
}
|
||||
|
||||
public void setCanvasName(String canvasName) {
|
||||
this.canvasName = canvasName;
|
||||
}
|
||||
|
||||
public int getNodeId() {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
public void setNodeId(int nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
}
|
||||
|
||||
public String getTypeId() {
|
||||
return typeId;
|
||||
}
|
||||
|
||||
public void setTypeId(String typeId) {
|
||||
this.typeId = typeId;
|
||||
}
|
||||
|
||||
public String getBaseTypeId() {
|
||||
return baseTypeId;
|
||||
}
|
||||
|
||||
public void setBaseTypeId(String baseTypeId) {
|
||||
this.baseTypeId = baseTypeId;
|
||||
}
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
public void setTopic(String topic) {
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
public Map<String, Object> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
public void setValues(Map<String, Object> values) {
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.document;
|
||||
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* In MongoDB gespeicherte Task-Liste. Das Feld {@link #tasks} entspricht
|
||||
* exakt dem {@code tasks}-Array, das zur stadtbote-App gesendet wird.
|
||||
* {@link #layout} hält zusätzlich den rohen Editor-Graphen (Drawflow),
|
||||
* damit die Liste später im Editor wieder geöffnet werden kann.
|
||||
*/
|
||||
@Document(collection = "${MONGODB_COLLECTION:tasks_json}")
|
||||
public class TaskListDocument {
|
||||
|
||||
/**
|
||||
* Eindeutige Dokument-ID. Wird beim Speichern automatisch als
|
||||
* MongoDB-ObjectId vergeben, falls nicht gesetzt.
|
||||
*/
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String name;
|
||||
private Instant createdAt;
|
||||
private List<TaskDto> tasks;
|
||||
private String layout;
|
||||
|
||||
public TaskListDocument() {
|
||||
}
|
||||
|
||||
public TaskListDocument(String name, Instant createdAt, List<TaskDto> tasks, String layout) {
|
||||
this.name = name;
|
||||
this.createdAt = createdAt;
|
||||
this.tasks = tasks;
|
||||
this.layout = layout;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public List<TaskDto> getTasks() {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public void setTasks(List<TaskDto> tasks) {
|
||||
this.tasks = tasks;
|
||||
}
|
||||
|
||||
public String getLayout() {
|
||||
return layout;
|
||||
}
|
||||
|
||||
public void setLayout(String layout) {
|
||||
this.layout = layout;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -28,6 +31,7 @@ public class BlockTypeRegistry {
|
||||
/** Typnummer der virtuellen Abschluss-Markierung (negativ = nicht exportiert). */
|
||||
public static final int END_TASK_TYPE = -2;
|
||||
|
||||
@Autowired
|
||||
public BlockTypeRegistry(@Value("${tasklist.hide-script-block:false}") boolean hideScriptBlock) {
|
||||
// Rein virtuelle Workflow-Markierungen: Start (nur Ausgang) und Abschluss
|
||||
// (nur Eingang). Sie dienen ausschließlich der Darstellung eines
|
||||
@@ -55,13 +59,17 @@ public class BlockTypeRegistry {
|
||||
List.of()
|
||||
));
|
||||
|
||||
// Die Typnummern 2–11 entsprechen der historischen Task-Typ-Belegung
|
||||
// der stadtbote-App (2 = Unterschrift, 3 = Ankunft, 4 = Fotos,
|
||||
// 5 = Barcode, 7–9 = Texteingaben, 11 = Statusauswahl). Neue, im
|
||||
// Editor eingeführte Typen werden ab 12 aufsteigend nummeriert.
|
||||
register(new BlockType(
|
||||
"ankunft-melden",
|
||||
"Ankunft melden",
|
||||
"Erfasst die Ankunft am Einsatzort (Bestätigungsschaltfläche).",
|
||||
"vaadin:map-marker",
|
||||
"#2563eb",
|
||||
1,
|
||||
3,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.bool("track", "GPS-Position erfassen", true),
|
||||
@@ -92,7 +100,7 @@ public class BlockTypeRegistry {
|
||||
"Nimmt eine Anzahl von Fotos auf.",
|
||||
"vaadin:camera",
|
||||
"#059669",
|
||||
3,
|
||||
4,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.number("min", "Mindestanzahl Fotos", 1),
|
||||
@@ -155,7 +163,7 @@ public class BlockTypeRegistry {
|
||||
"Einfache Bestätigung per Häkchen ohne weitere Eingabe.",
|
||||
"vaadin:check-square-o",
|
||||
"#16a34a",
|
||||
6,
|
||||
12,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.bool("opt", "Optionale Aufgabe", false),
|
||||
@@ -194,17 +202,16 @@ public class BlockTypeRegistry {
|
||||
)
|
||||
));
|
||||
|
||||
// Listen-Tasks: erfassen mehrerer gleichartiger Werte. Die Anzahl wird
|
||||
// analog zu "Fotos aufnehmen" über min/max gesteuert. Diese Typen haben
|
||||
// (noch) keinen Handler in der stadtbote-App und liegen daher oberhalb
|
||||
// des dort genutzten Bereichs (1–11).
|
||||
// Listen-Tasks: erfassen mehrerer gleichartiger Werte über
|
||||
// vordefinierte Eingabefelder. Wie alle im Editor neu eingeführten
|
||||
// Typen liegen sie oberhalb des historischen App-Bereichs (2–11).
|
||||
register(new BlockType(
|
||||
"text-liste-erfassen",
|
||||
"Liste von Texten erfassen",
|
||||
"Erfasst mehrere Texteinträge über vordefinierte Eingabefelder.",
|
||||
"vaadin:lines-list",
|
||||
"#4338ca",
|
||||
12,
|
||||
13,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"),
|
||||
@@ -218,7 +225,7 @@ public class BlockTypeRegistry {
|
||||
"Erfasst mehrere numerische Werte über vordefinierte Eingabefelder.",
|
||||
"vaadin:list-ol",
|
||||
"#0369a1",
|
||||
13,
|
||||
14,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"),
|
||||
@@ -232,7 +239,7 @@ public class BlockTypeRegistry {
|
||||
"Lässt mehrere vordefinierte Optionen per Checkbox ankreuzen.",
|
||||
"vaadin:tasks",
|
||||
"#15803d",
|
||||
14,
|
||||
15,
|
||||
1, 1,
|
||||
List.of(
|
||||
PropertyDefinition.nameList("entries", "Name der Checkboxen"),
|
||||
@@ -258,6 +265,21 @@ public class BlockTypeRegistry {
|
||||
types.put(type.id(), type);
|
||||
}
|
||||
|
||||
private BlockTypeRegistry(List<BlockType> standardTypes) {
|
||||
standardTypes.forEach(this::register);
|
||||
}
|
||||
|
||||
/** Jeder Editor-Aufruf erhält seinen eigenen Katalog ohne fremde eigene Typen. */
|
||||
public BlockTypeRegistry forEditor() {
|
||||
return new BlockTypeRegistry(all().stream()
|
||||
.filter(type -> type.taskType() < CUSTOM_TYPE_START)
|
||||
.toList());
|
||||
}
|
||||
|
||||
public void clearCustom() {
|
||||
types.values().removeIf(type -> type.taskType() >= CUSTOM_TYPE_START);
|
||||
}
|
||||
|
||||
public List<BlockType> all() {
|
||||
return List.copyOf(types.values());
|
||||
}
|
||||
@@ -266,7 +288,7 @@ public class BlockTypeRegistry {
|
||||
return Optional.ofNullable(types.get(id));
|
||||
}
|
||||
|
||||
/** Die festen Standard-Typen (Typnummer 1–8), auf denen eigene Typen aufbauen. */
|
||||
/** Die festen Standard-Typen (Typnummer < 1000), auf denen eigene Typen aufbauen. */
|
||||
public List<BlockType> baseTypes() {
|
||||
return types.values().stream()
|
||||
.filter(t -> t.taskType() >= 1 && t.taskType() < CUSTOM_TYPE_START)
|
||||
@@ -278,12 +300,16 @@ public class BlockTypeRegistry {
|
||||
|
||||
/** Nächste freie Typnummer ab {@value #CUSTOM_TYPE_START}. */
|
||||
public synchronized int nextCustomTaskType() {
|
||||
int next = CUSTOM_TYPE_START;
|
||||
for (BlockType type : types.values()) {
|
||||
if (type.taskType() >= next) {
|
||||
next = type.taskType() + 1;
|
||||
}
|
||||
}
|
||||
return nextCustomTaskType(List.of());
|
||||
}
|
||||
|
||||
/** Im Undo-Verlauf vorkommende Nummern dürfen ebenfalls nicht erneut vergeben werden. */
|
||||
public synchronized int nextCustomTaskType(Collection<Integer> reserved) {
|
||||
var occupied = new HashSet<>(reserved);
|
||||
types.values().forEach(type -> occupied.add(type.taskType()));
|
||||
int highest = occupied.stream().mapToInt(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;
|
||||
}
|
||||
|
||||
@@ -318,7 +344,7 @@ public class BlockTypeRegistry {
|
||||
|
||||
/**
|
||||
* Registriert einen bereits vollständig aufgebauten benutzerdefinierten Typ
|
||||
* (z. B. aus MongoDB geladen). Bereits vorhandene Typen mit gleicher ID
|
||||
* (z. B. vom Aufrufer übergeben). Bereits vorhandene Typen mit gleicher ID
|
||||
* werden überschrieben.
|
||||
*/
|
||||
public synchronized BlockType registerCustom(BlockType custom) {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Serverseitiger Zustand eines Canvas-Knotens (Property-Werte + Bezeichnung),
|
||||
* der zusammen mit dem Drawflow-Layout gespeichert wird. Über die
|
||||
* {@link #nodeId} wird der Zustand beim Laden wieder dem importierten Knoten
|
||||
* zugeordnet (Drawflow erhält die IDs beim Import).
|
||||
*/
|
||||
public class CanvasBlockState {
|
||||
|
||||
private int nodeId;
|
||||
private String typeId;
|
||||
private String topic;
|
||||
private Map<String, Object> values;
|
||||
|
||||
public CanvasBlockState() {
|
||||
}
|
||||
|
||||
public CanvasBlockState(int nodeId, String typeId, String topic, Map<String, Object> values) {
|
||||
this.nodeId = nodeId;
|
||||
this.typeId = typeId;
|
||||
this.topic = topic;
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
public int getNodeId() {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
public void setNodeId(int nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
}
|
||||
|
||||
public String getTypeId() {
|
||||
return typeId;
|
||||
}
|
||||
|
||||
public void setTypeId(String typeId) {
|
||||
this.typeId = typeId;
|
||||
}
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
public void setTopic(String topic) {
|
||||
this.topic = topic;
|
||||
}
|
||||
|
||||
public Map<String, Object> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
public void setValues(Map<String, Object> values) {
|
||||
this.values = values;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
/**
|
||||
* In MongoDB ablegbarer Zustand einer {@link PropertyDefinition} eines
|
||||
* Als JSON übertragbarer Zustand einer {@link PropertyDefinition} eines
|
||||
* benutzerdefinierten Typs (inkl. der erfassten Vorgabe). Beim Laden wird
|
||||
* daraus wieder eine {@link PropertyDefinition} aufgebaut.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Vollständiger eigener Typ im Import- und Rückgabe-JSON des Editors. */
|
||||
public record CustomTypeDefinition(
|
||||
String typeId,
|
||||
int taskType,
|
||||
String baseTypeId,
|
||||
String label,
|
||||
String description,
|
||||
String icon,
|
||||
String color,
|
||||
int inputs,
|
||||
int outputs,
|
||||
List<CustomPropertyState> properties
|
||||
) {
|
||||
public CustomTypeDefinition {
|
||||
properties = properties == null ? List.of() : properties.stream()
|
||||
.map(property -> new CustomPropertyState(property.getId(), property.getLabel(),
|
||||
property.getType(), copyValue(property.getDefaultValue())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static CustomTypeDefinition from(BlockType type) {
|
||||
return new CustomTypeDefinition(type.id(), type.taskType(), type.baseTypeId(), type.label(),
|
||||
type.description(), type.icon(), type.color(), type.inputs(), type.outputs(),
|
||||
type.properties().stream().map(CustomPropertyState::of).toList());
|
||||
}
|
||||
|
||||
/** Jeder Editor erhält eigene veränderbare Vorgabewerte, auch bei erneutem Öffnen eines Imports. */
|
||||
public BlockType toBlockType() {
|
||||
return new BlockType(typeId, label, description, icon, color, taskType, inputs, outputs,
|
||||
properties.stream().map(property -> new PropertyDefinition(property.getId(),
|
||||
property.getLabel(), property.getType(), copyValue(property.getDefaultValue())))
|
||||
.toList(),
|
||||
baseTypeId);
|
||||
}
|
||||
|
||||
private static Object copyValue(Object value) {
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = new ArrayList<>(list.size());
|
||||
list.forEach(item -> copy.add(copyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<Object, Object> copy = new LinkedHashMap<>();
|
||||
map.forEach((key, item) -> copy.put(key, copyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.util.List;
|
||||
* erscheinen.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonPropertyOrder({"s_id", "type", "topic", "opt", "reenter", "min", "max",
|
||||
@JsonPropertyOrder({"s_id", "type", "base_type", "topic", "opt", "reenter", "min", "max",
|
||||
"max_len", "rt", "track", "txt", "task_arrival", "task_fin_work", "entries", "choices", "fields", "script"})
|
||||
public class TaskDto {
|
||||
|
||||
@@ -25,6 +25,15 @@ public class TaskDto {
|
||||
|
||||
private int type;
|
||||
|
||||
/**
|
||||
* Typnummer des Basis-Typs bei benutzerdefinierten Typen
|
||||
* ({@code type >= }{@link BlockTypeRegistry#CUSTOM_TYPE_START}); die App
|
||||
* stellt solche Tasks wie ihren Basis-Typ dar. Bei Standard-Typen
|
||||
* {@code null} und damit nicht im JSON enthalten.
|
||||
*/
|
||||
@JsonProperty("base_type")
|
||||
private Integer baseType;
|
||||
|
||||
private String topic;
|
||||
|
||||
private Boolean opt;
|
||||
@@ -47,19 +56,21 @@ public class TaskDto {
|
||||
|
||||
private List<TaskEntryDto> entries;
|
||||
|
||||
/** Auswahloptionen eines Status-Tasks (Typ 6). */
|
||||
/** Auswahloptionen eines Status-Tasks (Typ 11). */
|
||||
private List<ChoiceDto> choices;
|
||||
|
||||
/** Eingabefelder eines Listen-Text-Tasks (Typ 12). */
|
||||
/** Eingabefelder einer Text-/Zahlen-Liste (Typ 13/14). */
|
||||
private List<FieldDto> fields;
|
||||
|
||||
/** Logik-Graph eines Script-Blocks (Drawflow-Definition als JSON). */
|
||||
private JsonNode script;
|
||||
|
||||
@JsonProperty("s_id")
|
||||
public int getSId() {
|
||||
return sId;
|
||||
}
|
||||
|
||||
@JsonProperty("s_id")
|
||||
public void setSId(int sId) {
|
||||
this.sId = sId;
|
||||
}
|
||||
@@ -72,6 +83,14 @@ public class TaskDto {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Integer getBaseType() {
|
||||
return baseType;
|
||||
}
|
||||
|
||||
public void setBaseType(Integer baseType) {
|
||||
this.baseType = baseType;
|
||||
}
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,9 @@ public class TaskListPayload {
|
||||
|
||||
private String name;
|
||||
private List<TaskDto> tasks;
|
||||
private JsonNode metadata;
|
||||
private String returnOrigin;
|
||||
private List<CustomTypeDefinition> customTypes = List.of();
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
@@ -29,4 +34,28 @@ public class TaskListPayload {
|
||||
public void setTasks(List<TaskDto> tasks) {
|
||||
this.tasks = tasks;
|
||||
}
|
||||
|
||||
public JsonNode getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public void setMetadata(JsonNode metadata) {
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public String getReturnOrigin() {
|
||||
return returnOrigin;
|
||||
}
|
||||
|
||||
public void setReturnOrigin(String returnOrigin) {
|
||||
this.returnOrigin = returnOrigin;
|
||||
}
|
||||
|
||||
public List<CustomTypeDefinition> getCustomTypes() {
|
||||
return customTypes;
|
||||
}
|
||||
|
||||
public void setCustomTypes(List<CustomTypeDefinition> customTypes) {
|
||||
this.customTypes = customTypes == null ? List.of() : List.copyOf(customTypes);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.repository;
|
||||
|
||||
import de.assecutor.tasklisteditor.document.CanvasDocument;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring-Data-Repository für die in MongoDB abgelegten Canvas-Stände.
|
||||
*/
|
||||
public interface CanvasRepository extends MongoRepository<CanvasDocument, String> {
|
||||
|
||||
List<CanvasDocument> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.repository;
|
||||
|
||||
import de.assecutor.tasklisteditor.document.CustomTypeDocument;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring-Data-Repository für die in MongoDB abgelegten benutzerdefinierten Typen.
|
||||
*/
|
||||
public interface CustomTypeRepository extends MongoRepository<CustomTypeDocument, String> {
|
||||
|
||||
List<CustomTypeDocument> findAllByOrderByTaskTypeAsc();
|
||||
|
||||
long deleteByTaskType(int taskType);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package de.assecutor.tasklisteditor.repository;
|
||||
|
||||
import de.assecutor.tasklisteditor.document.TaskListDocument;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
|
||||
/**
|
||||
* Spring-Data-Repository für die in MongoDB abgelegten Task-Listen.
|
||||
*/
|
||||
public interface TaskListRepository extends MongoRepository<TaskListDocument, String> {
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package de.assecutor.tasklisteditor.rest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
||||
import de.assecutor.tasklisteditor.service.CustomTypeValidator;
|
||||
import de.assecutor.tasklisteditor.service.PendingImportStore;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -13,7 +15,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Nimmt die per Deep-Link zu übergebenden Task-Daten als JSON-Body entgegen
|
||||
@@ -39,10 +44,13 @@ public class EditorImportController {
|
||||
|
||||
private final PendingImportStore importStore;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BlockTypeRegistry registry;
|
||||
|
||||
public EditorImportController(PendingImportStore importStore, ObjectMapper objectMapper) {
|
||||
public EditorImportController(PendingImportStore importStore, ObjectMapper objectMapper,
|
||||
BlockTypeRegistry registry) {
|
||||
this.importStore = importStore;
|
||||
this.objectMapper = objectMapper;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
public record ImportResponse(String token, String deepLink) {
|
||||
@@ -50,8 +58,10 @@ public class EditorImportController {
|
||||
|
||||
@PostMapping("/import")
|
||||
public ResponseEntity<ImportResponse> importTasks(@RequestBody JsonNode body) {
|
||||
TaskListPayload payload = toPayload(body);
|
||||
if (payload.getTasks() == null || payload.getTasks().isEmpty()) {
|
||||
TaskListPayload payload;
|
||||
try {
|
||||
payload = toPayload(body);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
@@ -66,16 +76,72 @@ public class EditorImportController {
|
||||
}
|
||||
|
||||
private TaskListPayload toPayload(JsonNode body) {
|
||||
if (body == null || body.isNull()) {
|
||||
return new TaskListPayload();
|
||||
if (body == null || (!body.isArray() && !body.isObject())) {
|
||||
throw new IllegalArgumentException("Expected an object or task array");
|
||||
}
|
||||
|
||||
JsonNode tasks = body.isArray() ? body : body.get("tasks");
|
||||
if (tasks != null && !tasks.isNull()) {
|
||||
if (!tasks.isArray()) {
|
||||
throw new IllegalArgumentException("tasks must be an array");
|
||||
}
|
||||
for (JsonNode task : tasks) {
|
||||
if (!task.isObject()) {
|
||||
throw new IllegalArgumentException("Every task must be an object");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TaskListPayload payload;
|
||||
if (body.isArray()) {
|
||||
TaskListPayload payload = new TaskListPayload();
|
||||
payload = new TaskListPayload();
|
||||
payload.setTasks(objectMapper.convertValue(body, new TypeReference<List<TaskDto>>() {
|
||||
}));
|
||||
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}
|
||||
* entgegen, legt sie hier unter einem Token ab und liefert einen Deep-Link mit
|
||||
* diesem Token zurück. Der Editor liest die Daten beim Öffnen anhand des Tokens
|
||||
* wieder aus (und entfernt sie dabei).
|
||||
* wieder aus. Das Token bleibt bis zum Ablauf gültig, damit ein Neuladen
|
||||
* des Editors denselben Aufruf wiederherstellen kann.
|
||||
*
|
||||
* <p>Bewusst In-Memory gehalten (transienter Hand-off). Für einen
|
||||
* mehrinstanzigen Betrieb müsste hier ein geteilter Speicher (z. B.
|
||||
* MongoDB mit TTL oder Redis) verwendet werden.
|
||||
* mehrinstanzigen Betrieb müsste hier ein geteilter temporärer Speicher
|
||||
* verwendet werden.
|
||||
*/
|
||||
@Component
|
||||
public class PendingImportStore {
|
||||
@@ -41,11 +42,21 @@ public class PendingImportStore {
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Liest die Nutzlast bis zum Ablauf wiederholt aus, etwa bei Browser-Reloads. */
|
||||
public Optional<TaskListPayload> find(String token) {
|
||||
purgeExpired();
|
||||
Entry entry = entries.get(token);
|
||||
if (entry == null || !entry.expiresAt().isAfter(Instant.now())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(entry.payload());
|
||||
}
|
||||
|
||||
/** Liest die Nutzlast einmalig aus und entfernt sie. */
|
||||
public Optional<TaskListPayload> consume(String token) {
|
||||
purgeExpired();
|
||||
Entry entry = entries.remove(token);
|
||||
if (entry == null || entry.expiresAt().isBefore(Instant.now())) {
|
||||
if (entry == null || !entry.expiresAt().isAfter(Instant.now())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(entry.payload());
|
||||
@@ -53,6 +64,6 @@ public class PendingImportStore {
|
||||
|
||||
private void purgeExpired() {
|
||||
Instant now = Instant.now();
|
||||
entries.entrySet().removeIf(e -> e.getValue().expiresAt().isBefore(now));
|
||||
entries.entrySet().removeIf(e -> !e.getValue().expiresAt().isAfter(now));
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,11 @@ import java.util.Set;
|
||||
public class TaskListExporter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BlockTypeRegistry registry;
|
||||
|
||||
public TaskListExporter(ObjectMapper objectMapper) {
|
||||
public TaskListExporter(ObjectMapper objectMapper, BlockTypeRegistry registry) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
public List<TaskDto> export(String drawflowJson, Map<Integer, BlockInstance> instances) {
|
||||
@@ -225,6 +227,13 @@ public class TaskListExporter {
|
||||
private TaskDto toTask(BlockInstance instance) {
|
||||
TaskDto task = new TaskDto();
|
||||
task.setType(instance.type().taskType());
|
||||
// Benutzerdefinierte Typen verhalten sich in der App wie ihr
|
||||
// Basis-Typ; dessen Typnummer wird als base_type mit exportiert.
|
||||
if (instance.type().taskType() >= BlockTypeRegistry.CUSTOM_TYPE_START
|
||||
&& instance.type().baseTypeId() != null) {
|
||||
registry.find(instance.type().baseTypeId())
|
||||
.ifPresent(base -> task.setBaseType(base.taskType()));
|
||||
}
|
||||
String topic = instance.topic();
|
||||
task.setTopic(topic == null || topic.isBlank() ? instance.type().label() : topic);
|
||||
|
||||
|
||||
@@ -37,12 +37,17 @@ public class TaskListImporter {
|
||||
}
|
||||
|
||||
public List<ResolvedTask> resolve(List<TaskDto> tasks) {
|
||||
return resolve(tasks, registry);
|
||||
}
|
||||
|
||||
/** Verwendet den Katalog des konkreten Editor-Aufrufs. */
|
||||
public List<ResolvedTask> resolve(List<TaskDto> tasks, BlockTypeRegistry editorRegistry) {
|
||||
List<ResolvedTask> resolved = new ArrayList<>();
|
||||
if (tasks == null) {
|
||||
return resolved;
|
||||
}
|
||||
for (TaskDto task : tasks) {
|
||||
BlockType type = findByTaskType(task.getType());
|
||||
BlockType type = findByTaskType(task.getType(), editorRegistry);
|
||||
if (type == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -54,8 +59,8 @@ public class TaskListImporter {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private BlockType findByTaskType(int taskType) {
|
||||
return registry.all().stream()
|
||||
private BlockType findByTaskType(int taskType, BlockTypeRegistry editorRegistry) {
|
||||
return editorRegistry.all().stream()
|
||||
.filter(t -> t.taskType() == taskType)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
@@ -64,13 +69,16 @@ public class TaskListImporter {
|
||||
private Map<String, Object> valuesFor(BlockType type, TaskDto task) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (PropertyDefinition def : type.properties()) {
|
||||
// Listen-Eigenschaften (Typ 12–14) werden anhand ihres
|
||||
// Listen-Eigenschaften (Typ 13–15) werden anhand ihres
|
||||
// Property-Typs in die vom Zeilen-Editor erwartete Struktur
|
||||
// (Liste aus {title}/{title,placeholder}-Maps) zurückgewandelt.
|
||||
Object value = switch (def.type()) {
|
||||
case FIELD_LIST -> fieldRows(task);
|
||||
case NAME_LIST -> entryRows(task);
|
||||
case CHOICE_LIST -> choiceRows(task);
|
||||
case FIELD_LIST -> task.getFields() == null
|
||||
? copyValue(def.defaultValue()) : fieldRows(task);
|
||||
case NAME_LIST -> task.getEntries() == null
|
||||
? copyValue(def.defaultValue()) : entryRows(task);
|
||||
case CHOICE_LIST -> task.getChoices() == null && task.getEntries() == null
|
||||
? copyValue(def.defaultValue()) : choiceRows(task);
|
||||
default -> scalarValue(def, task);
|
||||
};
|
||||
values.put(def.id(), value);
|
||||
@@ -78,6 +86,21 @@ public class TaskListImporter {
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Vorgabelisten gehören zur Typdefinition und dürfen beim Bearbeiten nicht verändert werden. */
|
||||
private Object copyValue(Object value) {
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = new ArrayList<>(list.size());
|
||||
list.forEach(item -> copy.add(copyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<Object, Object> copy = new LinkedHashMap<>();
|
||||
map.forEach((key, item) -> copy.put(key, copyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Object scalarValue(PropertyDefinition def, TaskDto task) {
|
||||
return switch (def.id()) {
|
||||
case "opt" -> orDefault(task.getOpt(), def.defaultValue());
|
||||
@@ -95,7 +118,7 @@ public class TaskListImporter {
|
||||
};
|
||||
}
|
||||
|
||||
/** {@code fields}-Array (Typ 12/13) zurück in Zeilen mit Titel + Platzhalter. */
|
||||
/** {@code fields}-Array (Typ 13/14) zurück in Zeilen mit Titel + Platzhalter. */
|
||||
private List<Map<String, Object>> fieldRows(TaskDto task) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (task.getFields() == null) {
|
||||
@@ -111,7 +134,7 @@ public class TaskListImporter {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** {@code entries}-Array (Typ 14) zurück in Zeilen mit Namen ({@code title}). */
|
||||
/** {@code entries}-Array (Typ 15) zurück in Zeilen mit Namen ({@code title}). */
|
||||
private List<Map<String, Object>> entryRows(TaskDto task) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (task.getEntries() == null) {
|
||||
@@ -129,7 +152,7 @@ public class TaskListImporter {
|
||||
/** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */
|
||||
private List<Map<String, Object>> choiceRows(TaskDto task) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (task.getChoices() != null && !task.getChoices().isEmpty()) {
|
||||
if (task.getChoices() != null) {
|
||||
for (ChoiceDto choice : task.getChoices()) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("title", choice.getTxt() == null ? "" : choice.getTxt());
|
||||
|
||||
@@ -2,33 +2,26 @@ package de.assecutor.tasklisteditor.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.tasklisteditor.document.TaskListDocument;
|
||||
import de.assecutor.tasklisteditor.model.BlockInstance;
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import de.assecutor.tasklisteditor.repository.TaskListRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON und speichert
|
||||
* die Liste in MongoDB.
|
||||
* Erzeugt aus dem Editor-Zustand das app-konforme Task-JSON für den Aufrufer.
|
||||
*/
|
||||
@Service
|
||||
public class TaskListService {
|
||||
|
||||
private final TaskListExporter exporter;
|
||||
private final TaskListRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public TaskListService(TaskListExporter exporter,
|
||||
TaskListRepository repository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.exporter = exporter;
|
||||
this.repository = repository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -102,13 +95,4 @@ public class TaskListService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Task-Liste in MongoDB.
|
||||
*
|
||||
* @return die vergebene Dokument-ID
|
||||
*/
|
||||
public String save(String name, List<TaskDto> tasks, String layout) {
|
||||
TaskListDocument document = new TaskListDocument(name, Instant.now(), tasks, layout);
|
||||
return repository.save(document).getId();
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,12 @@ public class WorkflowAssistantService {
|
||||
* die Antwort nicht verwertbar ist
|
||||
*/
|
||||
public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson) {
|
||||
return generateWorkflow(wish, currentName, currentTasksJson, registry.all());
|
||||
}
|
||||
|
||||
/** Nutzt einen Snapshot der Typen des aufrufenden Editors, auch im Hintergrund. */
|
||||
public WorkflowDraft generateWorkflow(String wish, String currentName, String currentTasksJson,
|
||||
List<BlockType> availableTypes) {
|
||||
if (!isEnabled()) {
|
||||
throw new IllegalStateException(
|
||||
"KI-Assistent deaktiviert: AI_ENABLED=true und ANTHROPIC_API_KEY "
|
||||
@@ -98,7 +104,7 @@ public class WorkflowAssistantService {
|
||||
.model(model)
|
||||
.maxTokens(16000L)
|
||||
.thinking(ThinkingConfigAdaptive.builder().build())
|
||||
.system(buildSystemPrompt())
|
||||
.system(buildSystemPrompt(availableTypes))
|
||||
.addUserMessage(buildUserMessage(wish, currentName, currentTasksJson))
|
||||
.build();
|
||||
|
||||
@@ -193,7 +199,7 @@ public class WorkflowAssistantService {
|
||||
* {@link BlockTypeRegistry}, inkl. benutzerdefinierter Typen) und das
|
||||
* erwartete JSON-Format.
|
||||
*/
|
||||
private String buildSystemPrompt() {
|
||||
private String buildSystemPrompt(List<BlockType> availableTypes) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
prompt.append("""
|
||||
Du bist der Workflow-Assistent eines grafischen Editors für Auftrags-Workflows
|
||||
@@ -230,9 +236,9 @@ public class WorkflowAssistantService {
|
||||
(boolean) erlaubt erneutes Bearbeiten.
|
||||
- Listenfelder:
|
||||
* "fields": Array aus {"title": "...", "placeholder": "...", "required": true|false}
|
||||
(Text-/Zahlenlisten, Typ 12/13).
|
||||
(Text-/Zahlenlisten, Typ 13/14).
|
||||
* "entries": Array aus {"code": <fortlaufend ab 1>, "text": "...", "required": true|false}
|
||||
(Checkbox-Liste, Typ 14).
|
||||
(Checkbox-Liste, Typ 15).
|
||||
* "choices": Array aus {"txt": "...", "ret": <fortlaufend ab 1>}
|
||||
(Statusauswahl, Typ 11).
|
||||
Kennzeichne Einträge nur dann mit "required": true, wenn der Benutzer sie
|
||||
@@ -243,7 +249,7 @@ public class WorkflowAssistantService {
|
||||
Verfügbare Element-Typen:
|
||||
""");
|
||||
|
||||
for (BlockType type : registry.all()) {
|
||||
for (BlockType type : availableTypes) {
|
||||
if ("script".equals(type.id())) {
|
||||
continue; // Script-Blöcke kann der Assistent nicht sinnvoll konfigurieren.
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.checkbox.Checkbox;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.dialog.Dialog;
|
||||
import com.vaadin.flow.component.grid.Grid;
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.component.html.H2;
|
||||
import com.vaadin.flow.component.html.H3;
|
||||
@@ -33,14 +32,10 @@ import de.assecutor.tasklisteditor.component.ScriptEditor;
|
||||
import de.assecutor.tasklisteditor.model.BlockInstance;
|
||||
import de.assecutor.tasklisteditor.model.BlockType;
|
||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||
import de.assecutor.tasklisteditor.model.CanvasBlockState;
|
||||
import de.assecutor.tasklisteditor.model.CustomTypeDefinition;
|
||||
import de.assecutor.tasklisteditor.model.PropertyDefinition;
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
||||
import de.assecutor.tasklisteditor.document.CanvasDocument;
|
||||
import de.assecutor.tasklisteditor.service.CanvasService;
|
||||
import de.assecutor.tasklisteditor.service.CustomTypeService;
|
||||
import de.assecutor.tasklisteditor.service.ElementSettingsService;
|
||||
import de.assecutor.tasklisteditor.service.PendingImportStore;
|
||||
import de.assecutor.tasklisteditor.service.TaskListImporter;
|
||||
import de.assecutor.tasklisteditor.service.TaskListService;
|
||||
@@ -50,10 +45,9 @@ import elemental.json.JsonArray;
|
||||
import elemental.json.JsonObject;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
@@ -78,9 +72,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
private final TaskListService taskListService;
|
||||
private final TaskListImporter taskListImporter;
|
||||
private final PendingImportStore importStore;
|
||||
private final CanvasService canvasService;
|
||||
private final CustomTypeService customTypeService;
|
||||
private final ElementSettingsService elementSettingsService;
|
||||
private final WorkflowAssistantService workflowAssistantService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final NodeEditor nodeEditor;
|
||||
@@ -97,28 +88,25 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
return size() > 100;
|
||||
}
|
||||
};
|
||||
private Div palettePanel;
|
||||
private Div customPalettePanel;
|
||||
private String currentListName;
|
||||
private String importToken;
|
||||
private String returnOrigin;
|
||||
private JsonNode importMetadata;
|
||||
private final String appVersion;
|
||||
|
||||
public MainView(BlockTypeRegistry registry,
|
||||
TaskListService taskListService,
|
||||
TaskListImporter taskListImporter,
|
||||
PendingImportStore importStore,
|
||||
CanvasService canvasService,
|
||||
CustomTypeService customTypeService,
|
||||
ElementSettingsService elementSettingsService,
|
||||
WorkflowAssistantService workflowAssistantService,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${tasklist.app-version:}") String appVersion) {
|
||||
this.appVersion = appVersion;
|
||||
this.registry = registry;
|
||||
this.registry = registry.forEditor();
|
||||
this.taskListService = taskListService;
|
||||
this.taskListImporter = taskListImporter;
|
||||
this.importStore = importStore;
|
||||
this.canvasService = canvasService;
|
||||
this.customTypeService = customTypeService;
|
||||
this.elementSettingsService = elementSettingsService;
|
||||
this.workflowAssistantService = workflowAssistantService;
|
||||
this.objectMapper = objectMapper;
|
||||
setSizeFull();
|
||||
@@ -126,8 +114,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
setSpacing(false);
|
||||
addClassName("tasklist-app");
|
||||
|
||||
loadCustomTypes();
|
||||
|
||||
add(buildHeader());
|
||||
|
||||
HorizontalLayout body = new HorizontalLayout();
|
||||
@@ -143,10 +129,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
body.expand(nodeEditor);
|
||||
add(body);
|
||||
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
nodeEditor.registerBlockTypes(this.registry.all());
|
||||
|
||||
nodeEditor.addNodeAddedListener(e -> {
|
||||
BlockType type = registry.find(e.typeId()).orElse(null);
|
||||
BlockType type = this.registry.find(e.typeId()).orElse(null);
|
||||
if (type == null) return;
|
||||
instances.put(e.nodeId(), new BlockInstance(e.nodeId(), type));
|
||||
});
|
||||
@@ -200,6 +186,13 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
}
|
||||
restored = new BlockInstance(id, type);
|
||||
}
|
||||
if (isCustomType(restored.type()) && registry.find(typeId).isEmpty()) {
|
||||
// Undo eines entfernten Blocks stellt auch seinen inzwischen
|
||||
// gelöschten Typ wieder her, damit die Rückgabe vollständig bleibt.
|
||||
registry.registerCustom(restored.type());
|
||||
addCustomPaletteItem(restored.type());
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
}
|
||||
instances.put(id, restored);
|
||||
});
|
||||
}
|
||||
@@ -225,13 +218,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
title.add(version);
|
||||
}
|
||||
|
||||
Button assistant = new Button("KI-Assistent", new Icon(VaadinIcon.MAGIC));
|
||||
assistant.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
assistant.setTooltipText("Workflow aus einer Beschreibung erzeugen oder ändern (Claude)");
|
||||
assistant.addClickListener(e -> openAssistantDialog());
|
||||
// KI-Funktionalität per .env (AI_ENABLED) abschaltbar.
|
||||
assistant.setVisible(workflowAssistantService.isEnabled());
|
||||
|
||||
Button undo = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
undo.setTooltipText("Letzte Canvas-Änderung zurücknehmen (Strg+Z, max. 10 Schritte)");
|
||||
@@ -241,39 +227,26 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
}
|
||||
}));
|
||||
|
||||
Button clear = new Button("Workflow löschen", new Icon(VaadinIcon.TRASH));
|
||||
clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
clear.addClickListener(e -> {
|
||||
nodeEditor.clear();
|
||||
// In den Papierkorb statt verwerfen: Undo kann das Leeren zurücknehmen.
|
||||
removedInstances.putAll(instances);
|
||||
instances.clear();
|
||||
Notification.show("Canvas geleert");
|
||||
});
|
||||
Button cancel = new Button("Abbrechen", new Icon(VaadinIcon.CLOSE));
|
||||
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
cancel.setTooltipText("Bearbeitung ohne Übernahme abbrechen");
|
||||
cancel.addClickListener(e -> returnToCaller("cancelled", null));
|
||||
|
||||
Button saveCanvas = new Button("Workflow speichern", new Icon(VaadinIcon.HARDDRIVE));
|
||||
saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
saveCanvas.addClickListener(e -> saveCanvas());
|
||||
|
||||
Button loadCanvas = new Button("Worklow laden", new Icon(VaadinIcon.FOLDER_OPEN));
|
||||
loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
loadCanvas.addClickListener(e -> openLoadCanvasDialog());
|
||||
|
||||
Button export = new Button("JSON erzeugen & speichern", new Icon(VaadinIcon.DOWNLOAD));
|
||||
export.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
export.addClickListener(e -> exportTasks());
|
||||
Button finish = new Button("Fertig", new Icon(VaadinIcon.CHECK));
|
||||
finish.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
finish.setTooltipText("Bearbeitung abschließen");
|
||||
finish.addClickListener(e -> exportTasks());
|
||||
|
||||
Span spacer = new Span();
|
||||
spacer.getStyle().set("flex", "1");
|
||||
|
||||
header.add(title, spacer, assistant, undo, clear, saveCanvas, loadCanvas, export);
|
||||
header.add(title, spacer, undo, cancel, finish);
|
||||
return header;
|
||||
}
|
||||
|
||||
private Div buildPalette() {
|
||||
Div panel = new Div();
|
||||
panel.addClassName("palette-panel");
|
||||
palettePanel = panel;
|
||||
|
||||
H3 heading = new H3("Funktionsblöcke");
|
||||
heading.addClassName("panel-heading");
|
||||
@@ -283,6 +256,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
hint.addClassName("panel-hint");
|
||||
panel.add(hint);
|
||||
|
||||
Button assistant = new Button("KI-Assistent", new Icon(VaadinIcon.MAGIC));
|
||||
assistant.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
assistant.setWidthFull();
|
||||
assistant.setTooltipText("Workflow aus einer Beschreibung erzeugen oder ändern (Claude)");
|
||||
assistant.addClickListener(e -> openAssistantDialog());
|
||||
assistant.setVisible(workflowAssistantService.isEnabled());
|
||||
panel.add(assistant);
|
||||
|
||||
// Standardelemente
|
||||
for (BlockType type : registry.all()) {
|
||||
if (!isCustomType(type)) {
|
||||
@@ -301,6 +282,10 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
customType.addClickListener(e -> openCustomTypeDialog());
|
||||
panel.add(customType);
|
||||
|
||||
customPalettePanel = new Div();
|
||||
customPalettePanel.getStyle().set("display", "contents");
|
||||
panel.add(customPalettePanel);
|
||||
|
||||
// Selbst definierte Elemente
|
||||
for (BlockType type : registry.all()) {
|
||||
if (isCustomType(type)) {
|
||||
@@ -316,7 +301,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
|
||||
/** Fügt ein benutzerdefiniertes Element ans Ende der Palette an (nach dem Button). */
|
||||
private void addCustomPaletteItem(BlockType type) {
|
||||
palettePanel.add(buildPaletteItem(type));
|
||||
customPalettePanel.add(buildPaletteItem(type));
|
||||
}
|
||||
|
||||
private Component buildPaletteDivider() {
|
||||
@@ -328,24 +313,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
return divider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die in MongoDB gespeicherten benutzerdefinierten Typen und registriert
|
||||
* sie, sofern sie noch nicht bekannt sind. Fehlt die Datenbank, startet der
|
||||
* Editor trotzdem (nur ohne die gespeicherten Typen).
|
||||
*/
|
||||
private void loadCustomTypes() {
|
||||
try {
|
||||
for (BlockType custom : customTypeService.loadAll()) {
|
||||
if (registry.find(custom.id()).isEmpty()) {
|
||||
registry.registerCustom(custom);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Eigene Typen konnten nicht geladen werden (läuft MongoDB?): "
|
||||
+ ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog zum Anlegen eines benutzerdefinierten Typs: vergibt die nächste freie
|
||||
* Typnummer ab 1000, lässt einen der festen Typen (1–8) als Basis wählen und
|
||||
@@ -356,7 +323,8 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
dialog.setHeaderTitle("Eigenen Typ anlegen");
|
||||
dialog.setWidth("440px");
|
||||
|
||||
int number = registry.nextCustomTaskType();
|
||||
int number = registry.nextCustomTaskType(removedInstances.values().stream()
|
||||
.map(instance -> instance.type().taskType()).toList());
|
||||
|
||||
IntegerField numberField = new IntegerField("Typnummer");
|
||||
numberField.setWidthFull();
|
||||
@@ -418,12 +386,6 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
value == null ? def.defaultValue() : value));
|
||||
}
|
||||
BlockType custom = registry.registerCustom(number, base, label.trim(), props);
|
||||
try {
|
||||
customTypeService.save(custom, base.id());
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Typ angelegt, aber nicht gespeichert (läuft MongoDB?): "
|
||||
+ ex.getMessage());
|
||||
}
|
||||
addCustomPaletteItem(custom);
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
Notification.show("Typ " + number + " „" + custom.label() + "“ angelegt.");
|
||||
@@ -498,16 +460,14 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
}
|
||||
|
||||
private void deleteCustomType(BlockType type, Component tile) {
|
||||
if (instances.values().stream().anyMatch(instance -> instance.type().id().equals(type.id()))) {
|
||||
Notification.show("Dieser Typ wird im Workflow verwendet. Bitte zuerst die zugehörigen Blöcke entfernen.");
|
||||
return;
|
||||
}
|
||||
if (!registry.removeCustom(type.id())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
customTypeService.deleteByTaskType(type.taskType());
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Typ entfernt, aber nicht aus der DB gelöscht (läuft MongoDB?): "
|
||||
+ ex.getMessage());
|
||||
}
|
||||
palettePanel.remove(tile);
|
||||
customPalettePanel.remove(tile);
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
Notification.show("Typ " + type.taskType() + " „" + type.label() + "“ gelöscht.");
|
||||
}
|
||||
@@ -850,7 +810,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
* aus einem Titel und – falls {@code withPlaceholder} – zusätzlich einem
|
||||
* Platzhalter sowie – falls {@code withRequired} – einer Pflichtfeld-
|
||||
* Checkbox. Der Wert wird als {@code List<Map<String,Object>>} unter der
|
||||
* Property-ID abgelegt (rundläuft über Mongo und Export).
|
||||
* Property-ID abgelegt (bleibt bei Übergabe und Rückgabe erhalten).
|
||||
*/
|
||||
private Component buildRowList(BlockInstance instance, PropertyDefinition def, Object current,
|
||||
boolean withPlaceholder, boolean withRequired) {
|
||||
@@ -960,17 +920,56 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
|
||||
@Override
|
||||
public void beforeEnter(BeforeEnterEvent event) {
|
||||
// Auch bei Navigation innerhalb derselben View beginnt jeder Aufruf
|
||||
// mit eigenem Kontext und darf keine vorherigen Tasks übernehmen.
|
||||
importToken = null;
|
||||
returnOrigin = null;
|
||||
importMetadata = null;
|
||||
currentListName = null;
|
||||
instances.clear();
|
||||
removedInstances.clear();
|
||||
registry.clearCustom();
|
||||
customPalettePanel.removeAll();
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
nodeEditor.importGraph("{\"drawflow\":{\"Home\":{\"data\":{}}}}");
|
||||
List<String> tokens = event.getLocation().getQueryParameters()
|
||||
.getParameters().getOrDefault(IMPORT_PARAM, List.of());
|
||||
if (tokens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
importStore.consume(tokens.get(0)).ifPresent(this::loadPayload);
|
||||
importToken = tokens.get(0);
|
||||
importStore.find(importToken).ifPresentOrElse(this::loadPayload,
|
||||
() -> Notification.show("Der Aufruf ist ungültig oder abgelaufen. Bitte den Editor erneut öffnen."));
|
||||
}
|
||||
|
||||
/** Baut den Canvas aus den per Deep-Link übergebenen Daten auf. */
|
||||
private void loadPayload(TaskListPayload payload) {
|
||||
buildCanvasFromTasks(payload.getTasks(), payload.getName(), "aus Deep-Link geladen");
|
||||
currentListName = payload.getName();
|
||||
importMetadata = payload.getMetadata();
|
||||
returnOrigin = payload.getReturnOrigin();
|
||||
for (CustomTypeDefinition definition : payload.getCustomTypes()) {
|
||||
BlockType custom = registry.registerCustom(definition.toBlockType());
|
||||
addCustomPaletteItem(custom);
|
||||
}
|
||||
nodeEditor.registerBlockTypes(registry.all());
|
||||
if (payload.getTasks() == null || payload.getTasks().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// App-JSON enthält keine virtuellen Workflow-Markierungen. Sie werden
|
||||
// beim Öffnen ergänzt, damit der importierte Ablauf sofort gültig ist.
|
||||
List<TaskDto> tasks = new ArrayList<>(payload.getTasks());
|
||||
if (tasks.stream().noneMatch(t -> t.getType() == BlockTypeRegistry.START_TASK_TYPE)) {
|
||||
TaskDto start = new TaskDto();
|
||||
start.setType(BlockTypeRegistry.START_TASK_TYPE);
|
||||
tasks.add(0, start);
|
||||
}
|
||||
if (tasks.stream().noneMatch(t -> t.getType() == BlockTypeRegistry.END_TASK_TYPE)) {
|
||||
TaskDto end = new TaskDto();
|
||||
end.setType(BlockTypeRegistry.END_TASK_TYPE);
|
||||
tasks.add(end);
|
||||
}
|
||||
buildCanvasFromTasks(tasks, payload.getName(), "aus Deep-Link geladen");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -979,7 +978,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
* Deep-Link-Import und vom KI-Assistenten genutzt.
|
||||
*/
|
||||
private void buildCanvasFromTasks(List<TaskDto> tasks, String name, String successHint) {
|
||||
List<TaskListImporter.ResolvedTask> resolved = taskListImporter.resolve(tasks);
|
||||
List<TaskListImporter.ResolvedTask> resolved = taskListImporter.resolve(tasks, registry);
|
||||
if (resolved.isEmpty()) {
|
||||
Notification.show("Keine verwertbaren Elemente gefunden.");
|
||||
return;
|
||||
@@ -1100,6 +1099,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
progressLabel.setVisible(true);
|
||||
|
||||
UI ui = UI.getCurrent();
|
||||
List<BlockType> availableTypes = registry.all();
|
||||
ui.setPollInterval(500);
|
||||
if (modifying) {
|
||||
// Aktuellen Canvas als Task-JSON exportieren und als Kontext mitgeben.
|
||||
@@ -1118,12 +1118,12 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
}
|
||||
runAssistant(dialog, generate, wishes, progress, progressLabel, ui,
|
||||
"per KI-Assistent geändert",
|
||||
() -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson));
|
||||
() -> workflowAssistantService.generateWorkflow(wish, currentListName, currentJson, availableTypes));
|
||||
});
|
||||
} else {
|
||||
runAssistant(dialog, generate, wishes, progress, progressLabel, ui,
|
||||
"per KI-Assistent erstellt",
|
||||
() -> workflowAssistantService.generateWorkflow(wish));
|
||||
() -> workflowAssistantService.generateWorkflow(wish, null, null, availableTypes));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1159,183 +1159,85 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Canvas speichern / laden (MongoDB) ---
|
||||
|
||||
private void saveCanvas() {
|
||||
if (instances.isEmpty()) {
|
||||
Notification.show("Der Canvas ist leer – nichts zu speichern.");
|
||||
return;
|
||||
}
|
||||
nodeEditor.exportGraph(layout -> openSaveCanvasDialog(layout));
|
||||
}
|
||||
|
||||
private void openSaveCanvasDialog(String layout) {
|
||||
Dialog dialog = new Dialog();
|
||||
dialog.setHeaderTitle("Canvas speichern");
|
||||
dialog.setWidth("440px");
|
||||
|
||||
TextField name = new TextField("Name");
|
||||
name.setWidthFull();
|
||||
name.setValue(currentListName != null && !currentListName.isBlank()
|
||||
? currentListName
|
||||
: "Canvas " + instances.size() + " Blöcke");
|
||||
|
||||
dialog.add(name);
|
||||
|
||||
Button save = new Button("Speichern", new Icon(VaadinIcon.HARDDRIVE), ev -> {
|
||||
try {
|
||||
String id = canvasService.save(name.getValue(), layout, collectBlocks());
|
||||
elementSettingsService.saveForCanvas(id, name.getValue(), instances.values());
|
||||
currentListName = name.getValue();
|
||||
Notification.show("Canvas gespeichert (ID: " + id + ")");
|
||||
dialog.close();
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
|
||||
}
|
||||
});
|
||||
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
|
||||
Button cancel = new Button("Abbrechen", ev -> dialog.close());
|
||||
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
|
||||
dialog.getFooter().add(cancel, save);
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
private List<CanvasBlockState> collectBlocks() {
|
||||
List<CanvasBlockState> blocks = new ArrayList<>();
|
||||
instances.forEach((id, instance) -> blocks.add(new CanvasBlockState(
|
||||
id,
|
||||
instance.type().id(),
|
||||
instance.topic(),
|
||||
new LinkedHashMap<>(instance.values()))));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private void openLoadCanvasDialog() {
|
||||
List<CanvasDocument> saved;
|
||||
try {
|
||||
saved = canvasService.listNewestFirst();
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Laden fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
|
||||
return;
|
||||
}
|
||||
if (saved.isEmpty()) {
|
||||
Notification.show("Keine gespeicherten Canvas-Stände vorhanden.");
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog dialog = new Dialog();
|
||||
dialog.setHeaderTitle("Canvas laden");
|
||||
dialog.setWidth("560px");
|
||||
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
Grid<CanvasDocument> grid = new Grid<>();
|
||||
grid.addColumn(d -> d.getName()).setHeader("Name").setAutoWidth(true);
|
||||
grid.addColumn(d -> d.getBlocks() == null ? 0 : d.getBlocks().size()).setHeader("Blöcke");
|
||||
grid.addColumn(d -> d.getCreatedAt() == null ? "" : fmt.format(d.getCreatedAt()))
|
||||
.setHeader("Gespeichert");
|
||||
grid.setItems(saved);
|
||||
grid.setHeight("320px");
|
||||
dialog.add(grid);
|
||||
|
||||
Button load = new Button("Laden", new Icon(VaadinIcon.FOLDER_OPEN), ev -> {
|
||||
CanvasDocument selected = grid.asSingleSelect().getValue();
|
||||
if (selected == null) {
|
||||
Notification.show("Bitte einen Eintrag auswählen.");
|
||||
return;
|
||||
}
|
||||
loadCanvas(selected);
|
||||
dialog.close();
|
||||
});
|
||||
load.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
|
||||
Button cancel = new Button("Abbrechen", ev -> dialog.close());
|
||||
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
|
||||
dialog.getFooter().add(cancel, load);
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
private void loadCanvas(CanvasDocument document) {
|
||||
instances.clear();
|
||||
if (document.getBlocks() != null) {
|
||||
for (CanvasBlockState block : document.getBlocks()) {
|
||||
BlockType type = registry.find(block.getTypeId()).orElse(null);
|
||||
if (type == null) {
|
||||
continue;
|
||||
}
|
||||
BlockInstance instance = new BlockInstance(block.getNodeId(), type);
|
||||
instance.setTopic(block.getTopic());
|
||||
if (block.getValues() != null) {
|
||||
block.getValues().forEach(instance::set);
|
||||
}
|
||||
instances.put(block.getNodeId(), instance);
|
||||
}
|
||||
}
|
||||
currentListName = document.getName();
|
||||
nodeEditor.importGraph(document.getLayout());
|
||||
Notification.show("Canvas „" + document.getName() + "“ geladen.");
|
||||
}
|
||||
|
||||
private void exportTasks() {
|
||||
if (instances.isEmpty()) {
|
||||
Notification.show("Es sind keine Funktionsblöcke auf dem Canvas.");
|
||||
finishExport(new TaskListService.ExportResult(List.of(), "[]"));
|
||||
return;
|
||||
}
|
||||
nodeEditor.exportGraph(graphJson -> {
|
||||
try {
|
||||
TaskListService.ExportResult result = taskListService.build(graphJson, instances);
|
||||
if (result.tasks().isEmpty()) {
|
||||
Notification.show("Keine Tasks gefunden.");
|
||||
return;
|
||||
}
|
||||
openExportDialog(result, graphJson);
|
||||
finishExport(result);
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Export fehlgeschlagen: " + ex.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openExportDialog(TaskListService.ExportResult result, String layout) {
|
||||
private void finishExport(TaskListService.ExportResult result) {
|
||||
if (returnOrigin != null) {
|
||||
returnToCaller("completed", result.tasks());
|
||||
} else {
|
||||
openExportDialog(result);
|
||||
}
|
||||
}
|
||||
|
||||
private List<CustomTypeDefinition> customTypes() {
|
||||
return registry.all().stream().filter(this::isCustomType)
|
||||
.map(CustomTypeDefinition::from).toList();
|
||||
}
|
||||
|
||||
/** Gibt das Ergebnis an das einbettende Fenster oder den Popup-Aufrufer zurück. */
|
||||
private void returnToCaller(String status, List<TaskDto> tasks) {
|
||||
if (returnOrigin == null || importToken == null) {
|
||||
Notification.show("Kein Aufrufer verbunden. Bitte den Editor über die aufrufende Seite öffnen.");
|
||||
return;
|
||||
}
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
result.put("type", "tasklist-editor:result");
|
||||
result.put("token", importToken);
|
||||
result.put("status", status);
|
||||
result.put("name", currentListName);
|
||||
result.set("metadata", importMetadata);
|
||||
if (tasks != null) {
|
||||
result.set("tasks", objectMapper.valueToTree(tasks));
|
||||
result.set("customTypes", objectMapper.valueToTree(customTypes()));
|
||||
}
|
||||
|
||||
getUI().ifPresent(ui -> ui.getPage().executeJs("""
|
||||
const caller = window.parent !== window ? window.parent : window.opener;
|
||||
if (!caller || caller.closed) return false;
|
||||
caller.postMessage(JSON.parse($0), $1);
|
||||
return true;
|
||||
""", result.toString(), returnOrigin).then(Boolean.class, sent -> {
|
||||
if (!Boolean.TRUE.equals(sent)) {
|
||||
Notification.show("Der Aufrufer ist nicht mehr erreichbar. Bitte den Editor dort erneut öffnen.");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void openExportDialog(TaskListService.ExportResult result) {
|
||||
Dialog dialog = new Dialog();
|
||||
dialog.setHeaderTitle("Task-JSON (" + result.tasks().size() + " Tasks)");
|
||||
dialog.setHeaderTitle("JSON (" + result.tasks().size() + " Tasks)");
|
||||
dialog.setWidth("680px");
|
||||
|
||||
TextField name = new TextField("Name der Task-Liste");
|
||||
name.setWidthFull();
|
||||
name.setValue(currentListName != null && !currentListName.isBlank()
|
||||
? currentListName
|
||||
: "Task-Liste (" + result.tasks().size() + " Tasks)");
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("name", currentListName);
|
||||
payload.set("metadata", importMetadata);
|
||||
payload.set("tasks", objectMapper.valueToTree(result.tasks()));
|
||||
payload.set("customTypes", objectMapper.valueToTree(customTypes()));
|
||||
|
||||
TextArea json = new TextArea("App-JSON (tasks-Array)");
|
||||
TextArea json = new TextArea("Tasks und eigene Typen");
|
||||
json.setWidthFull();
|
||||
json.setHeight("360px");
|
||||
json.setReadOnly(true);
|
||||
json.setValue(result.json());
|
||||
|
||||
VerticalLayout content = new VerticalLayout(name, json);
|
||||
content.setPadding(false);
|
||||
content.setSpacing(true);
|
||||
dialog.add(content);
|
||||
|
||||
Button save = new Button("In MongoDB speichern", new Icon(VaadinIcon.DATABASE), ev -> {
|
||||
try {
|
||||
String id = taskListService.save(name.getValue(), result.tasks(), layout);
|
||||
Notification.show("In MongoDB gespeichert (ID: " + id + ")");
|
||||
dialog.close();
|
||||
} catch (Exception ex) {
|
||||
Notification.show("Speichern fehlgeschlagen (läuft MongoDB?): " + ex.getMessage());
|
||||
}
|
||||
});
|
||||
save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
json.setValue(payload.toPrettyString());
|
||||
dialog.add(json);
|
||||
|
||||
Button close = new Button("Schließen", ev -> dialog.close());
|
||||
close.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
|
||||
dialog.getFooter().add(close, save);
|
||||
dialog.getFooter().add(close);
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
server.port=8080
|
||||
spring.application.name=workflow-editor
|
||||
|
||||
# Anwendungsversion: wird beim Build per Maven-Resource-Filtering
|
||||
# (spring-boot-starter-parent, @...@-Delimiter) aus der pom.xml gefüllt.
|
||||
@@ -6,6 +7,9 @@ tasklist.app-version=@project.version@
|
||||
|
||||
vaadin.launch-browser=true
|
||||
vaadin.allowed-packages=de.assecutor.tasklisteditor
|
||||
# Lit-/Router-Modus statt React (muss zum reactEnable=false im pom.xml passen,
|
||||
# damit der Dev-Modus beim IDE-Start kein React-Frontend generiert).
|
||||
vaadin.react.enable=false
|
||||
|
||||
management.endpoints.web.exposure.include=health,info
|
||||
management.endpoint.health.probes.enabled=true
|
||||
@@ -14,18 +18,6 @@ management.health.readinessstate.enabled=true
|
||||
|
||||
logging.level.org.atmosphere=WARN
|
||||
|
||||
# MongoDB – Speicherung der erzeugten Task-Listen.
|
||||
# Zugangsdaten werden aus der .env-Datei geladen (spring-dotenv).
|
||||
# Verbindung wird erst beim ersten Speichern aufgebaut, d.h. die Anwendung
|
||||
# startet auch ohne laufende MongoDB.
|
||||
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017}
|
||||
spring.data.mongodb.database=${MONGODB_DATABASE:tasklist_editor}
|
||||
# Legt die @Indexed-Indizes (u. a. eindeutige Typnummer) bei Bedarf an.
|
||||
spring.data.mongodb.auto-index-creation=true
|
||||
|
||||
# Health-Check soll nicht fehlschlagen, wenn keine MongoDB läuft.
|
||||
management.health.mongo.enabled=false
|
||||
|
||||
# Script-Block (grafisches Logik-Element) im Editor ausblenden.
|
||||
# Wird per .env (HIDE_SCRIPT_BLOCK) gesteuert.
|
||||
tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false}
|
||||
@@ -37,22 +29,3 @@ tasklist.hide-script-block=${HIDE_SCRIPT_BLOCK:false}
|
||||
tasklist.ai-enabled=${AI_ENABLED:false}
|
||||
tasklist.anthropic-api-key=${ANTHROPIC_API_KEY:}
|
||||
tasklist.anthropic-model=${ANTHROPIC_MODEL:claude-opus-4-8}
|
||||
|
||||
# --- Collections je Element-Typ ---------------------------------------------
|
||||
# Einstellungen der einzelnen Elemente werden beim Canvas-Speichern zusätzlich
|
||||
# je Element-Typ in eine eigene Collection geschrieben. Namen aus der .env
|
||||
# (MONGODB_ELEMENT_*), mit Default. Benutzerdefinierte Typen werden auf ihren
|
||||
# statischen Basistyp aufgelöst; für jeden Typ muss eine Zuordnung existieren.
|
||||
tasklist.element-collections[ankunft-melden]=${MONGODB_ELEMENT_ARRIVAL_COLLECTION:element_arrival}
|
||||
tasklist.element-collections[unterschrift-erfassen]=${MONGODB_ELEMENT_SIGNATURE_COLLECTION:element_signature}
|
||||
tasklist.element-collections[fotos-aufnehmen]=${MONGODB_ELEMENT_PHOTOS_COLLECTION:element_photos}
|
||||
tasklist.element-collections[quittungsgeber-erfassen]=${MONGODB_ELEMENT_RECEIPT_GIVER_COLLECTION:element_receipt_giver}
|
||||
tasklist.element-collections[bemerkung-erfassen]=${MONGODB_ELEMENT_REMARK_COLLECTION:element_remark}
|
||||
tasklist.element-collections[kommissionsnummer-erfassen]=${MONGODB_ELEMENT_COMMISSION_NUMBER_COLLECTION:element_commission_number}
|
||||
tasklist.element-collections[checkbox]=${MONGODB_ELEMENT_CHECKBOX_COLLECTION:element_checkbox}
|
||||
tasklist.element-collections[barcode-scannen]=${MONGODB_ELEMENT_BARCODE_COLLECTION:element_barcode}
|
||||
tasklist.element-collections[statusauswahl]=${MONGODB_ELEMENT_STATUS_SELECTION_COLLECTION:element_status_selection}
|
||||
tasklist.element-collections[text-liste-erfassen]=${MONGODB_ELEMENT_TEXT_LIST_COLLECTION:element_text_list}
|
||||
tasklist.element-collections[zahl-liste-erfassen]=${MONGODB_ELEMENT_NUMBER_LIST_COLLECTION:element_number_list}
|
||||
tasklist.element-collections[checkbox-liste-erfassen]=${MONGODB_ELEMENT_CHECKBOX_LIST_COLLECTION:element_checkbox_list}
|
||||
tasklist.element-collections[script]=${MONGODB_ELEMENT_SCRIPT_COLLECTION:element_script}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
|
||||
class CustomTypeDefinitionTest {
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void snapshotsAndReopeningDoNotShareMutableDefaults() throws Exception {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("title", "Referenz");
|
||||
row.put("placeholder", "Nummer eingeben");
|
||||
row.put("required", true);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row);
|
||||
BlockType custom = new BlockType("custom-1005", "Eigene Liste", "Beschreibung", "vaadin:input",
|
||||
"#123456", 1005, 1, 1,
|
||||
List.of(new PropertyDefinition("fields", "Felder", PropertyType.FIELD_LIST, rows)),
|
||||
"text-liste-erfassen");
|
||||
|
||||
CustomTypeDefinition snapshot = CustomTypeDefinition.from(custom);
|
||||
row.put("title", "Nachträglich geändert");
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
CustomTypeDefinition received = mapper.readValue(mapper.writeValueAsString(snapshot),
|
||||
CustomTypeDefinition.class);
|
||||
BlockType firstEditor = received.toBlockType();
|
||||
BlockType secondEditor = received.toBlockType();
|
||||
List<Map<String, Object>> firstRows =
|
||||
(List<Map<String, Object>>) firstEditor.properties().getFirst().defaultValue();
|
||||
List<Map<String, Object>> secondRows =
|
||||
(List<Map<String, Object>>) secondEditor.properties().getFirst().defaultValue();
|
||||
|
||||
assertEquals("Referenz", firstRows.getFirst().get("title"));
|
||||
firstRows.getFirst().put("title", "Nur im ersten Editor");
|
||||
firstRows.add(Map.of("title", "Zusatz"));
|
||||
|
||||
assertNotSame(firstRows, secondRows);
|
||||
assertEquals(1, secondRows.size());
|
||||
assertEquals("Referenz", secondRows.getFirst().get("title"));
|
||||
assertEquals(mapper.valueToTree(snapshot), mapper.valueToTree(received));
|
||||
assertEquals("text-liste-erfassen", secondEditor.baseTypeId());
|
||||
assertEquals(1005, secondEditor.taskType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.assecutor.tasklisteditor.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class TaskDtoTest {
|
||||
|
||||
@Test
|
||||
void preservesSequenceIdUsingOnlyTheAppFieldName() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
TaskDto task = mapper.readValue("{\"s_id\": 42, \"type\": 3}", TaskDto.class);
|
||||
|
||||
assertEquals(42, task.getSId());
|
||||
JsonNode serialized = mapper.valueToTree(task);
|
||||
assertEquals(42, serialized.path("s_id").asInt());
|
||||
assertFalse(serialized.has("sid"));
|
||||
assertFalse(serialized.has("sId"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package de.assecutor.tasklisteditor.rest;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import de.assecutor.tasklisteditor.model.BlockType;
|
||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||
import de.assecutor.tasklisteditor.model.CustomTypeDefinition;
|
||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
||||
import de.assecutor.tasklisteditor.service.PendingImportStore;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class EditorImportControllerTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final BlockTypeRegistry registry = new BlockTypeRegistry(false);
|
||||
private RecordingStore store;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new RecordingStore();
|
||||
mvc = MockMvcBuilders.standaloneSetup(new EditorImportController(store, mapper, registry)).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesTasksAndNestedMetadataForReturnToCaller() throws Exception {
|
||||
JsonNode request = mapper.readTree("""
|
||||
{
|
||||
"name": "Tour 4711",
|
||||
"tasks": [{"s_id": 1, "type": 3, "topic": "Ankunft"}],
|
||||
"metadata": {"requestId": "req-42", "nested": [null, true, 17, {"value": "äöü"}]},
|
||||
"returnOrigin": "http://localhost:9000/"
|
||||
}
|
||||
""");
|
||||
|
||||
TaskListPayload payload = importPayload(request.toString());
|
||||
assertEquals("Tour 4711", payload.getName());
|
||||
assertEquals(1, payload.getTasks().size());
|
||||
assertEquals("Ankunft", payload.getTasks().getFirst().getTopic());
|
||||
assertEquals(request.get("metadata"), mapper.valueToTree(payload).get("metadata"));
|
||||
assertEquals("http://localhost:9000", payload.getReturnOrigin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsLegacyTaskArrayWithoutReturnTarget() throws Exception {
|
||||
TaskListPayload payload = importPayload("[{\"s_id\": 2, \"type\": 3, \"topic\": \"Ankunft\"}]");
|
||||
assertEquals(2, payload.getTasks().getFirst().getSId());
|
||||
assertNull(payload.getMetadata());
|
||||
assertNull(payload.getReturnOrigin());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"{}", "{\"tasks\": null}", "{\"tasks\": []}", "[]"})
|
||||
void acceptsNewWorkflow(String body) throws Exception {
|
||||
assertTrue(importPayload(body).getTasks().isEmpty());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"{}", "[]", "{\"customTypes\": null}", "{\"customTypes\": []}"})
|
||||
void defaultsToAnEmptyCustomTypeCatalog(String body) throws Exception {
|
||||
assertTrue(importPayload(body).getCustomTypes().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesCustomDefinitionsDefaultsAndTasks() throws Exception {
|
||||
ObjectNode definition = customType();
|
||||
definition.put("label", "Kundeneigene Ankunft – Büro & Straße");
|
||||
((ObjectNode) definition.withArray("properties").get(0)).put("defaultValue", false);
|
||||
ObjectNode request = mapper.createObjectNode();
|
||||
request.putArray("customTypes").add(definition);
|
||||
request.putArray("tasks").addObject().put("type", 1000).put("topic", "Beim Kunden");
|
||||
|
||||
TaskListPayload payload = importPayload(request.toString());
|
||||
|
||||
assertEquals(definition, mapper.valueToTree(payload.getCustomTypes().getFirst()));
|
||||
BlockType custom = payload.getCustomTypes().getFirst().toBlockType();
|
||||
assertEquals("ankunft-melden", custom.baseTypeId());
|
||||
assertEquals(false, custom.properties().getFirst().defaultValue());
|
||||
assertEquals(1000, payload.getTasks().getFirst().getType());
|
||||
assertEquals(definition, mapper.valueToTree(CustomTypeDefinition.from(custom)));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"{\"customTypes\": {}}", "{\"customTypes\": true}", "{\"customTypes\": [null]}",
|
||||
"{\"customTypes\": [17]}", "{\"customTypes\": [{}]}",
|
||||
"{\"tasks\": [{\"type\": 1000}]}", "[{\"type\": 1000}]"
|
||||
})
|
||||
void rejectsMalformedCustomCatalogsAndMissingDefinitions(String body) throws Exception {
|
||||
rejectWithoutStoring(body);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"{\"typeId\": \"start\"}", "{\"typeId\": \"script\"}", "{\"typeId\": \"ankunft-melden\"}",
|
||||
"{\"typeId\": \" \"}", "{\"typeId\": 17}", "{\"taskType\": 999}",
|
||||
"{\"taskType\": 2147483647}", "{\"taskType\": \"1000\"}", "{\"taskType\": 1000.5}",
|
||||
"{\"baseTypeId\": \"start\"}", "{\"baseTypeId\": \"script\"}",
|
||||
"{\"baseTypeId\": \"custom-1001\"}", "{\"baseTypeId\": \"unknown\"}",
|
||||
"{\"label\": \" \"}", "{\"description\": []}", "{\"icon\": null}",
|
||||
"{\"color\": \"red;display:none\"}", "{\"inputs\": 0}", "{\"outputs\": 2}",
|
||||
"{\"inputs\": \"1\"}", "{\"properties\": null}", "{\"properties\": []}"
|
||||
})
|
||||
void rejectsInvalidCustomDefinitions(String changes) throws Exception {
|
||||
ObjectNode definition = customType();
|
||||
definition.setAll((ObjectNode) mapper.readTree(changes));
|
||||
rejectWithoutStoring(withCustomTypes(definition));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"{\"id\": \"unknown\"}", "{\"id\": \"opt\"}", "{\"label\": \" \"}",
|
||||
"{\"type\": \"TEXT\"}", "{\"type\": null}",
|
||||
"{\"defaultValue\": \"true\"}", "{\"defaultValue\": 1}", "{\"defaultValue\": []}"
|
||||
})
|
||||
void rejectsInvalidPropertyDefinitions(String changes) throws Exception {
|
||||
ObjectNode definition = customType();
|
||||
((ObjectNode) definition.withArray("properties").get(0))
|
||||
.setAll((ObjectNode) mapper.readTree(changes));
|
||||
rejectWithoutStoring(withCustomTypes(definition));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullProperties() throws Exception {
|
||||
ObjectNode definition = customType();
|
||||
definition.withArray("properties").set(0, mapper.nullNode());
|
||||
rejectWithoutStoring(withCustomTypes(definition));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateCustomIds() throws Exception {
|
||||
ObjectNode first = customType();
|
||||
ObjectNode second = customType();
|
||||
second.put("taskType", 1001);
|
||||
rejectWithoutStoring(withCustomTypes(first, second));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateCustomTaskNumbers() throws Exception {
|
||||
ObjectNode first = customType();
|
||||
ObjectNode second = customType();
|
||||
second.put("typeId", "custom-1001");
|
||||
rejectWithoutStoring(withCustomTypes(first, second));
|
||||
}
|
||||
|
||||
private ObjectNode customType() {
|
||||
BlockType base = registry.find("ankunft-melden").orElseThrow();
|
||||
BlockType custom = new BlockType("custom-1000", "Eigene Ankunft", base.description(),
|
||||
base.icon(), base.color(), 1000, base.inputs(), base.outputs(), base.properties(), base.id());
|
||||
return mapper.valueToTree(CustomTypeDefinition.from(custom));
|
||||
}
|
||||
|
||||
private String withCustomTypes(ObjectNode... types) {
|
||||
ObjectNode request = mapper.createObjectNode();
|
||||
ArrayNode definitions = request.putArray("customTypes");
|
||||
for (ObjectNode type : types) {
|
||||
definitions.add(type);
|
||||
}
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
private void rejectWithoutStoring(String body) throws Exception {
|
||||
mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
assertEquals(0, store.putCount);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"null", "17", "true", "\"text\"", "{", "{\"tasks\": {}}",
|
||||
"{\"tasks\": \"text\"}", "{\"tasks\": [null]}", "[null]", "[17]",
|
||||
"{\"tasks\": [{\"type\": {}}]}", "{\"returnOrigin\": 17}",
|
||||
"{\"returnOrigin\": true}", "{\"returnOrigin\": []}"
|
||||
})
|
||||
void rejectsMalformedRequestsWithoutStoringThem(String body) throws Exception {
|
||||
mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
assertEquals(0, store.putCount);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', value = {
|
||||
"http://localhost:9000/|http://localhost:9000",
|
||||
"HTTP://EXAMPLE.COM:80/|http://example.com",
|
||||
"HTTPS://EXAMPLE.COM:443|https://example.com",
|
||||
"https://example.com:8443|https://example.com:8443",
|
||||
"http://[::1]:9000/|http://[::1]:9000"
|
||||
})
|
||||
void normalizesValidReturnOrigins(String input, String expected) throws Exception {
|
||||
String body = mapper.createObjectNode().put("returnOrigin", input).toString();
|
||||
assertEquals(expected, importPayload(body).getReturnOrigin());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"", "*", "null", "//example.com", "/callback", "ftp://example.com",
|
||||
"javascript:alert(1)", "https://user:password@example.com", "https://example.com/callback",
|
||||
"https://example.com?query=value", "https://example.com#fragment",
|
||||
"http://localhost:99999", "http://localhost:-1", "https://example.com/../",
|
||||
" https://example.com", "https://example.com\n"
|
||||
})
|
||||
void rejectsValuesThatAreNotHttpOrigins(String origin) throws Exception {
|
||||
String body = mapper.createObjectNode().put("returnOrigin", origin).toString();
|
||||
mvc.perform(post("/api/editor/import").contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
assertEquals(0, store.putCount);
|
||||
}
|
||||
|
||||
private TaskListPayload importPayload(String body) throws Exception {
|
||||
String response = mvc.perform(post("/api/editor/import")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
JsonNode result = mapper.readTree(response);
|
||||
String token = result.path("token").asText();
|
||||
assertTrue(!token.isBlank());
|
||||
assertEquals("http://localhost/?import=" + token, result.path("deepLink").asText());
|
||||
assertEquals(1, store.putCount);
|
||||
return store.find(token).orElseThrow();
|
||||
}
|
||||
|
||||
private static class RecordingStore extends PendingImportStore {
|
||||
private int putCount;
|
||||
|
||||
@Override
|
||||
public String put(TaskListPayload payload) {
|
||||
putCount++;
|
||||
return super.put(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.assecutor.tasklisteditor.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.tasklisteditor.model.BlockInstance;
|
||||
import de.assecutor.tasklisteditor.model.BlockType;
|
||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||
import de.assecutor.tasklisteditor.model.CustomTypeDefinition;
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CustomTypeRoundTripTest {
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final BlockTypeRegistry standards = new BlockTypeRegistry(false);
|
||||
|
||||
@Test
|
||||
void highImportedTypeNumberDoesNotOverflowAndUndoNumbersRemainReserved() {
|
||||
BlockTypeRegistry editor = standards.forEditor();
|
||||
BlockType base = standards.find("fotos-aufnehmen").orElseThrow();
|
||||
editor.registerCustom(Integer.MAX_VALUE - 1, base, "Große Typnummer", base.properties());
|
||||
assertEquals(1002, editor.nextCustomTaskType(List.of(1000, 1001)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void simultaneousEditorsCanUseSameCustomNumberWithoutSharingChanges() {
|
||||
BlockTypeRegistry first = standards.forEditor();
|
||||
BlockTypeRegistry second = standards.forEditor();
|
||||
BlockType base = standards.find("fotos-aufnehmen").orElseThrow();
|
||||
first.registerCustom(1000, base, "Erster Aufrufer", base.properties());
|
||||
second.registerCustom(1000, base, "Zweiter Aufrufer", base.properties());
|
||||
|
||||
TaskDto task = new TaskDto();
|
||||
task.setType(1000);
|
||||
TaskListImporter importer = new TaskListImporter(standards);
|
||||
assertEquals("Erster Aufrufer", importer.resolve(List.of(task), first).getFirst().topic());
|
||||
assertEquals("Zweiter Aufrufer", importer.resolve(List.of(task), second).getFirst().topic());
|
||||
assertTrue(standards.find("custom-1000").isEmpty());
|
||||
|
||||
first.clearCustom();
|
||||
assertTrue(first.find("custom-1000").isEmpty());
|
||||
assertTrue(first.find("fotos-aufnehmen").isPresent());
|
||||
assertTrue(second.find("custom-1000").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnedTaskAndTypeDefinitionCanBeOpenedAgainWithEditedValues() throws Exception {
|
||||
BlockTypeRegistry editor = standards.forEditor();
|
||||
BlockType base = standards.find("fotos-aufnehmen").orElseThrow();
|
||||
BlockType custom = editor.registerCustom(1000, base, "Zustellnachweis", base.properties());
|
||||
BlockInstance photo = new BlockInstance(2, custom);
|
||||
photo.setTopic("Paket fotografieren");
|
||||
photo.set("min", 2);
|
||||
photo.set("max", 6);
|
||||
Map<Integer, BlockInstance> instances = Map.of(
|
||||
1, new BlockInstance(1, standards.find("start").orElseThrow()),
|
||||
2, photo,
|
||||
3, new BlockInstance(3, standards.find("abschluss").orElseThrow()));
|
||||
String graph = """
|
||||
{"drawflow":{"Home":{"data":{
|
||||
"1":{"inputs":{},"outputs":{"output_1":{"connections":[{"node":"2"}]}}},
|
||||
"2":{"inputs":{"input_1":{"connections":[{"node":"1"}]}},
|
||||
"outputs":{"output_1":{"connections":[{"node":"3"}]}}},
|
||||
"3":{"inputs":{"input_1":{"connections":[{"node":"2"}]}},"outputs":{}}
|
||||
}}}}
|
||||
""";
|
||||
List<TaskDto> result = new TaskListExporter(mapper, standards).export(graph, instances);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(1000, result.getFirst().getType());
|
||||
assertEquals(4, result.getFirst().getBaseType());
|
||||
|
||||
CustomTypeDefinition definition = mapper.readValue(
|
||||
mapper.writeValueAsString(CustomTypeDefinition.from(custom)), CustomTypeDefinition.class);
|
||||
BlockTypeRegistry reopened = standards.forEditor();
|
||||
reopened.registerCustom(definition.toBlockType());
|
||||
var imported = new TaskListImporter(standards).resolve(result, reopened).getFirst();
|
||||
assertEquals("Zustellnachweis", imported.type().label());
|
||||
assertEquals("Paket fotografieren", imported.topic());
|
||||
assertEquals(2, imported.values().get("min"));
|
||||
assertEquals(6, imported.values().get("max"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.assecutor.tasklisteditor.service;
|
||||
|
||||
import de.assecutor.tasklisteditor.model.TaskListPayload;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PendingImportStoreTest {
|
||||
|
||||
@Test
|
||||
void supportsReloadsUntilPayloadIsConsumed() {
|
||||
PendingImportStore store = new PendingImportStore();
|
||||
TaskListPayload payload = new TaskListPayload();
|
||||
String token = store.put(payload);
|
||||
|
||||
assertSame(payload, store.find(token).orElseThrow());
|
||||
assertSame(payload, store.find(token).orElseThrow());
|
||||
assertSame(payload, store.consume(token).orElseThrow());
|
||||
assertTrue(store.find(token).isEmpty());
|
||||
assertTrue(store.consume(token).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSeparateImportsIndependent() {
|
||||
PendingImportStore store = new PendingImportStore();
|
||||
TaskListPayload first = new TaskListPayload();
|
||||
TaskListPayload second = new TaskListPayload();
|
||||
String firstToken = store.put(first);
|
||||
String secondToken = store.put(second);
|
||||
|
||||
assertNotEquals(firstToken, secondToken);
|
||||
store.consume(firstToken);
|
||||
assertSame(second, store.find(secondToken).orElseThrow());
|
||||
assertTrue(store.find("missing-token").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package de.assecutor.tasklisteditor.service;
|
||||
|
||||
import de.assecutor.tasklisteditor.model.BlockTypeRegistry;
|
||||
import de.assecutor.tasklisteditor.model.PropertyDefinition;
|
||||
import de.assecutor.tasklisteditor.model.PropertyType;
|
||||
import de.assecutor.tasklisteditor.model.TaskDto;
|
||||
import de.assecutor.tasklisteditor.model.TaskEntryDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TaskListImporterTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = PropertyType.class, names = {"FIELD_LIST", "NAME_LIST", "CHOICE_LIST"})
|
||||
@SuppressWarnings("unchecked")
|
||||
void missingTaskListsUseIndependentCopiesOfCustomDefaults(PropertyType propertyType) {
|
||||
List<Map<String, Object>> defaults = new ArrayList<>();
|
||||
defaults.add(new LinkedHashMap<>(Map.of("title", "Vorgabe", "required", true)));
|
||||
BlockTypeRegistry registry = customRegistry(propertyType, defaults);
|
||||
TaskListImporter importer = new TaskListImporter(registry);
|
||||
|
||||
var imported = importer.resolve(List.of(task(), task()), registry);
|
||||
List<Map<String, Object>> first = (List<Map<String, Object>>) imported.get(0).values().get("items");
|
||||
assertThat(first).isEqualTo(defaults);
|
||||
|
||||
first.get(0).put("title", "Geändert");
|
||||
first.add(new LinkedHashMap<>(Map.of("title", "Neu")));
|
||||
|
||||
assertThat(imported.get(1).values().get("items")).isEqualTo(defaults);
|
||||
assertThat(defaults).containsExactly(Map.of("title", "Vorgabe", "required", true));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = PropertyType.class, names = {"FIELD_LIST", "NAME_LIST", "CHOICE_LIST"})
|
||||
void explicitlyEmptyTaskListsOverrideCustomDefaults(PropertyType propertyType) {
|
||||
BlockTypeRegistry registry = customRegistry(propertyType, List.of(Map.of("title", "Vorgabe")));
|
||||
TaskDto task = task();
|
||||
task.setFields(List.of());
|
||||
task.setEntries(List.of());
|
||||
task.setChoices(List.of());
|
||||
|
||||
var imported = new TaskListImporter(registry).resolve(List.of(task), registry);
|
||||
|
||||
assertThat(imported.getFirst().values().get("items")).isEqualTo(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingChoicesStillImportLegacyEntries() {
|
||||
BlockTypeRegistry registry = customRegistry(PropertyType.CHOICE_LIST, List.of(Map.of("title", "Vorgabe")));
|
||||
TaskDto task = task();
|
||||
TaskEntryDto entry = new TaskEntryDto();
|
||||
entry.setText("Alte Option");
|
||||
task.setEntries(List.of(entry));
|
||||
|
||||
var imported = new TaskListImporter(registry).resolve(List.of(task), registry);
|
||||
|
||||
assertThat(imported.getFirst().values().get("items"))
|
||||
.isEqualTo(List.of(Map.of("title", "Alte Option", "required", false)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitlyEmptyChoicesDoNotRestoreLegacyEntries() {
|
||||
BlockTypeRegistry registry = customRegistry(PropertyType.CHOICE_LIST, List.of(Map.of("title", "Vorgabe")));
|
||||
TaskDto task = task();
|
||||
TaskEntryDto entry = new TaskEntryDto();
|
||||
entry.setText("Alte Option");
|
||||
task.setEntries(List.of(entry));
|
||||
task.setChoices(List.of());
|
||||
|
||||
var imported = new TaskListImporter(registry).resolve(List.of(task), registry);
|
||||
|
||||
assertThat(imported.getFirst().values().get("items")).isEqualTo(List.of());
|
||||
}
|
||||
|
||||
private BlockTypeRegistry customRegistry(PropertyType type, Object defaults) {
|
||||
BlockTypeRegistry registry = new BlockTypeRegistry(false).forEditor();
|
||||
registry.registerCustom(1000, registry.find("statusauswahl").orElseThrow(), "Eigener Typ",
|
||||
List.of(new PropertyDefinition("items", "Einträge", type, defaults)));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private TaskDto task() {
|
||||
TaskDto task = new TaskDto();
|
||||
task.setType(1000);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -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