Files
CableGUI/internal/exporter/exporter.go
mAi c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
Full project rename per m's call. Single atomic commit because the
codebase rename is a coupled change — go module path, env vars, DB
default, Docker artefact names, and on-disk mDock paths all flip
together.

- go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui
- cmd/mcables → cmd/cablegui (git mv)
- All Go imports rewritten to the new module path
- Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB
- DB default path: data/mcables.db → data/cablegui.db
- Dockerfile + docker-compose.yml: image, container_name, env vars,
  bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui,
  secrets /home/m/secrets/mcables → /home/m/secrets/cablegui
- Makefile: bin target + run/build commands point at cmd/cablegui
- .gitignore + .dockerignore: /mcables → /cablegui
- README, docs/design.md, CLAUDE.md: prose + paths + image name
- web/static/index.html: <title> + brand
- web/static/main.js + web/web.go: header comment
- internal/exporter: Scene.Source "mcables" → "cablegui"
- internal/server/export.go: error-detail secrets path
- internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN)

Memory group_id kept as "mcables" to preserve existing memory continuity.
Documented as historical in CLAUDE.md.

go build ./... clean; go test -race ./... green
2026-05-16 15:35:42 +02:00

631 lines
19 KiB
Go

// 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)
}