Excalidraw scene now mirrors the v5 routing model: - Clamps export as 12×12 grey rounded squares (BackgroundColor=#888888, StrokeColor=#555555, Roundness type 3). Distinct from the red IO marker diamonds so wall outlets vs. routing anchors stay readable. Frame_id propagates into the element's FrameID per the existing pattern. - Cable arrows include clamp positions as mid-vertices in the `points` array. Pre-grouped + sort.Slice-sorted by ord; each mid-vertex is added as an (x-fromAnchor.x, y-fromAnchor.y) offset. startBinding / endBinding still point at the from / to endpoint excalidraw_ids; mid-vertices are unbound (Excalidraw doesn't have per-vertex binding). - IDAssignment grows a Clamps map; PersistExcalidrawIDs accepts it and updates clamps.excalidraw_id on first export so re-exports reuse the same element ids (collab cursors / undo history survive). - Bundle-stripe overlay is **viewer-only** — Excalidraw can't represent gradient strokes losslessly, so we export individual cable arrows and let the in-app viewer derive the bundle viz. Tests: - TestBuildScene_ClampsRenderAsRectangles — 2 clamps → 2 rectangle elements + 2 ids in IDAssignment.Clamps. - TestBuildScene_ArrowPointsIncludeClamps — cable with 1 clamp → arrow.Points has 3 entries; middle vertex equals the clamp's position relative to fromAnchor. This closes the v5 slice plan (§11.10). Six slices, one branch, one redeploy below.
123 lines
3.4 KiB
Go
123 lines
3.4 KiB
Go
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"
|
|
}
|
|
user := os.Getenv("MEXDRAW_USER")
|
|
pass := os.Getenv("MEXDRAW_PASS")
|
|
if user == "" || pass == "" {
|
|
writeJSON(w, http.StatusBadRequest, errorBody{
|
|
Error: "MEXDRAW_USER / MEXDRAW_PASS not set",
|
|
Details: "Add MEXDRAW_USER and MEXDRAW_PASS to /home/m/secrets/mcables/.env on mDock and restart the container — mxdrw expects HTTP Basic Auth",
|
|
})
|
|
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, ids.Clamps); 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.SetBasicAuth(user, pass)
|
|
|
|
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
|