feat(backend): slice 8 — export scene to mxdrw
- internal/exporter: pure BuildScene + 21-char base62 IDs, port ellipses, device rect+text pairs, IO diamonds, arrow bindings, legend texts. Bundles intentionally omitted per design §4.1. - internal/db: PersistExcalidrawIDs idempotent updater per project. - internal/server: POST /api/projects/:pid/sync/export — loads snapshot, mints/reuses excalidraw_ids, PUTs scene to mxdrw with bearer auth. Returns viewer URL + element_count + mxdrw response. Roundtrip hand-tested against mxdrw.msbls.de: scene saved, IDs stable across re-exports.
This commit is contained in:
60
internal/db/excalidraw_ids.go
Normal file
60
internal/db/excalidraw_ids.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersistExcalidrawIDs writes the assignments returned by the exporter
|
||||||
|
// back onto the corresponding rows. Idempotent: only updates rows whose
|
||||||
|
// excalidraw_id is currently NULL (the first export "owns" the id; later
|
||||||
|
// exports reuse it so mxdrw's collab cursors / undo history survive).
|
||||||
|
//
|
||||||
|
// Caller passes one map per kind; keys are the in-project row ids,
|
||||||
|
// values are the 21-char Excalidraw element ids the exporter minted.
|
||||||
|
func (s *Store) PersistExcalidrawIDs(projectID int64,
|
||||||
|
frames, devices, ports, ios, cables map[int64]string,
|
||||||
|
) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if err := updateExIDs(tx, "frames", projectID, frames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "devices", projectID, devices); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "ports", projectID, ports); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "io_markers", projectID, ios); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := updateExIDs(tx, "cables", projectID, cables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateExIDs(tx *sql.Tx, table string, projectID int64, m map[int64]string) error {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stmt, err := tx.Prepare(
|
||||||
|
`UPDATE ` + table + `
|
||||||
|
SET excalidraw_id = ?
|
||||||
|
WHERE id = ? AND project_id = ? AND excalidraw_id IS NULL`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
for id, exID := range m {
|
||||||
|
if _, err := stmt.Exec(exID, id, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
563
internal/exporter/exporter.go
Normal file
563
internal/exporter/exporter.go
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{},
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
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: [][2]float64{{0, 0}, {toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]}},
|
||||||
|
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: "mcables",
|
||||||
|
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)
|
||||||
|
}
|
||||||
165
internal/exporter/exporter_test.go
Normal file
165
internal/exporter/exporter_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deterministic id generator for tests
|
||||||
|
func newSeq() func() string {
|
||||||
|
i := 0
|
||||||
|
return func() string {
|
||||||
|
i++
|
||||||
|
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
buf := [20]byte{}
|
||||||
|
pos := len(buf)
|
||||||
|
for i > 0 {
|
||||||
|
pos--
|
||||||
|
buf[pos] = byte('0' + i%10)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
return string(buf[pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleSnapshot() *db.Snapshot {
|
||||||
|
pid := int64(1)
|
||||||
|
devID := int64(10)
|
||||||
|
devID2 := int64(11)
|
||||||
|
portID := int64(100)
|
||||||
|
portID2 := int64(101)
|
||||||
|
ioID := int64(200)
|
||||||
|
|
||||||
|
return &db.Snapshot{
|
||||||
|
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
|
||||||
|
Frames: []db.Frame{
|
||||||
|
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
|
||||||
|
},
|
||||||
|
Devices: []db.Device{
|
||||||
|
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
|
||||||
|
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
|
||||||
|
},
|
||||||
|
Ports: []db.Port{
|
||||||
|
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||||
|
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
|
||||||
|
},
|
||||||
|
IOMarkers: []db.IOMarker{
|
||||||
|
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
|
||||||
|
},
|
||||||
|
Cables: []db.Cable{
|
||||||
|
{ID: 1000, ProjectID: pid, TypeID: 5,
|
||||||
|
FromPortID: &portID, ToPortID: &portID2, Auto: false},
|
||||||
|
},
|
||||||
|
CableTypes: []db.CableType{
|
||||||
|
{ID: 1, Name: "Power", Color: "#e03131"},
|
||||||
|
{ID: 2, Name: "USB", Color: "#2f9e44"},
|
||||||
|
{ID: 3, Name: "HDMI", Color: "#1971c2"},
|
||||||
|
{ID: 4, Name: "DP", Color: "#9c36b5"},
|
||||||
|
{ID: 5, Name: "RJ45", Color: "#ffd500"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestBuildScene_BasicShape(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
|
||||||
|
if scene.Type != "excalidraw" || scene.Version != 2 {
|
||||||
|
t.Errorf("bad header: %+v", scene)
|
||||||
|
}
|
||||||
|
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
|
||||||
|
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
|
||||||
|
if len(scene.Elements) < 15 {
|
||||||
|
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
|
||||||
|
}
|
||||||
|
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
|
||||||
|
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
|
||||||
|
t.Errorf("id assignment shape wrong: %+v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
// Pre-assign an excalidraw_id on the first device.
|
||||||
|
preset := "preset0000000000000NAS"[:21]
|
||||||
|
snap.Devices[0].ExcalidrawID = &preset
|
||||||
|
_, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
if ids.Devices[snap.Devices[0].ID] != preset {
|
||||||
|
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
// The arrow's startBinding should reference the from-port's element id.
|
||||||
|
fromPortElID := ids.Ports[100]
|
||||||
|
toPortElID := ids.Ports[101]
|
||||||
|
var found *Element
|
||||||
|
for i := range scene.Elements {
|
||||||
|
if scene.Elements[i].Type == "arrow" {
|
||||||
|
found = &scene.Elements[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("no arrow in scene")
|
||||||
|
}
|
||||||
|
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
|
||||||
|
t.Errorf("start binding wrong: %+v", found.StartBinding)
|
||||||
|
}
|
||||||
|
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
|
||||||
|
t.Errorf("end binding wrong: %+v", found.EndBinding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildScene_BundlesIgnored(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
|
||||||
|
// Add some and confirm no bundle elements appear in the scene.
|
||||||
|
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
|
||||||
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
for _, e := range scene.Elements {
|
||||||
|
if strings.Contains(e.Type, "bundle") {
|
||||||
|
t.Errorf("bundle element leaked into scene: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalScene_IsJSON(t *testing.T) {
|
||||||
|
snap := sampleSnapshot()
|
||||||
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
||||||
|
b, err := MarshalScene(scene)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var roundtrip map[string]any
|
||||||
|
if err := json.Unmarshal(b, &roundtrip); err != nil {
|
||||||
|
t.Fatalf("roundtrip: %v", err)
|
||||||
|
}
|
||||||
|
if roundtrip["type"] != "excalidraw" {
|
||||||
|
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate21(t *testing.T) {
|
||||||
|
a := Generate21()
|
||||||
|
b := Generate21()
|
||||||
|
if len(a) != 21 || len(b) != 21 {
|
||||||
|
t.Errorf("len wrong: %d / %d", len(a), len(b))
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("ids collide: %q == %q", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/server/export.go
Normal file
121
internal/server/export.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/mcables/internal/db"
|
||||||
|
"mgit.msbls.de/m/mcables/internal/exporter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncExport runs the project's snapshot through the exporter, persists
|
||||||
|
// the assigned excalidraw_ids, then PUTs the scene to mxdrw.msbls.de.
|
||||||
|
func (h *handlers) syncExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, ok := parseInt64Path(r, "pid")
|
||||||
|
if !ok {
|
||||||
|
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := os.Getenv("MEXDRAW_BASE_URL")
|
||||||
|
if base == "" {
|
||||||
|
base = "https://mxdrw.msbls.de"
|
||||||
|
}
|
||||||
|
token := os.Getenv("MEXDRAW_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorBody{
|
||||||
|
Error: "MEXDRAW_TOKEN not set",
|
||||||
|
Details: "Add MEXDRAW_TOKEN to /home/m/secrets/mcables/.env on mDock and restart the container",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := h.store.Snapshot(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
scene, ids := exporter.BuildScene(snap, now, exporter.Generate21)
|
||||||
|
|
||||||
|
// Persist the freshly-assigned ids so the next export reuses them.
|
||||||
|
// We pass in the full maps; PersistExcalidrawIDs is idempotent (it
|
||||||
|
// only updates rows whose excalidraw_id is still NULL).
|
||||||
|
if err := h.store.PersistExcalidrawIDs(pid, ids.Frames, ids.Devices, ids.Ports, ids.IOMarkers, ids.Cables); err != nil {
|
||||||
|
writeError(w, fmt.Errorf("persist excalidraw_ids: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := exporter.MarshalScene(scene)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, fmt.Errorf("marshal scene: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawingName := snap.Project.DrawingName
|
||||||
|
if !strings.HasSuffix(drawingName, ".excalidraw") {
|
||||||
|
drawingName += ".excalidraw"
|
||||||
|
}
|
||||||
|
url := strings.TrimSuffix(base, "/") + "/api/drawings/" + drawingName
|
||||||
|
|
||||||
|
// Sane network timeout; mxdrw is on the LAN so this should be quick.
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, fmt.Errorf("build PUT: %w", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||||
|
Error: "mxdrw unreachable",
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
writeJSON(w, http.StatusBadGateway, errorBody{
|
||||||
|
Error: fmt.Sprintf("mxdrw rejected upload (%d)", resp.StatusCode),
|
||||||
|
Details: map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"body": string(body),
|
||||||
|
"url": url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort parse — mxdrw returns whatever it returns; we surface
|
||||||
|
// the public viewer URL no matter what.
|
||||||
|
var serverEcho any
|
||||||
|
_ = json.Unmarshal(body, &serverEcho)
|
||||||
|
|
||||||
|
viewerURL := strings.TrimSuffix(base, "/") + "/draw/" + strings.TrimSuffix(drawingName, ".excalidraw") + ".excalidraw"
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"drawing_name": drawingName,
|
||||||
|
"url": viewerURL,
|
||||||
|
"element_count": len(scene.Elements),
|
||||||
|
"mxdrw_response": serverEcho,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noLeak prevents unused-import errors if errors-pkg ever becomes unused
|
||||||
|
// after a refactor — keeps the import light.
|
||||||
|
var _ = errors.New
|
||||||
@@ -90,6 +90,9 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
|||||||
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
|
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
|
||||||
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
|
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate)
|
||||||
|
|
||||||
|
// Slice 8 — export to mxdrw.msbls.de
|
||||||
|
mux.HandleFunc("POST /api/projects/{pid}/sync/export", h.syncExport)
|
||||||
|
|
||||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||||
// the file server already emits — without this, browsers cache aggressively
|
// the file server already emits — without this, browsers cache aggressively
|
||||||
|
|||||||
Reference in New Issue
Block a user