package platform import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "sort" ) // A plan contains only explicit legacy memberships. Coordinates are local to // the containing canvas; the viewport is never part of this transformation. type legacyFramePlan struct { FrameID string `json:"frame_id"` NodeIDs []string `json:"node_ids"` InternalEdges []string `json:"internal_edges"` BoundaryEdges []string `json:"boundary_edges"` } type legacyMigrationPlan struct { PlanID string `json:"plan_id"` ParentVersion int64 `json:"parent_version"` SourceDigest string `json:"source_digest"` ChildCount int `json:"child_count"` Frames []legacyFramePlan `json:"frames"` Issues []string `json:"issues"` } func legacyPlan(project, workspace string, parent canvasSnapshot) legacyMigrationPlan { var doc map[string]any _ = json.Unmarshal(parent.Document, &doc) canonical, _ := json.Marshal(doc) digest := sha256.Sum256(canonical) p := legacyMigrationPlan{ParentVersion: parent.Version, SourceDigest: hex.EncodeToString(digest[:]), Frames: []legacyFramePlan{}, Issues: []string{}} nodes, _ := doc["nodes"].([]any) edges, _ := doc["edges"].([]any) membership := map[string]string{} for _, v := range nodes { n := v.(map[string]any) d := n["data"].(map[string]any) if f, ok := d["frameId"].(string); ok { membership[n["id"].(string)] = f } } for _, v := range nodes { n := v.(map[string]any) d := n["data"].(map[string]any) if d["kind"] != "frame" || d["subgraph"] != nil { continue } id := n["id"].(string) f := legacyFramePlan{FrameID: id, NodeIDs: []string{}, InternalEdges: []string{}, BoundaryEdges: []string{}} for node, frame := range membership { if frame == id { f.NodeIDs = append(f.NodeIDs, node) } } sort.Strings(f.NodeIDs) for _, v := range edges { e := v.(map[string]any) a, b := membership[e["source"].(string)] == id, membership[e["target"].(string)] == id if a && b { f.InternalEdges = append(f.InternalEdges, e["id"].(string)) } else if a || b { f.BoundaryEdges = append(f.BoundaryEdges, e["id"].(string)) } } p.Frames = append(p.Frames, f) } sort.Slice(p.Frames, func(i, j int) bool { return p.Frames[i].FrameID < p.Frames[j].FrameID }) p.ChildCount = len(p.Frames) identity := fmt.Sprintf("%s/%s/%s/%d/%s", project, workspace, parent.BoardID, parent.Version, p.SourceDigest) id := sha256.Sum256([]byte(identity)) p.PlanID = hex.EncodeToString(id[:]) return p } // migrateLegacyDocument mutates only a freshly decoded snapshot. Boundary edges // stay in the parent and are redirected through stable, deduplicated ports. func migrateLegacyDocument(raw json.RawMessage, children map[string]string) (json.RawMessage, map[string]json.RawMessage, error) { var doc map[string]any if err := json.Unmarshal(raw, &doc); err != nil { return nil, nil, err } nodes := doc["nodes"].([]any) edges := doc["edges"].([]any) frames := map[string]map[string]any{} owner := map[string]string{} childDocs := map[string]map[string]any{} for _, v := range nodes { n := v.(map[string]any) id := n["id"].(string) if _, ok := children[id]; ok { frames[id] = n childDocs[id] = map[string]any{"schema_version": 2, "nodes": []any{}, "edges": []any{}, "viewport": map[string]any{"x": 0, "y": 0, "zoom": 1}} } } parentNodes := []any{} for _, v := range nodes { n := v.(map[string]any) d := n["data"].(map[string]any) frame, _ := d["frameId"].(string) if f := frames[frame]; f != nil { p := f["position"].(map[string]any) pos := n["position"].(map[string]any) pos["x"] = pos["x"].(float64) - p["x"].(float64) - 24 pos["y"] = pos["y"].(float64) - p["y"].(float64) - 36 delete(d, "frameId") owner[n["id"].(string)] = frame childDocs[frame]["nodes"] = append(childDocs[frame]["nodes"].([]any), n) } else { parentNodes = append(parentNodes, n) } } for id, f := range frames { d := f["data"].(map[string]any) d["subgraph"] = map[string]any{"boardId": children[id]} d["ports"] = []any{} d["presentation"] = map[string]any{"collapsed": false, "contentScale": 1, "expandedSize": map[string]any{"width": f["width"], "height": f["height"]}, "collapsedSize": map[string]any{"width": 240, "height": 140}} } parentEdges := []any{} for _, v := range edges { e := v.(map[string]any) a, b := owner[e["source"].(string)], owner[e["target"].(string)] if a != "" && a == b { // Only knot positions change coordinate spaces. Control vectors do not. p := frames[a]["position"].(map[string]any) translateEdgeKnots(e, -p["x"].(float64)-24, -p["y"].(float64)-36) childDocs[a]["edges"] = append(childDocs[a]["edges"].([]any), e) continue } for _, end := range []string{"source", "target"} { node := e[end].(string) frame := owner[node] if frame == "" { continue } handle, _ := e[end+"Handle"].(string) if handle == "" { if end == "source" { handle = "right" } else { handle = "left" } } direction := "input" if end == "source" { direction = "output" } sum := sha256.Sum256([]byte(direction + "/" + node + "/" + handle)) portID := "p-" + hex.EncodeToString(sum[:16]) data := frames[frame]["data"].(map[string]any) ports := data["ports"].([]any) found := false for _, v := range ports { if v.(map[string]any)["id"] == portID { found = true } } if !found { data["ports"] = append(ports, map[string]any{"id": portID, "direction": direction, "nodeId": node, "handle": handle}) } e[end] = frame e[end+"Handle"] = "port:" + portID } parentEdges = append(parentEdges, e) } doc["nodes"] = parentNodes doc["edges"] = parentEdges doc["schema_version"] = 2 encoded, _ := json.Marshal(doc) parent, ok := normalizeCanvas(encoded) if !ok { return nil, nil, fmt.Errorf("invalid migrated parent") } result := map[string]json.RawMessage{} for frame, d := range childDocs { encoded, _ := json.Marshal(d) normalized, ok := normalizeCanvas(encoded) if !ok { return nil, nil, fmt.Errorf("invalid migrated child") } result[frame] = normalized } return parent, result, nil } func translateEdgeKnots(e map[string]any, x, y float64) { data, _ := e["data"].(map[string]any) path, _ := data["path"].(map[string]any) knots, _ := path["knots"].([]any) for _, v := range knots { p := v.(map[string]any)["position"].(map[string]any) p["x"] = p["x"].(float64) + x p["y"] = p["y"].(float64) + y } }