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:
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("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
|
||||
|
||||
Reference in New Issue
Block a user