Full project rename per m's call. Single atomic commit because the codebase rename is a coupled change — go module path, env vars, DB default, Docker artefact names, and on-disk mDock paths all flip together. - go.mod: module mgit.msbls.de/m/mcables → mgit.msbls.de/m/cablegui - cmd/mcables → cmd/cablegui (git mv) - All Go imports rewritten to the new module path - Env vars: MCABLES_ADDR/MCABLES_DB → CABLEGUI_ADDR/CABLEGUI_DB - DB default path: data/mcables.db → data/cablegui.db - Dockerfile + docker-compose.yml: image, container_name, env vars, bind-mount /home/m/stacks/mcables → /home/m/stacks/cablegui, secrets /home/m/secrets/mcables → /home/m/secrets/cablegui - Makefile: bin target + run/build commands point at cmd/cablegui - .gitignore + .dockerignore: /mcables → /cablegui - README, docs/design.md, CLAUDE.md: prose + paths + image name - web/static/index.html: <title> + brand - web/static/main.js + web/web.go: header comment - internal/exporter: Scene.Source "mcables" → "cablegui" - internal/server/export.go: error-detail secrets path - internal/db/migrations/*.sql: header comments (mCables vN → CableGUI vN) Memory group_id kept as "mcables" to preserve existing memory continuity. Documented as historical in CLAUDE.md. go build ./... clean; go test -race ./... green
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/cablegui/internal/db"
|
|
"mgit.msbls.de/m/cablegui/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/cablegui/.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
|