// Package exporter builds an Excalidraw scene JSON from a project // snapshot per docs/design.md §4 ("Export — DB → Excalidraw"). // // The exporter is a pure function on a *db.Snapshot — no DB access, no // IO — so it's trivial to unit-test against fixtures and gives the // caller (the HTTP handler) a clean handoff: build scene → upload. package exporter import ( "crypto/rand" "encoding/json" "fmt" "math/big" "sort" "mgit.msbls.de/m/cablegui/internal/db" ) // Scene is the top-level Excalidraw file format. Keys mirror what the // official Excalidraw JSON contains (we only emit the keys mxdrw cares // about for rendering — `appState`, `files`, `libraryItems` etc. can be // added later if m needs them). type Scene struct { Type string `json:"type"` Version int `json:"version"` Source string `json:"source"` Elements []Element `json:"elements"` AppState AppState `json:"appState"` Files Files `json:"files"` } type AppState struct { GridSize *int `json:"gridSize"` ViewBackground string `json:"viewBackgroundColor"` } type Files struct{} // Element is one node in the scene. Excalidraw's wire format has a lot // of optional fields; we only emit the ones that matter for the shapes // we draw. Extra null/zero fields are fine in Excalidraw (it merges // defaults). Pointer fields stay nil-omitted via omitempty so the // payload stays clean. type Element struct { ID string `json:"id"` Type string `json:"type"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` Height float64 `json:"height"` Angle float64 `json:"angle"` StrokeColor string `json:"strokeColor"` BackgroundColor string `json:"backgroundColor"` FillStyle string `json:"fillStyle"` StrokeWidth int `json:"strokeWidth"` StrokeStyle string `json:"strokeStyle"` Roughness int `json:"roughness"` Opacity int `json:"opacity"` GroupIDs []string `json:"groupIds"` FrameID *string `json:"frameId"` Roundness *Roundness `json:"roundness"` Seed int64 `json:"seed"` Version int `json:"version"` VersionNonce int64 `json:"versionNonce"` IsDeleted bool `json:"isDeleted"` BoundElements []BoundRef `json:"boundElements,omitempty"` Updated int64 `json:"updated"` Link *string `json:"link"` Locked bool `json:"locked"` // Element-type-specific extras Name string `json:"name,omitempty"` // Text-element fields Text string `json:"text,omitempty"` FontSize int `json:"fontSize,omitempty"` FontFamily int `json:"fontFamily,omitempty"` TextAlign string `json:"textAlign,omitempty"` VerticalAlign string `json:"verticalAlign,omitempty"` ContainerID *string `json:"containerId,omitempty"` OriginalText string `json:"originalText,omitempty"` LineHeight float64 `json:"lineHeight,omitempty"` // Arrow-element fields Points [][2]float64 `json:"points,omitempty"` StartBinding *Binding `json:"startBinding,omitempty"` EndBinding *Binding `json:"endBinding,omitempty"` StartArrowhead *string `json:"startArrowhead,omitempty"` EndArrowhead *string `json:"endArrowhead,omitempty"` LastCommittedPoint *[2]float64 `json:"lastCommittedPoint,omitempty"` } type Roundness struct { Type int `json:"type"` } type BoundRef struct { ID string `json:"id"` Type string `json:"type"` } type Binding struct { ElementID string `json:"elementId"` Focus float64 `json:"focus"` Gap float64 `json:"gap"` } // IDAssignment is the result of running BuildScene: the scene to upload // + the per-row excalidraw_id assignments that the caller should // persist so the next export reuses the same ids (Excalidraw collab // cursors / comments / undo history survive that way; design §4.2). type IDAssignment struct { Frames map[int64]string `json:"frames"` Devices map[int64]string `json:"devices"` Ports map[int64]string `json:"ports"` IOMarkers map[int64]string `json:"io_markers"` Cables map[int64]string `json:"cables"` Clamps map[int64]string `json:"clamps"` } // BuildScene transforms a project snapshot into an Excalidraw Scene + // the id-assignment side-table. // // nowMilli is the Updated timestamp (one millisecond stamp for every // element keeps re-exports consistent — mxdrw treats wildly-different // updateds as edit-noise). // // genID is a 21-char ID factory. Tests pass a deterministic generator // to lock element ids down across asserts. Production uses Generate21. func BuildScene(snap *db.Snapshot, nowMilli int64, genID func() string) (*Scene, *IDAssignment) { a := &IDAssignment{ Frames: map[int64]string{}, Devices: map[int64]string{}, Ports: map[int64]string{}, IOMarkers: map[int64]string{}, Cables: map[int64]string{}, Clamps: map[int64]string{}, } // idFor: reuse the existing excalidraw_id if present, else mint one. idFor := func(existing *string) string { if existing != nil && *existing != "" { return *existing } return genID() } cableTypeColor := map[int64]string{} for _, t := range snap.CableTypes { cableTypeColor[t.ID] = t.Color } // We'll need: device-id → element-id, port-id → element-id, io-id → element-id // for binding arrows. deviceElID := map[int64]string{} portElID := map[int64]string{} ioElID := map[int64]string{} frameElID := map[int64]string{} var els []Element // Frames first (Excalidraw renders later elements on top; frames are // containers that go on the bottom). for _, f := range snap.Frames { elID := idFor(f.ExcalidrawID) a.Frames[f.ID] = elID frameElID[f.ID] = elID els = append(els, Element{ ID: elID, Type: "frame", X: f.X, Y: f.Y, Width: f.Width, Height: f.Height, StrokeColor: "#bbbbbb", BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, Name: f.Name, }) } // Devices: rectangle + bound text with the device's name. Excalidraw // uses a `containerId` pointer on the text to bind it to the rect, // and `boundElements` on the rect to point back at the text. for _, d := range snap.Devices { rectID := idFor(d.ExcalidrawID) a.Devices[d.ID] = rectID deviceElID[d.ID] = rectID textID := genID() var frameRef *string if d.FrameID != nil { if v, ok := frameElID[*d.FrameID]; ok { frameRef = &v } } // Rect els = append(els, Element{ ID: rectID, Type: "rectangle", X: d.X, Y: d.Y, Width: d.Width, Height: d.Height, StrokeColor: d.Color, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Roundness: &Roundness{Type: 3}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, BoundElements: []BoundRef{{ID: textID, Type: "text"}}, }) // Bound text — name centered on the rect. els = append(els, Element{ ID: textID, Type: "text", X: d.X, Y: d.Y + d.Height/2 - 8, Width: d.Width, Height: 16, StrokeColor: d.Color, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, Text: d.Name, OriginalText: d.Name, FontSize: 16, FontFamily: 1, TextAlign: "center", VerticalAlign: "middle", ContainerID: &rectID, LineHeight: 1.25, }) } // Ports — small ellipses at device.x + port.x_offset (positional, // not containerId-bound per the seed drawing's grammar; design §4.1). for _, p := range snap.Ports { elID := idFor(p.ExcalidrawID) a.Ports[p.ID] = elID portElID[p.ID] = elID // Locate the parent device for absolute pos + frame ref. var dev *db.Device for i := range snap.Devices { if snap.Devices[i].ID == p.DeviceID { dev = &snap.Devices[i] break } } if dev == nil { continue } var frameRef *string if dev.FrameID != nil { if v, ok := frameElID[*dev.FrameID]; ok { frameRef = &v } } color := cableTypeColor[p.TypeID] if color == "" { color = "#1e1e1e" } els = append(els, Element{ ID: elID, Type: "ellipse", X: dev.X + p.XOffset - 6, Y: dev.Y + p.YOffset - 4, Width: 12, Height: 9, StrokeColor: color, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Roundness: &Roundness{Type: 2}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, }) } // IO markers — diamonds with bound "IO" (or m's label) text. powerColor := "" for _, t := range snap.CableTypes { if t.Name == "Power" { powerColor = t.Color break } } if powerColor == "" { powerColor = "#e03131" } for _, m := range snap.IOMarkers { elID := idFor(m.ExcalidrawID) a.IOMarkers[m.ID] = elID ioElID[m.ID] = elID textID := genID() var frameRef *string if m.FrameID != nil { if v, ok := frameElID[*m.FrameID]; ok { frameRef = &v } } els = append(els, Element{ ID: elID, Type: "diamond", X: m.X, Y: m.Y, Width: 30, Height: 30, StrokeColor: powerColor, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Roundness: &Roundness{Type: 2}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, BoundElements: []BoundRef{{ID: textID, Type: "text"}}, }) els = append(els, Element{ ID: textID, Type: "text", X: m.X, Y: m.Y + 7, Width: 30, Height: 16, StrokeColor: powerColor, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, Text: m.Label, OriginalText: m.Label, FontSize: 11, FontFamily: 1, TextAlign: "center", VerticalAlign: "middle", ContainerID: &elID, LineHeight: 1.25, }) } // Clamps — small grey rounded squares (v5 §11.7). Distinct from the // red IO marker diamonds so m can tell routing anchors from wall // outlets at a glance. const clampSize = 12.0 for _, cl := range snap.Clamps { elID := idFor(cl.ExcalidrawID) a.Clamps[cl.ID] = elID var frameRef *string if cl.FrameID != nil { if v, ok := frameElID[*cl.FrameID]; ok { frameRef = &v } } els = append(els, Element{ ID: elID, Type: "rectangle", X: cl.X - clampSize/2, Y: cl.Y - clampSize/2, Width: clampSize, Height: clampSize, StrokeColor: "#555555", BackgroundColor: "#888888", FillStyle: "solid", StrokeWidth: 1, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, FrameID: frameRef, Roundness: &Roundness{Type: 3}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, }) } // Pre-group cable_clamps by cable for the arrow mid-points pass. clampsByCable := map[int64][]db.CableClamp{} for _, cc := range snap.CableClamps { clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc) } for _, arr := range clampsByCable { // Already sorted by ListCableClamps (ORDER BY cable_id, ord), // but defend against unsorted inputs. sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord }) } clampPos := map[int64][2]float64{} for _, cl := range snap.Clamps { clampPos[cl.ID] = [2]float64{cl.X, cl.Y} } // Cables — arrows with startBinding/endBinding to the port / device / // IO marker excalidraw_ids. Endpoint anchors (the visible "from" / // "to" points) come from the same anchor logic the canvas uses. for _, c := range snap.Cables { elID := idFor(c.ExcalidrawID) a.Cables[c.ID] = elID fromAnchor, fromRef := exportAnchor(c.FromPortID, c.FromDeviceID, c.FromIOID, snap, deviceElID, portElID, ioElID) toAnchor, toRef := exportAnchor(c.ToPortID, c.ToDeviceID, c.ToIOID, snap, deviceElID, portElID, ioElID) // fromRef/toRef are nil when the endpoint row vanished (manual // cable referencing a deleted port, say). Skip rather than emit // a half-bound arrow. if fromRef == nil || toRef == nil { continue } color := cableTypeColor[c.TypeID] if color == "" { color = "#1e1e1e" } startArr := "" endArr := "arrow" // Excalidraw arrow `points` is relative to (X, Y). We anchor at // the from-point, so vertex 0 is always (0, 0). Mid-vertices // (clamps) and the final to-vertex are offsets from there. pts := [][2]float64{{0, 0}} for _, cc := range clampsByCable[c.ID] { pos, ok := clampPos[cc.ClampID] if !ok { continue } pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]}) } pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}) els = append(els, Element{ ID: elID, Type: "arrow", X: fromAnchor[0], Y: fromAnchor[1], Width: toAnchor[0] - fromAnchor[0], Height: toAnchor[1] - fromAnchor[1], StrokeColor: color, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 2, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, Points: pts, StartArrowhead: &startArr, EndArrowhead: &endArr, StartBinding: bindingPtr(fromRef), EndBinding: bindingPtr(toRef), }) } // Legend in the top-left of the first frame (or at 20,20 if there // are no frames). One text row per cable_type, stacked vertically. legendX, legendY := 20.0, 20.0 if len(snap.Frames) > 0 { legendX = snap.Frames[0].X + 10 legendY = snap.Frames[0].Y + 10 } for i, t := range snap.CableTypes { els = append(els, Element{ ID: genID(), Type: "text", X: legendX, Y: legendY + float64(i*18), Width: 80, Height: 16, StrokeColor: t.Color, BackgroundColor: "transparent", FillStyle: "solid", StrokeWidth: 1, StrokeStyle: "solid", Roughness: 0, Opacity: 100, GroupIDs: []string{}, Seed: randInt(), Version: 1, VersionNonce: randInt(), Updated: nowMilli, Text: t.Name, OriginalText: t.Name, FontSize: 16, FontFamily: 1, TextAlign: "left", VerticalAlign: "top", LineHeight: 1.25, }) } scene := &Scene{ Type: "excalidraw", Version: 2, Source: "cablegui", Elements: els, AppState: AppState{ GridSize: nil, ViewBackground: "#ffffff", }, Files: Files{}, } return scene, a } func bindingPtr(b *Binding) *Binding { if b == nil { return nil } return b } // exportAnchor returns (x,y) + a Binding for the endpoint kind passed in. func exportAnchor(portID, deviceID, ioID *int64, snap *db.Snapshot, devElID, portElID, ioElID map[int64]string, ) ([2]float64, *Binding) { if portID != nil { // Find the port + its parent device. for _, p := range snap.Ports { if p.ID != *portID { continue } for _, d := range snap.Devices { if d.ID == p.DeviceID { id := portElID[p.ID] return [2]float64{d.X + p.XOffset, d.Y + p.YOffset}, &Binding{ElementID: id, Focus: 0, Gap: 1} } } } } if deviceID != nil { for _, d := range snap.Devices { if d.ID != *deviceID { continue } id := devElID[d.ID] return [2]float64{d.X + d.Width/2, d.Y + d.Height/2}, &Binding{ElementID: id, Focus: 0, Gap: 1} } } if ioID != nil { for _, m := range snap.IOMarkers { if m.ID != *ioID { continue } id := ioElID[m.ID] return [2]float64{m.X + 15, m.Y + 15}, &Binding{ElementID: id, Focus: 0, Gap: 1} } } return [2]float64{}, nil } // Generate21 mints a 21-char base62 identifier, the shape Excalidraw // uses for element ids (nanoid-style). crypto/rand source. func Generate21() string { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" buf := make([]byte, 21) max := big.NewInt(int64(len(alphabet))) for i := range buf { n, err := rand.Int(rand.Reader, max) if err != nil { // crypto/rand failure is unrecoverable in practice; fall back // to a deterministic alphabet position so callers see a panic- // adjacent symptom rather than a half-initialised id. return fmt.Sprintf("crypto-rand-failed-%d", i) } buf[i] = alphabet[n.Int64()] } return string(buf) } // randInt returns a non-negative int64 derived from crypto/rand for // Excalidraw's `seed` / `versionNonce`. Excalidraw treats these as // noise — only the IDs and the structural fields matter. func randInt() int64 { n, err := rand.Int(rand.Reader, big.NewInt(1<<62)) if err != nil { return 0 } return n.Int64() } // MarshalScene returns the scene as Excalidraw-flavoured JSON. func MarshalScene(s *Scene) ([]byte, error) { return json.Marshal(s) }