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 }