diff --git a/internal/db/excalidraw_ids.go b/internal/db/excalidraw_ids.go new file mode 100644 index 0000000..b970906 --- /dev/null +++ b/internal/db/excalidraw_ids.go @@ -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 +} diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go new file mode 100644 index 0000000..80f4199 --- /dev/null +++ b/internal/exporter/exporter.go @@ -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) +} diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go new file mode 100644 index 0000000..342c2df --- /dev/null +++ b/internal/exporter/exporter_test.go @@ -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) + } +} diff --git a/internal/server/export.go b/internal/server/export.go new file mode 100644 index 0000000..6d2823d --- /dev/null +++ b/internal/server/export.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index ea41ffa..b89f4c8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -90,6 +90,9 @@ func New(store *db.Store, frontend fs.FS) http.Handler { mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates) 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. // Wrap in noCache so the browser revalidates with the ETag/Last-Modified // the file server already emits — without this, browsers cache aggressively diff --git a/web/static/index.html b/web/static/index.html index 3ab32ff..db62865 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -22,9 +22,8 @@
- + +
diff --git a/web/static/main.js b/web/static/main.js index fbd4764..353c796 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -128,6 +128,7 @@ const solveProject = (pid, preview) => api("POST", `/projects/${pid}/solve${pre const portsAndResolve = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports-and-resolve`, body); const listSetupTemplates = () => api("GET", `/setup-templates`); const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body); +const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {}); // ---------- DOM helpers ---------- // @@ -2112,6 +2113,40 @@ function renderTemplatePreview(preview, templateIDStr) { `; } +// ---------- export flow ---------- // + +let toastTimer = null; + +function showToast(kind, html, holdMs = 5000) { + const t = $("#toast"); + t.className = "toast " + (kind || ""); + t.innerHTML = html; + setHidden(t, false); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { setHidden(t, true); t.innerHTML = ""; }, holdMs); +} + +async function exportCurrentProject() { + if (!state.active) { alert("Pick a project first"); return; } + const btn = $("#btn-export"); + btn.disabled = true; + showToast("", "Exporting…", 30000); + try { + const res = await syncExport(state.active.id); + const url = res.url ?? "(no url)"; + const count = res.element_count ?? 0; + showToast("ok", + `Exported ${count} elements → ${escapeHtml(url)}`, + 8000); + } catch (e) { + // Surface mxdrw unreachability or the upstream error verbatim. + const detail = typeof e.details === "object" ? JSON.stringify(e.details) : (e.details ?? ""); + showToast("error", `Export failed: ${escapeHtml(e.message)}${detail ? ` (${escapeHtml(String(detail))})` : ""}`, 12000); + } finally { + btn.disabled = false; + } +} + // ---------- boot ---------- // async function boot() { @@ -2133,6 +2168,7 @@ async function boot() { }); $("#btn-solve").addEventListener("click", openSolveModal); $("#btn-apply-template").addEventListener("click", openApplyTemplateModal); + $("#btn-export").addEventListener("click", exportCurrentProject); $("#project-select").addEventListener("change", (e) => { const v = /** @type {HTMLSelectElement} */ (e.target).value; diff --git a/web/static/style.css b/web/static/style.css index 54c7501..85af292 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -236,6 +236,24 @@ body { filter: drop-shadow(0 0 4px var(--accent)); } +/* Header toast — slice 8 export feedback */ +.toast { + display: inline-block; + margin-left: 12px; + font-size: 13px; + padding: 4px 10px; + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text); + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.toast.ok { background: #e8f5e9; color: #1b5e20; } +.toast.error { background: #fdecea; color: #911313; } +.toast a { color: inherit; text-decoration: underline; } + /* IO markers — diamonds. Power-by-convention, so the default fill is the Power cable_type colour (#e03131). Rotated 45° rect is the easiest way to draw a diamond that still hit-tests at the rotated