Add canonical canvas rows, stable identity migration and model operations
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type modelIdentity struct {
|
||||
SourceCanvasID string `json:"sourceCanvasId"`
|
||||
Structure string `json:"structure"`
|
||||
SourceID string `json:"sourceId"`
|
||||
ElementID string `json:"elementId"`
|
||||
}
|
||||
type modelMigration struct {
|
||||
Model canvasModel
|
||||
Identities []modelIdentity
|
||||
NewCanvases []string
|
||||
}
|
||||
type modelSpace struct{ X, Y, Scale float64 }
|
||||
|
||||
// No source document is changed. IDs are allocated once inside the eventual
|
||||
// migration transaction and the complete mapping is stored with its receipt.
|
||||
func migrateCanvasModel(root string, snapshots map[string]canvasSnapshot, relations []subgraphRelation, allocate func() string) (modelMigration, error) {
|
||||
result := modelMigration{Model: emptyModel(root), Identities: []modelIdentity{}, NewCanvases: []string{}}
|
||||
documents, nodes := map[string]map[string]any{}, map[string]map[string]any{}
|
||||
identities, children, synthetic := map[string]string{}, map[string]string{}, map[string]bool{}
|
||||
used := map[string]bool{}
|
||||
for board := range snapshots {
|
||||
used[board] = true
|
||||
}
|
||||
nextID := func() (string, error) {
|
||||
id := allocate()
|
||||
if !uuidSlugPattern.MatchString(id) || used[id] {
|
||||
return "", errCanvasModel
|
||||
}
|
||||
used[id] = true
|
||||
return id, nil
|
||||
}
|
||||
identityKey := func(board, structure, id string) string { return board + "/" + structure + "/" + id }
|
||||
for _, board := range sortedKeys(snapshots) {
|
||||
if !uuidSlugPattern.MatchString(board) || snapshots[board].Version < 1 {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
normalized, valid := normalizeCanvas(snapshots[board].Document)
|
||||
if !valid {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
var document map[string]any
|
||||
if json.Unmarshal(normalized, &document) != nil {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
documents[board] = document
|
||||
result.Model.Canvases[board] = modelCanvas{ID: board}
|
||||
result.Model.Versions[board] = snapshots[board].Version + 1
|
||||
for _, collection := range []string{"nodes", "edges"} {
|
||||
structure := "node"
|
||||
if collection == "edges" {
|
||||
structure = "connection"
|
||||
}
|
||||
for _, value := range document[collection].([]any) {
|
||||
record := value.(map[string]any)
|
||||
old := record["id"].(string)
|
||||
id, err := nextID()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
identities[identityKey(board, structure, old)] = id
|
||||
result.Identities = append(result.Identities, modelIdentity{SourceCanvasID: board, Structure: structure, SourceID: old, ElementID: id})
|
||||
if structure == "node" {
|
||||
nodes[canvasNodeKey(board, old)] = record
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, relation := range relations {
|
||||
if relation.Archived {
|
||||
continue
|
||||
}
|
||||
key := canvasNodeKey(relation.Parent, relation.Frame)
|
||||
frame := nodes[key]
|
||||
if frame == nil || documents[relation.Child] == nil || children[key] != "" || relation.Child == root {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
data := frame["data"].(map[string]any)
|
||||
sub, _ := data["subgraph"].(map[string]any)
|
||||
if data["kind"] != "frame" || sub["boardId"] != relation.Child || result.Model.Canvases[relation.Child].Parent != nil {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
children[key] = relation.Child
|
||||
result.Model.Canvases[relation.Child] = modelCanvas{ID: relation.Child, Parent: &modelParent{CanvasID: relation.Parent, EntryElementID: identities[identityKey(relation.Parent, "node", relation.Frame)]}}
|
||||
}
|
||||
// Ordinary legacy frameId groups also become actual canvases, even if empty.
|
||||
for _, key := range sortedKeys(nodes) {
|
||||
node := nodes[key]
|
||||
data := node["data"].(map[string]any)
|
||||
if data["kind"] != "frame" {
|
||||
continue
|
||||
}
|
||||
if _, linked := data["subgraph"]; linked && children[key] == "" {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
if children[key] != "" {
|
||||
continue
|
||||
}
|
||||
id, err := nextID()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
board := strings.SplitN(key, "/", 2)[0]
|
||||
children[key], synthetic[key] = id, true
|
||||
result.Model.Canvases[id] = modelCanvas{ID: id, Parent: &modelParent{CanvasID: board, EntryElementID: identities[identityKey(board, "node", node["id"].(string))]}}
|
||||
result.Model.Versions[id] = 1
|
||||
result.NewCanvases = append(result.NewCanvases, id)
|
||||
}
|
||||
for _, board := range sortedKeys(documents) {
|
||||
for index, value := range documents[board]["nodes"].([]any) {
|
||||
node := value.(map[string]any)
|
||||
original := node["data"].(map[string]any)
|
||||
data := map[string]any{}
|
||||
for key, value := range original {
|
||||
data[key] = value
|
||||
}
|
||||
kind := data["kind"].(string)
|
||||
owner := board
|
||||
geometry := legacyModelGeometry(node, kind)
|
||||
if frameID, exists := data["frameId"].(string); exists {
|
||||
frame := nodes[canvasNodeKey(board, frameID)]
|
||||
child := children[canvasNodeKey(board, frameID)]
|
||||
if frame == nil || child == "" {
|
||||
return result, errCanvasModel
|
||||
}
|
||||
position := frame["position"].(map[string]any)
|
||||
frameData := frame["data"].(map[string]any)
|
||||
scale := modelContentScale(frameData)
|
||||
geometry["x"] = (geometry["x"].(float64) - position["x"].(float64) - 24) / scale
|
||||
geometry["y"] = (geometry["y"].(float64) - position["y"].(float64) - 36) / scale
|
||||
geometry["width"], geometry["height"] = geometry["width"].(float64)/scale, geometry["height"].(float64)/scale
|
||||
owner = child
|
||||
}
|
||||
for _, key := range []string{"kind", "rotation", "frameId", "subgraph", "ports"} {
|
||||
delete(data, key)
|
||||
}
|
||||
id := identities[identityKey(board, "node", node["id"].(string))]
|
||||
result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": kind, "dataVersion": float64(1), "structure": "node", "geometry": geometry, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)}
|
||||
}
|
||||
}
|
||||
spaces, err := modelSpaces(result.Model)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
resolve := func(board, node, handle, direction string) (map[string]any, error) {
|
||||
seen := map[string]bool{}
|
||||
for depth := 0; depth <= 8; depth++ {
|
||||
key := canvasNodeKey(board, node)
|
||||
legacy := nodes[key]
|
||||
if legacy == nil || seen[key+"/"+handle] {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
seen[key+"/"+handle] = true
|
||||
data := legacy["data"].(map[string]any)
|
||||
if data["kind"] != "frame" || !strings.HasPrefix(handle, "port:") {
|
||||
endpoint := map[string]any{"elementId": identities[identityKey(board, "node", node)]}
|
||||
if handle != "" {
|
||||
endpoint["portId"] = handle
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
ports, _ := data["ports"].([]any)
|
||||
found := false
|
||||
for _, value := range ports {
|
||||
port := value.(map[string]any)
|
||||
if port["id"] == strings.TrimPrefix(handle, "port:") && port["direction"] == direction {
|
||||
node, handle = modelString(port["nodeId"]), modelString(port["handle"])
|
||||
if !synthetic[key] {
|
||||
board = children[key]
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
}
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
for _, board := range sortedKeys(documents) {
|
||||
for index, value := range documents[board]["edges"].([]any) {
|
||||
edge := value.(map[string]any)
|
||||
source, err := resolve(board, modelString(edge["source"]), modelString(edge["sourceHandle"]), "output")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
target, err := resolve(board, modelString(edge["target"]), modelString(edge["targetHandle"]), "input")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
owner, err := modelCommonCanvas(result.Model, modelString(result.Model.Elements[source["elementId"].(string)]["ownerCanvasId"]), modelString(result.Model.Elements[target["elementId"].(string)]["ownerCanvasId"]))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
from, to := spaces[board], spaces[owner]
|
||||
projectEdgePath(edge, from.Scale/to.Scale, (from.X-to.X)/to.Scale, (from.Y-to.Y)/to.Scale)
|
||||
data, _ := edge["data"].(map[string]any)
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
path := data["path"]
|
||||
delete(data, "path")
|
||||
id := identities[identityKey(board, "connection", edge["id"].(string))]
|
||||
result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "source": source, "target": target, "path": path, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)}
|
||||
}
|
||||
}
|
||||
if err := validateCanvasModel(result.Model); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func modelContentScale(data map[string]any) float64 {
|
||||
presentation, _ := data["presentation"].(map[string]any)
|
||||
if scale, ok := presentation["contentScale"].(float64); ok && scale > 0 {
|
||||
return scale
|
||||
}
|
||||
return 1
|
||||
}
|
||||
func legacyModelGeometry(node map[string]any, kind string) map[string]any {
|
||||
width, height := float64(260), float64(200)
|
||||
switch kind {
|
||||
case "plain-text":
|
||||
width, height = 240, 72
|
||||
case "plain-image":
|
||||
width, height = 320, 240
|
||||
case "icon":
|
||||
width, height = 80, 80
|
||||
case "frame":
|
||||
width, height = 640, 400
|
||||
}
|
||||
if value, exists := node["width"].(float64); exists {
|
||||
width = value
|
||||
}
|
||||
if value, exists := node["height"].(float64); exists {
|
||||
height = value
|
||||
}
|
||||
position := node["position"].(map[string]any)
|
||||
data := node["data"].(map[string]any)
|
||||
rotation, _ := data["rotation"].(float64)
|
||||
return map[string]any{"x": position["x"], "y": position["y"], "width": width, "height": height, "rotation": rotation}
|
||||
}
|
||||
func modelSpaces(model canvasModel) (map[string]modelSpace, error) {
|
||||
result := map[string]modelSpace{model.Root: {Scale: 1}}
|
||||
remaining := len(model.Canvases) - 1
|
||||
for depth := 0; remaining > 0 && depth < 9; depth++ {
|
||||
progress := false
|
||||
for _, id := range sortedKeys(model.Canvases) {
|
||||
canvas := model.Canvases[id]
|
||||
if _, exists := result[id]; exists || canvas.Parent == nil {
|
||||
continue
|
||||
}
|
||||
parent, exists := result[canvas.Parent.CanvasID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
entry := model.Elements[canvas.Parent.EntryElementID]
|
||||
if entry == nil {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
geometry := entry["geometry"].(map[string]any)
|
||||
data := entry["data"].(map[string]any)
|
||||
result[id] = modelSpace{X: parent.X + (geometry["x"].(float64)+24)*parent.Scale, Y: parent.Y + (geometry["y"].(float64)+36)*parent.Scale, Scale: parent.Scale * modelContentScale(data)}
|
||||
remaining--
|
||||
progress = true
|
||||
}
|
||||
if !progress {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
}
|
||||
if remaining != 0 {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user