package platform import ( "crypto/sha256" "encoding/hex" "encoding/json" "sort" ) type canvasCoordinateSpace struct{ X, Y, Scale float64 } type memberLogicalEndpoint struct{ Key, Handle string } type memberLogicalEdge struct { OriginalBoard string Data map[string]any Source, Target memberLogicalEndpoint } // All documents are copied before transforming them. Each node/edge retains its // ID; collisions in a destination reject the entire operation instead of renaming. func moveMembersDocuments(parent, source, target string, nodeIDs []string, raw map[string]json.RawMessage) (map[string]json.RawMessage, error) { if source == target || source == "" || target == "" || len(nodeIDs) == 0 { return nil, errGraphOwnership } docs := map[string]map[string]any{} boards := []string{} for board, encoded := range raw { var doc map[string]any if err := json.Unmarshal(encoded, &doc); err != nil { return nil, err } docs[board] = doc boards = append(boards, board) } sort.Strings(boards) if docs[parent] == nil || docs[source] == nil || docs[target] == nil { return nil, errGraphOwnership } spaces := map[string]canvasCoordinateSpace{parent: {Scale: 1}} frames := map[string]map[string]any{} childForFrame := map[string]string{} for _, v := range docs[parent]["nodes"].([]any) { n := v.(map[string]any) data := n["data"].(map[string]any) sub, _ := data["subgraph"].(map[string]any) child, _ := sub["boardId"].(string) if child == "" || docs[child] == nil { continue } p := n["position"].(map[string]any) scale := float64(1) if presentation, ok := data["presentation"].(map[string]any); ok { if value, ok := presentation["contentScale"].(float64); ok { scale = value } } spaces[child] = canvasCoordinateSpace{X: p["x"].(float64) + 24, Y: p["y"].(float64) + 36, Scale: scale} frames[child] = n childForFrame[n["id"].(string)] = child } if _, ok := spaces[source]; !ok { return nil, errGraphOwnership } if _, ok := spaces[target]; !ok { return nil, errGraphOwnership } nodes := map[string]map[string]any{} owner := map[string]string{} selected := map[string]bool{} for _, id := range nodeIDs { if selected[id] { return nil, errGraphOwnership } selected[id] = true } for _, board := range boards { for _, v := range docs[board]["nodes"].([]any) { n := v.(map[string]any) key := canvasNodeKey(board, n["id"].(string)) nodes[key] = n owner[key] = board } } for id := range selected { key := canvasNodeKey(source, id) n := nodes[key] if n == nil { return nil, errGraphOwnership } if nodes[canvasNodeKey(target, id)] != nil { return nil, errGraphOwnership } if targetFrame := frames[target]; targetFrame != nil && source == parent && id == targetFrame["id"] { return nil, errGraphOwnership } owner[key] = target p := n["position"].(map[string]any) from, to := spaces[source], spaces[target] p["x"] = (from.X + from.Scale*p["x"].(float64) - to.X) / to.Scale p["y"] = (from.Y + from.Scale*p["y"].(float64) - to.Y) / to.Scale delete(n["data"].(map[string]any), "frameId") } resolve := func(board, node, handle string) (memberLogicalEndpoint, error) { if board == parent { if child := childForFrame[node]; child != "" { data := frames[child]["data"].(map[string]any) ports, _ := data["ports"].([]any) for _, v := range ports { p := v.(map[string]any) if "port:"+p["id"].(string) == handle { inside := p["nodeId"].(string) if nodes[canvasNodeKey(child, inside)] == nil { return memberLogicalEndpoint{}, errGraphOwnership } h, _ := p["handle"].(string) return memberLogicalEndpoint{Key: canvasNodeKey(child, inside), Handle: h}, nil } } return memberLogicalEndpoint{}, errGraphOwnership } } key := canvasNodeKey(board, node) if nodes[key] == nil { return memberLogicalEndpoint{}, errGraphOwnership } return memberLogicalEndpoint{Key: key, Handle: handle}, nil } logical := []memberLogicalEdge{} for _, board := range boards { for _, v := range docs[board]["edges"].([]any) { e := v.(map[string]any) sh, _ := e["sourceHandle"].(string) th, _ := e["targetHandle"].(string) a, err := resolve(board, e["source"].(string), sh) if err != nil { return nil, err } b, err := resolve(board, e["target"].(string), th) if err != nil { return nil, err } logical = append(logical, memberLogicalEdge{OriginalBoard: board, Data: e, Source: a, Target: b}) } } // Remove ports whose target changed ownership. Rebuild only the boundaries // still needed by logical edges; stable endpoint hashes deduplicate retries. for child, frame := range frames { data := frame["data"].(map[string]any) ports, _ := data["ports"].([]any) kept := []any{} for _, v := range ports { p := v.(map[string]any) if owner[canvasNodeKey(child, p["nodeId"].(string))] == child { kept = append(kept, p) } } data["ports"] = kept } outputNodes := map[string][]any{} outputEdges := map[string][]any{} edgeIDs := map[string]map[string]bool{} for _, board := range boards { outputNodes[board] = []any{} outputEdges[board] = []any{} edgeIDs[board] = map[string]bool{} } for _, board := range boards { for _, v := range docs[board]["nodes"].([]any) { n := v.(map[string]any) destination := owner[canvasNodeKey(board, n["id"].(string))] outputNodes[destination] = append(outputNodes[destination], n) } } endpoint := func(value memberLogicalEndpoint, edgeBoard, direction string) (string, string, error) { board := owner[value.Key] n := nodes[value.Key] node := n["id"].(string) if board == edgeBoard { return node, value.Handle, nil } frame := frames[board] if edgeBoard != parent || frame == nil { return "", "", errGraphOwnership } handle := value.Handle if handle == "" { handle = "left" if direction == "output" { handle = "right" } } sum := sha256.Sum256([]byte(direction + "/" + node + "/" + handle)) id := "p-" + hex.EncodeToString(sum[:16]) data := frame["data"].(map[string]any) ports := data["ports"].([]any) for _, v := range ports { p := v.(map[string]any) if p["nodeId"] == node && p["handle"] == handle && p["direction"] == direction { return frame["id"].(string), "port:" + p["id"].(string), nil } } data["ports"] = append(ports, map[string]any{"id": id, "nodeId": node, "handle": handle, "direction": direction}) return frame["id"].(string), "port:" + id, nil } for _, edge := range logical { destination := parent if owner[edge.Source.Key] == owner[edge.Target.Key] { destination = owner[edge.Source.Key] } e := edge.Data id := e["id"].(string) if edgeIDs[destination][id] { return nil, errGraphOwnership } edgeIDs[destination][id] = true a, sh, err := endpoint(edge.Source, destination, "output") if err != nil { return nil, err } b, th, err := endpoint(edge.Target, destination, "input") if err != nil { return nil, err } e["source"] = a e["target"] = b if sh != "" { e["sourceHandle"] = sh } else { delete(e, "sourceHandle") } if th != "" { e["targetHandle"] = th } else { delete(e, "targetHandle") } from, to := spaces[edge.OriginalBoard], spaces[destination] projectEdgePath(e, from.Scale/to.Scale, (from.X-to.X)/to.Scale, (from.Y-to.Y)/to.Scale) outputEdges[destination] = append(outputEdges[destination], e) } result := map[string]json.RawMessage{} for _, board := range boards { doc := docs[board] doc["schema_version"] = 2 doc["nodes"] = outputNodes[board] doc["edges"] = outputEdges[board] encoded, _ := json.Marshal(doc) normalized, ok := normalizeCanvas(encoded) if !ok { return nil, errGraphCapacity } result[board] = normalized } return result, nil }