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:
mAi
2026-05-16 01:35:46 +02:00
parent a81dbe2f8c
commit 275cb5a55a
5 changed files with 912 additions and 0 deletions

121
internal/server/export.go Normal file
View 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

View File

@@ -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