Compare commits

...

59 Commits

Author SHA1 Message Date
mAi
7331f334a8 merge: frame resize handle (bottom-right corner)
Mirrors picasso's device-resize pattern. 10x10 handle at frame's
bottom-right; pointerdown→drag→PATCH width+height on release. Min
200x150. Doesn't interfere with frame-body drag or label-as-grip
selection.
2026-05-17 17:21:52 +02:00
mAi
1c234f3f46 feat(ui): bottom-right resize handle on frames
m: 'We should also be able to resize frames, the same way we do with
devices.' Mirrors the device-resize pattern (89686d0).

- 10×10 SVG handle drawn at each frame's bottom-right corner with class
  .frame-resize-handle + cursor: nwse-resize. Appended after the label
  so it sits on top of the rect and wins the pointerdown.
- startFrameResize captures the pointer, stops propagation so the
  rect's pointerdown (= startDrag 'frame') doesn't also fire, and
  updates f.width / f.height on every pointermove using svgPoint
  deltas — works at any zoom level via the same world-coord conversion
  the rest of the canvas uses.
- Clamps to 200×150 minimum during the drag (frames need more room
  than devices since they host devices + IO markers + clamps).
- On pointerup: PATCH /api/projects/:pid/frames/:id with the new width
  + height. Contained children stay at their absolute positions — the
  frame body drag is what moves them; resize only changes the frame's
  own bounds, so devices/IO markers/clamps inside don't shift.
2026-05-17 17:19:53 +02:00
mAi
cff897978f merge: frame label = clickable drag grip
Drop pointer-events:none on .frame-label and add a pointerdown that
fires startDrag(e,'frame',f.id) — gives m a deterministic select-and-
drag grip at the top-left of every frame where devices/cables can't
occlude it.
2026-05-16 19:33:26 +02:00
mAi
55f8a06560 fix(ui): frame label is a clickable drag grip
Frame rect interior is occluded by devices/cables in SVG render order, so
clicking the frame to select/drag it was unreliable. Drop pointer-events:none
from .frame-label and bind the same pointerdown→startDrag('frame',id) as the
rect — the top-left label text is now a deterministic grip.
2026-05-16 19:32:14 +02:00
mAi
79e17a5cb1 merge: rename mCables → CableGUI (full)
Gitea repo: m/mCables → m/CableGUI
Docker image: m/mcables → m/cablegui
mDock paths: /home/m/stacks/{mcables→cablegui}/ + /home/m/secrets/{mcables→cablegui}/
DB filename: data/{mcables.db → cablegui.db}
Go module + env vars (MCABLES_* → CABLEGUI_*) renamed throughout.
LOFT project survived the DB filename move.
2026-05-16 15:39:16 +02:00
mAi
c206a331ec rename: mCables → CableGUI (project + repo + image + paths)
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
2026-05-16 15:35:42 +02:00
mAi
2b4c574508 merge: left-click-drag on empty canvas pans the view 2026-05-16 14:05:56 +02:00
mAi
2933bb8662 fix(ui): left-click-drag on empty canvas pans the view
Canvas zoom shipped pan as middle-drag / Space+drag, which left m unable
to reach a freshly-created frame outside the default viewport — the
only escape was middle-button or holding Space, neither of which is
discoverable.

Empty-canvas left-pointerdown now starts an ambiguous gesture: if the
cursor moves past a 3px screen-space threshold it promotes to a pan
(Excalidraw / Figma standard); below the threshold it falls back to
the historic "click empties the selection" UX so plain clicks still
deselect. Pointerdown on a device, frame, IO marker, port, or cable
keeps routing to its own handler. Middle-drag and Space+drag pan
unchanged.
2026-05-16 14:05:46 +02:00
mAi
98fe040364 merge: v5 — cable routing via clamps (all 6 slices)
picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
  UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
  two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
  clamps + clamp inspector with cables-through list and
  cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
  nearest segment; pointerup snaps to existing clamp within
  MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
  min(12,2+N), gradient stripes by count desc / id asc).
  ×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
  cable arrows carry mid-vertices through clamps; bundle viz stays
  viewer-only (Excalidraw can't represent gradient strokes).
2026-05-16 14:04:37 +02:00
mAi
813d59b068 feat(v5 slice 6): export clamps + cable mid-vertices to mxdrw
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.
2026-05-16 13:58:32 +02:00
mAi
2cbefd3146 feat(v5 slice 5): shared-segment bundle viz + clamp count badges
Walks every cable's polyline, keys each vertex by stable identity
(port:N / device:N / io:N / clamp:N), and accumulates cables by
undirected segment-key. Segments with ≥ 2 cables get a thick striped
overlay line in a new <g id="canvas-bundles"> layer, drawn on top of
the individual cable lines so the shared portion reads as a bundle
while endpoints still fan out to each cable's port colour.

- Stripe width: 2 + N px, capped at 12 (design v5 §11.3).
- Stripe order: by distinct cable-type count (ties by id) per
  v5 §11.9 q4.
- Implementation: SVG <linearGradient> with hard stops oriented
  perpendicular to the segment, registered in a new
  <defs id="canvas-defs"> on every render. Bundle <line> uses
  stroke="url(#bundle-grad-…)".
- <title> child lists the cable types and total cable count for
  hover tooltips.
- Clamp render gains a ×N badge when ≥ 2 cables route through it,
  derived independently from state.cableClamps.

Helper rename: cableVertices → cableVerticesWithKeys (returns
{vertices, keys}). The keys array also feeds the shared-segment
detection — keeps the geometry + identity tracking in one pass.
2026-05-16 13:54:57 +02:00
mAi
a1de1246e5 merge: remove '+ Type' button from sidebar legend
Per m: cable-type creation lives in the admin modal; the sidebar
button was prominent for a rare action.
2026-05-16 13:52:08 +02:00
mAi
fee9bc5d26 feat(ui): remove '+ Type' button from sidebar legend
Cable type creation is managed via the admin modal (⚙ → Cable types
tab), which makes the prominent sidebar affordance unnecessary. Drop
the button element and its click handler; the legend itself (rows,
edit button per row, active-type selection) is unchanged.
2026-05-16 13:50:49 +02:00
mAi
04e7e86a52 feat(v5 slice 4): cable polyline through clamps + mid-segment drag
Cables now render as <polyline> through their cable_clamps in `ord`
sequence. Empty clamp set collapses to a straight from→to line, so
nothing visual changes for unrouted (auto-emitted) cables.

cableVertices(cable, …) resolves the endpoint anchors + each clamp's
(x, y) into the vertex array. Endpoint-replug handles continue to
operate on the first/last vertex.

Mid-segment drag — startCableMidDrag:
- Triggered by pointerdown on a *selected* cable's polyline (button=0,
  not on an endpoint handle, no Space pan).
- nearestSegmentIndex + pointSegmentDistance pick which segment m is
  bending. The dragged vertex is rendered as a temp inserted point in
  the cable's polyline via a module-level cableMidDrag preview.
- On release: snap to the nearest existing clamp within
  MID_SNAP_PX / zoom (visual constant per design v5 §11.9 q2), else
  POST a fresh clamp at the drop point. Either way, attach to the
  cable at ord = segIdx + 1 so the new vertex sits inside the segment
  m was bending. A tiny-motion (< 4 world-units) drop is treated as
  a plain click-to-select and cancelled.

Snapping to a clamp already on the cable is a no-op (UNIQUE constraint
would 409). Re-fetches cable_clamps from the snapshot after each
attach so ord shifts from the slice-1 attach helper propagate.
2026-05-16 13:50:44 +02:00
mAi
6af076a5e0 feat(v5 slice 3): clamp render + Place tool + inspector
Frontend hooks for the v5 routing primitive.

- state gains clamps + cableClamps arrays, hydrated from the snapshot
  (`clamps`, `cable_clamps`). Reset on null-project + project-404 paths.
- API helpers: createClamp / patchClamp / deleteClamp + attach / detach /
  reorder cable_clamps.
- +Clamp tool button + "C" keyboard shortcut. armTool flips the
  tool-clamp class on .canvas-wrap (crosshair cursor).
- onCanvasPointerDown routes tool === "clamp" to placeClampAt, which
  POSTs a clamp at the click position. If the click target is on a
  cable, the new clamp is also attached to that cable in one go.
- renderCanvas paints clamps as 12×12 rounded squares (per design v5
  §11.9 q1) in a new #canvas-clamps <g>. Drag uses the existing
  startDrag pipeline (kind="clamp"), which now also moves clamps when
  their containing frame is dragged.
- renderInspectorClamp shows label + position + cables-through list +
  Delete (with cascade confirm when shared).

Slice 4 wires the clamp into a cable's polyline (mid-segment drag,
visual routing); for now placing a clamp on top of a cable just
attaches it.
2026-05-16 13:48:07 +02:00
mAi
ae59dfc894 feat(v5 slice 2): clamp HTTP endpoints
Wire the v5 store helpers from slice 1 onto net/http routes:

  GET    /api/projects/:pid/clamps
  POST   /api/projects/:pid/clamps
  PATCH  /api/projects/:pid/clamps/:id
  DELETE /api/projects/:pid/clamps/:id

  POST   /api/projects/:pid/cables/:cid/clamps          — attach
  PUT    /api/projects/:pid/cables/:cid/clamps          — reorder
  DELETE /api/projects/:pid/cables/:cid/clamps/:cmid    — detach

frame_id uses the same json.RawMessage tri-state as device/io patches
(absent / null / int) via the existing parseFrameRef helper.

Snapshot endpoint (GET /api/projects/:id) now carries the clamps[] +
cable_clamps[] arrays surfaced by ListClamps + ListCableClamps in
slice 1, so the frontend gets everything in one round-trip.
2026-05-16 13:42:23 +02:00
mAi
4202d0465f feat(v5 slice 1): clamps schema + store helpers + snapshot
Migration 007 introduces the v5 routing primitive:
- clamps table (project-scoped, optional frame_id, excalidraw_id).
- cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord)
  and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same
  cable twice.

Store helpers in internal/db/clamps.go:
- CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp —
  standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state.
- AttachClampToCable — appends or inserts at a given ord. Mid-sequence
  inserts use a two-pass shift (bump by 10000, settle to ord+1) since
  SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would
  collide with the UNIQUE (cable_id, ord) PK.
- DetachClampFromCable — removes the row then closes the gap.
- ReorderCableClamps — replaces the whole sequence in one tx.
- ListClampsForCable / ListCableClamps — read helpers.

Snapshot now carries clamps + cable_clamps arrays so the frontend can
hydrate everything in one call.

Tests cover create / update / cascade-delete / attach (append + insert
+ duplicate-rejected) / detach (gap closes) / reorder / snapshot.
2026-05-16 13:40:53 +02:00
mAi
8df5de193a merge: fix overbroad gitignore matching cmd/mcables/
Bare 'mcables' pattern in .gitignore + .dockerignore matched cmd/mcables/
in addition to the built binary at repo root. Root-anchored to '/mcables'.
cmd/mcables/main.go now tracked in git. Fresh worktrees / clones build
clean without copying main.go from a sibling.
2026-05-16 13:39:16 +02:00
mAi
a675c499c3 fix: root-anchor mcables ignore pattern, commit cmd/mcables/main.go
The bare `mcables` pattern in .gitignore (line 11) and .dockerignore
(line 18) was intended to ignore the built binary at the repo root, but
without a leading slash it also matched the cmd/mcables/ directory. The
result: cmd/mcables/main.go was never tracked in git, and fresh worktrees
had to copy it from a sibling to build.

- Change `mcables` → `/mcables` in both files (still ignores the root
  binary; no longer matches the cmd subdirectory).
- Add cmd/mcables/main.go (copied from picasso's worktree, verified
  identical to head's main checkout).

Verified: `git check-ignore cmd/mcables/main.go` returns not-ignored;
a touched `./mcables` at the repo root is still ignored via `/mcables`.
`go build ./...` clean.
2026-05-16 13:38:52 +02:00
mAi
78bce498b4 merge: design v5 — cable routing via clamps (§11)
Schema (clamps + cable_clamps join), polyline-through-clamps rendering,
bundle = derived from shared-segment overlap (no detection algorithm),
clamp tool + drag-cable-midpoint-to-snap-through-clamp UX, export
maps to Excalidraw arrow mid-points.
2026-05-16 13:35:14 +02:00
mAi
359ed892ac merge: double-click port → start cable draw (dali's variant)
Adds armTool('cable') so the cursor shows crosshair during the
in-progress draw — matches m's literal 'cursor crosshair' request.

(Picasso shipped a similar fix in parallel due to a head dispatch
race; dropping picasso's variant in favour of this one.)
2026-05-16 13:29:58 +02:00
mAi
0ecd9c8b4a feat(ui): double-click a port to start a cable draw
Double-click a port → enter cable-draw mode from that port without
having to arm the cable tool first. armTool("cable") is called so
the crosshair cursor is active during the draw; the next port-click
hits the existing cable-draw-in-progress branch in onPortPointerDown
and commits the cable. Esc / clicking the source port cancels.

Single-click behaviour (select + open port inspector) is unchanged
because pointerdown still hits onPortPointerDown first; dblclick
upgrades the selection to a cable-draw source.
2026-05-16 13:29:02 +02:00
mAi
fca9fb0a0f design(v5): cable routing via clamps — §11
m's bundling primitive: a clamp is a physical anchor on the canvas;
cables route through clamps in order; cables that share a consecutive
clamp pair are visibly bundled on that segment. Overlap is the bundle —
no detection pass.

Section covers:
- 11.1 Schema: clamps table + cable_clamps join, migration 007. Clamps
  carry frame_id so frame-drag carries them.
- 11.2 Cable rendering: <polyline> through [from, clamp₁..n, to];
  endpoint-replug handles stay on first/last vertices.
- 11.3 Bundle visualisation: shared segments rendered as a 2+N px
  striped line; clamp icon shows ×N count when shared. Computed live
  on every renderCanvas — O(C·N̄), trivial at v0 scale.
- 11.4 UI: +Clamp tool (C shortcut), mid-segment drag-to-snap (snap
  radius ~16 px / zoom), clamp inspector, right-click remove-from-cable.
- 11.5 Existing bundles table: keep, repurpose. Implicit bundles are
  derived from shared clamp segments; explicit named bundles still live
  in the table.
- 11.6 Solver coupling: v0 solver still emits straight cables; m
  hand-routes after. v5.1 future work for solver-suggested clamps.
- 11.7 Export: clamps export as small grey diamonds; cable arrows use
  Excalidraw's points array for mid-vertices. Bundle stripes are
  viewer-only (Excalidraw can't represent them losslessly).
- 11.8 API additions: clamp CRUD, attach/detach/reorder cable clamps.
  Snapshot grows clamps + cable_clamps arrays.
- 11.9 Five open questions for m (icon shape, snap radius scaling,
  cascade-on-delete confirm, stripe order, solver respect for manual
  clamp routing).
- 11.10 6-step slice plan post-approval.

DESIGN v5 READY FOR REVIEW
2026-05-16 13:19:55 +02:00
mAi
40ab3d2630 merge: drag-to-replug cable endpoints
Selected cable shows two endpoint handles (r=7, coloured + halo).
pointerdown on a handle starts an endpoint drag; hitTestEndpointTarget
resolves cursor over port / device / IO marker; pointerup PATCHes the
from_/to_ field. Cancel on empty canvas or same-endpoint drop.
auto=1 cables auto-promote to auto=0 when m successfully drops on a
new valid endpoint.
2026-05-16 13:17:25 +02:00
mAi
17e6b5e91c feat(ui): cable endpoint replug — drag handles to a new target
m can grab either end of a selected cable and drop it on a different
port / device / IO marker. Mechanics:

- Selected cable renders two .cable-handle circles at its endpoints
  (handle radius 7, filled in the cable's colour with a white halo +
  drop-shadow). Hidden unless the cable is selected so unrelated cables
  don't litter the canvas with grab points.
- pointerdown on a handle calls startCableReplug; the module-level
  cableReplug = {cableID, end, x, y} drives renderCanvas to anchor the
  affected endpoint at the cursor in world coords. Pointermove keeps
  the line tracking; pointerup hit-tests the cursor via
  elementsFromPoint (skipping the cable-handle itself).
- Drop target:
    port   → PATCH {from|to: {port_id}}
    device → PATCH {from|to: {device_id}}
    IO     → PATCH {from|to: {io_id}}
    empty / same endpoint → cancel (no PATCH)
- When the cable was auto=1 and the drop commits, the PATCH also sends
  promote=true so the server flips it to manual — m took control.
- preventDefault + stopPropagation on the handle pointerdown so canvas
  panning / cable-line clicks don't interfere. Pointer capture survives
  the drag leaving the SVG bounds.

CSS: .cable-handle gets grab cursor + drop-shadow; .replugging on the
canvas-wrap promotes to grabbing during the gesture.
2026-05-16 13:11:33 +02:00
mAi
9107a9f7b2 merge: device resize handle (bottom-right corner)
10x10 handle on every device, drag to resize. Min 60x30. On pointerup,
PATCH width/height + relayoutAllEdges so ports re-distribute. stopPropagation
keeps the body drag separate from the handle drag. Works at any zoom.
2026-05-16 13:07:31 +02:00
mAi
89686d0c1f feat(ui): bottom-right resize handle on devices
m: 'I want the size of devices to be customizable. A resize function at
the bottom right corner would be good.'

- 10×10 SVG handle drawn at each device's bottom-right corner with class
  .device-resize-handle + cursor: nwse-resize. Subtle grey by default,
  darker on hover so m can find it without it dominating the rect.
- startResize captures the pointer, stops propagation so the rect's
  pointerdown (= startDrag) doesn't also fire, and updates the local
  device.width / .height on every pointermove using svgPoint deltas —
  works at any zoom level via the same world-coord conversion the rest
  of the canvas uses.
- Clamps to 60×30 minimum during the drag so the rect can't collapse.
- On pointerup: PATCH /devices/:id with the new width + height, then
  relayoutAllEdges(deviceID) so ports on every edge redistribute to
  their i/(N+1) positions against the new dimensions. Right- and
  bottom-edge ports get the visible adjustment; top/left re-space too
  but their absolute positions don't change.
2026-05-16 12:59:51 +02:00
mAi
57a9154f18 merge: canvas zoom + pan (last of 6 polish tasks)
state.view = {x,y,zoom} drives SVG viewBox. Zoom clamped 0.2-5x.
- Wheel = zoom around cursor (Excalidraw-style)
- Middle-drag or Space+drag = pan
- 0 or Home = reset
- Header: zoom % indicator + Fit button (bbox + 40px padding)
- URL persists ?z=&px=&py= (cleaned when at default)
- All inputs/hit-tests stay in world coords — no changes needed to
  port/cable/drag handlers
2026-05-16 12:10:28 +02:00
mAi
6c31802522 feat(ui): canvas zoom + pan via SVG viewBox
m: wheel to zoom around the cursor, drag with middle-mouse / Space-held
to pan, `0` or `Home` to reset, Fit button to frame all content.

Implementation:
- state.view = { x, y, zoom } drives the SVG viewBox via applyViewBox().
  Base canvas is 2000×1500; viewBox = (view.x, view.y, 2000/zoom, 1500/zoom).
- Zoom clamped to 0.2x..5x. wheelZoom captures the cursor's world coord
  before + after the zoom-step and shifts view.x/y so it stays under
  the cursor (Excalidraw-style cursor-anchored zoom).
- startPan captures screen→world scale from getScreenCTM at pointerdown
  and converts pointer-move deltas into view.x/y updates — robust across
  zoom levels. Triggered by middle-mouse OR Space+drag. Releases pointer
  capture + persists the view on pointerup.
- resetView (0 / Home) restores zoom=1, x=0, y=0.
- fitToContent walks frames + devices + IO markers, computes their bbox
  with 40px padding, picks zoom = min(BASE_W/bw, BASE_H/bh), and centres
  the bbox inside the viewBox (compensating for aspect-ratio meet).
- Header gets a "100%" zoom indicator + Fit button. URL persists view
  as ?z=1.200&px=…&py=… so reload returns to the same view.

Because everything goes through viewBox (not CSS transform), svgPoint
still maps screen pixels to world coords via getScreenCTM. Existing
hit-tests, drag, port/cable placement all keep working unchanged.
2026-05-16 12:05:24 +02:00
mAi
46e8474c2b merge: requirements UX — per-device primary + all-view in admin
Device inspector gains a Requirements section + Requirement button
pre-filled with the current device's id. The global Requirements
section is removed from the left sidebar — legend + tools reclaim
the space. All-requirements view moves into the admin modal as a
5th tab.
2026-05-16 12:00:32 +02:00
mAi
9aa395854d feat(ui): requirements live in the device inspector + admin tab
m wants 'this device connects to ...' declared from the device itself,
not a global sidebar list.

- Device inspector gets a '+ Requirement' button under its Requirements
  section. Click pre-fills the modal with from_device_id = this device,
  so m only picks the other endpoint + cable type + must/nice.
- Existing requirement rows in the device inspector remain clickable —
  they jump to the requirement's own inspector pane.
- New 5th admin tab 'Requirements' carries the all-projects-wide list
  with Edit + Delete actions per row and a single '+ Add requirement'
  entry point (uses the same modal). Edit/Add close the admin modal
  so the requirement modal isn't stacked on top.
- Left sidebar 'Requirements' section + '+ Requirement' button removed.
  The legend + tools sections reclaim the freed real estate.

renderRequirements() and the renderRequirements call site in render()
deleted (no consumer left). #btn-add-requirement boot wiring removed.
2026-05-16 11:59:08 +02:00
mAi
f08c48e9b5 merge: admin modal — projects + cable types + device types + templates
⚙ button in header opens a tabbed modal:
- Projects: list, rename name/drawing_name/description, delete with
  typed-name confirm. patchProject API helper added.
- Cable types: global-scope banner, name + colour edit + delete
  (blocked on use) + add.
- Device types: built-ins read-only with locked badge; project-custom
  name/kind/icon/description CRUD. Port-profile reshape deferred —
  flagged in the UI.
- Setup templates: read-only with expanded member devices +
  requirements.

Modal over full page — fits the no-build vanilla-JS shape. Verified
on mDock (PATCH project rename + description round-trips).
2026-05-16 11:55:26 +02:00
mAi
6cd5925f4c feat(ui): admin modal — projects + cable types + device types + templates
Header gear ('⚙ Admin') opens a wide modal with four tabs:

- **Projects** — list, rename, edit drawing_name + description, delete
  with typed-name confirm. Wires the existing PATCH /projects/:id and
  DELETE /projects/:id?confirm=<name> endpoints; renaming was previously
  only reachable via the API.
- **Cable types** — full CRUD with the global-scope banner. Mirrors the
  legend's quick edit but in a tabular list, plus an inline "+ Add"
  form at the bottom.
- **Device types** — built-ins listed read-only with a locked badge
  showing kind, description, and port profile (each port row tinted
  with the cable_type's colour). Project-custom types under the active
  project get editable name / kind / icon / description + Delete.
  Port-profile editing on custom types is still deferred (port-profile
  reshape will land in a follow-up).
- **Setup templates** — read-only list of built-ins with member devices
  and connection requirements expanded under each.

The modal re-fetches projects / cable types / setup templates on open
so it reflects current state regardless of what m did via inspector
panes while it was closed.

Files:
- index.html: ⚙ Admin button + #modal-admin dialog scaffold.
- main.js: patchProject + createDeviceType/patchDeviceType/deleteDeviceType
  API helpers; openAdminModal + switchAdminTab + 4 render functions.
- style.css: .admin-shell / .admin-tabs / .admin-row + state classes.
2026-05-16 11:51:05 +02:00
mAi
9773063008 merge: port editor in sidebar — type + edge + name; +Port retired
Port inspector now has a Type dropdown (PATCH /ports/:id with
type_id), keeps edge picker + label input + delete + back-link.

Replaces the canvas-armed +Port tool with a sidebar 'Add port' form
(reached via +Port button in the device inspector). Form fields:
Type, Edge, Label with auto-default '<type> <next-index>' that stops
auto-updating once m hand-edits. Submit → POST → relayout edge for
even spacing → selection switches to the new port's editor.

Port rows in the device inspector's list now click-to-select.

Removed scaffolding: tool === 'port' branch, armPortTool,
placePortAt, snapToDeviceEdge, .tool-port cursor CSS.
2026-05-16 11:45:25 +02:00
mAi
61bc1dcf43 feat(ui): port editor + add-port form in the sidebar inspector
m: 'Add port' should be a sidebar form, not a two-step canvas gesture.

- Port inspector gains a Type dropdown (read /api/cable-types via
  state.cableTypes, PATCH /ports/:id with type_id). Edge picker + label
  + delete from prior shift are unchanged.
- New "Add port" form rendered from selection.kind === "port_new":
  Type / Edge / Label, Create + Cancel buttons. Default label is the
  next free index for the chosen type on this device ("HDMI 3" if two
  HDMIs already live there). Recomputes when m changes the type, but
  stops recomputing as soon as m hand-edits the label.
- +Port in the device inspector now flips selection to port_new,
  rendering the form. Submit → POST → switch to the new port's editor.
  No second canvas click required.
- Clicking a port row in the device inspector's port list selects that
  port and opens its editor (same surface as canvas-click).
- "← <device name>" back-link in both port editor and add-port form
  jumps back to the device inspector.

Removed: state.tool === "port" branch, armPortTool helper, placePortAt
function, .tool-port CSS, state.portToolDevice / portToolTypeID. The
canvas-armed +Port tool was the user-trip-wire perseus flagged; the
sidebar form replaces it entirely.

snapToDeviceEdge also removed — placePortAt was its only caller; the
edgeCentre + portEdge + relayoutEdge trio fully owns port placement
now.

Port rows in the device inspector get a hover background + pointer
cursor to read as clickable.
2026-05-16 11:40:45 +02:00
mAi
056777f1c1 merge: template-apply creates frame + grid-places devices inside
ApplyTemplate now creates a frame named after the template
('Living Room' etc, suffixed on collision), computes a uniform grid
(cols=min(ceil(sqrt(N)),4), rows=ceil(N/cols)), and places each
device inside the frame with frame_id set.

Frontend unchanged — activateProject re-hydrates the snapshot
including the new frame.

Tests cover frame creation + in-frame placement + name-collision
suffix. Verified on mDock: Living Room template → frame (200,200,
294×200) with TV/Soundbar at row 0 and ChromeCast wrapping to row 1.
2026-05-16 11:35:25 +02:00
mAi
2aff5eb04d feat(template): apply-template lands devices inside a named frame
Before: ApplyTemplate dropped devices in a horizontal row at fixed
canvas coords with frame_id NULL — devices appeared anywhere and m
had no way to express "these belong together".

Now: each apply creates a frame named after the template (suffixed
"…  2/3/…" on name collision) and lays the devices out in a uniform
grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped
at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the
new frame's id and grid-cell coords.

Schema unchanged. ApplyTemplateResult.frames_added carries the new
frame so the frontend can refresh the canvas without a full snapshot
reload.

Tests:
- TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is
  created with the template's name, every device has frame_id set,
  every device sits inside the frame rect, no two devices share a
  grid cell.
- TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing
  "Living Room" frame in the project ⇒ template's frame named
  "Living Room 2".
- Existing tests unchanged.
2026-05-16 11:30:32 +02:00
mAi
5c11bf33cb merge: port UX bundle — selection feedback + even-spacing + onUp + device colour
3 commits (491db73, b28fc0c, 86264d1):
- +Port now sets state.selection on the new port → inspector switches
  to the port panel + halo shows
- Ports relayout to even spacing along the affected edge on every
  add/delete/edge-change (no more invisible stacking)
- startDrag.onUp captures the rect in closure instead of reading
  currentTarget after pointerup (no more 'classList of null' spam)
- Device colour: dropped CSS stroke/fill hard-codes, inline style now
  paints the rect — picker actually changes the visible colour

All verified end-to-end on the deployed image.
2026-05-16 11:25:32 +02:00
mAi
86264d1284 fix(ui): device colour now actually shows on the canvas
CSS .device-rect hard-coded stroke + fill, overriding the
stroke=${d.color} SVG attribute the JS wrote. Author CSS beats
presentation attributes, so changing the device colour via the
inspector picker was invisible.

Drop the stroke/fill overrides from .device-rect; set both inline
on the rect element instead — stroke = the chosen colour, fill =
a 12% tint via color-mix so the device reads coloured without
becoming garish. Inline style beats class CSS, so the picker works.

Frames + IO markers don't currently expose a colour picker, so no
analogous fix needed there.
2026-05-16 11:23:47 +02:00
mAi
b28fc0c565 fix(ui): even-spacing relayout on every port-set change
m's stronger invariant: ports must never overlap and must line up on
their edge. Replace the slide-collision dedup with full even-spacing
re-layout — for N ports on an edge, position i goes to axis · i/(N+1)
for i=1..N.

- New portEdge(port, dev) — snaps a port's current offsets to the
  nearest of the four edges (same heuristic as snapToDeviceEdge).
- New relayoutEdge(deviceID, edge) — re-spaces every port on the
  device-edge and PATCHes the ones whose offsets actually change.
  Sort key: x_offset for top/bottom, y_offset for left/right —
  preserves m's "I dropped it roughly here" order.

Applied on:
- placePortAt — re-layout the edge after the new port is created.
- inspector edge picker — capture oldEdge, PATCH the port to the
  centre of newEdge, then re-layout BOTH old and new edges.
- port delete — re-layout the edge the deleted port was on so the
  survivors collapse back to even spacing.

snapToDeviceEdge reverted to its pre-dedup shape (drop the existingPorts
arg and resolveCollision helper); the layout invariant is owned by
relayoutEdge now. edgeOf folded into portEdge.
2026-05-16 11:19:16 +02:00
mAi
491db730eb fix(ui): +Port feedback + snap dedup + startDrag closure-capture
Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):

1. Select the freshly-placed port. placePortAt now sets
   state.selection = {kind:"port", id:port.id} before render() so the
   inspector switches to the port panel and the .selected halo makes
   the new circle visible — fixes m's "+Port does nothing" perception
   (the port WAS being created server-side; it just rendered invisibly
   stacked under an existing one and the inspector stayed on the device).

2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
   on the device; if the computed (xOff, yOff) lands within 8px of a
   peer on the same edge, slide along the edge in 16px steps until a
   free slot is found. Eliminates pixel-perfect port stacks.

3. startDrag closure-capture. onUp asynchronously referenced
   e.currentTarget after pointerup nulled it, throwing a TypeError
   in the console on every click-only device selection. Capture
   dragTarget in the outer closure and use that inside add/remove.
2026-05-16 11:12:13 +02:00
mAi
90157dfd14 merge: migration 006 — IOx-* and Multi-plug-* are power strips
m: 'IOx-8 should have 8 powerports on the front, one on the back'.
Migration 006 reshapes all 8 power-distribution types (IOx-3/6/8,
Multi-plug 3/4/5/6, Wifi-plug) into 1 Power In on top (back) +
N Power Out on bottom (front).

Existing devices keep their old ports per design §2.3 — delete +
recreate to pick up the new layout.

Verified on mDock: IOx-8 ports = [(top, Power In, 1), (bottom,
Power Out, 8)].
2026-05-16 11:08:13 +02:00
mAi
f1af2820e1 fix(catalog): migration 006 — IOx-* and Multi-plug-* are power strips
m's actual hardware: IOx-3/6/8 are power strips, not USB hubs. v4 seeded
them as Power × 1 + USB × N which doesn't match reality. Multi-plug 3-6
and Wifi-plug from v5 lumped every Power port on the same bottom edge
without distinguishing input from outputs.

Migration 006 wipes and re-seeds the port profile for all 8
power-distribution types with the canonical 2-row layout:

  Power In  × 1 on top    (back, sort_order 0)
  Power Out × N on bottom (front, sort_order 1)

N for each:
  IOx-3 / Multi-plug 3 → 3
  IOx-6 / Multi-plug 6 → 6
  IOx-8                → 8
  Multi-plug 4         → 4
  Multi-plug 5         → 5
  Wifi-plug            → 1 (pass-through outlet)

Existing device instances keep their already-seeded ports per design
§2.3 (ports are instance-owned). m needs to delete + recreate any
IOx-* / Multi-plug-* / Wifi-plug instances to pick up the new layout.

Tests:
- TestSeed_PortProfiles: comments updated; totals unchanged (Power In 1
  + Power Out N matches old Power 1 + USB N / Power N).
- TestSeed_PowerHubs (was TestSeed_PowerCatalog, rewritten): table-drives
  all 8 affected types. Asserts exactly 2 port rows — top/Power In/1 and
  bottom/Power Out/N — plus kind/icon for the v5 catalog entries.

Design §2.2 catalog table refreshed to match.
2026-05-16 11:03:32 +02:00
mAi
3276cfeb17 merge: port UX — coloured fill + selectable + edge picker
picasso shipped (1 commit @ 82cf5a3, +157/-28):
- onPortPointerDown rewritten into 4 deterministic branches:
  cable-draw-in-progress | no-tool-no-draw | cable-tool | other-tools
  (bubble). Other-tools branch is what makes +Port placement work
  when the click lands on an existing port — the previous handler
  silently returned for any non-cable tool.
- Port circles fill + stroke in cable-type colour. .selected halo.
- New renderInspectorPort: type swatch + label + edge dropdown
  (Top/Right/Bottom/Left) + delete. Edge change PATCHes x_offset
  and y_offset to the chosen side's centre.

End-to-end verified on deployed image via PATCH /ports/:id round-trip.
2026-05-16 02:21:09 +02:00
mAi
82cf5a3052 fix(ui): port UX — coloured fill, selectable, edge picker
Three bundled fixes to slice 7's port flow:

1. Port-pointerdown branches deterministically:
   - cable-draw in progress → finish / cancel
   - no tool, no draw → select port (inspector opens)
   - cable tool → start a draw from this port
   - any other tool armed → bubble (so +Port can place a new port even
     when the click lands on top of an existing one)

2. Port circles now fill *and* stroke with the cable_type colour so the
   port reads as obviously coloured against the device rect. Selection
   adds a drop-shadow halo.

3. Port inspector — clicking a port (no other tool armed) selects it
   and shows a panel with cable-type swatch, label input, edge selector
   (Top / Right / Bottom / Left), and Delete. Changing the edge PATCHes
   x_offset / y_offset to the centre of the chosen side.

snapToDeviceEdge already picks the nearest of the four edges, so
placement on +Port lands correctly without further changes.
2026-05-16 02:15:11 +02:00
mAi
5d055ad521 merge: catalog-power — Multi-plug 3/4/5/6 + Wifi-plug
Migration 005 adds 5 power-distribution device types. Total
device_types now 21.
2026-05-16 02:07:17 +02:00
mAi
93b276875e feat(catalog): migration 005 — power-distribution devices
Adds 5 built-in device_types (project_id NULL, built_in=1):
- Multi-plug 3/4/5/6 (kind=hub, 🔌) — Power × N+1 (1 in + N out)
- Wifi-plug (kind=accessory, 📶) — Power × 2 pass-through outlet

The solver treats every Power port identically regardless of in/out
direction; m knows which end is which from the physical setup.

Tests:
- TestSeed_BuiltInDeviceTypes: built-in count rises from 16 → 21.
- TestSeed_PortProfiles: new entries' port totals.
- TestSeed_PowerCatalog (new, table-driven): asserts kind, icon, and
  the single Power port row for each of the 5 new types.
2026-05-16 02:05:30 +02:00
mAi
205e9eab26 merge: fix mxdrw auth — Bearer → HTTP Basic
mxdrw on mlake uses BASIC_AUTH_USER + BASIC_AUTH_PASS; slice 8's
Bearer design didn't match. Swapped req.SetBasicAuth(MEXDRAW_USER,
MEXDRAW_PASS). DEPLOY-VERIFY drawing on mxdrw confirms end-to-end
export from the deployed image works.
2026-05-16 02:01:16 +02:00
mAi
fe6f86593e fix(export): switch mxdrw auth from Bearer to HTTP Basic
mxdrw expects HTTP Basic Auth (BASIC_AUTH_USER + BASIC_AUTH_PASS on the
server side). Replace MEXDRAW_TOKEN with MEXDRAW_USER + MEXDRAW_PASS,
use req.SetBasicAuth on the export PUT.

Updated docker-compose.yml comment and README env table to match.
Roundtrip verified locally against mxdrw.msbls.de.
2026-05-16 01:49:23 +02:00
mAi
a7835468a1 merge: slice 8 — Excalidraw export to mxdrw.msbls.de
picasso shipped (2 commits): internal/exporter pure BuildScene +
Generate21 (crypto/rand base62 IDs), internal/db/excalidraw_ids.go
idempotent persistence, internal/server/export.go POST handler with
bearer auth + 10s timeout, frontend Export button + toast.

6 new exporter tests + 60+ existing all green with -race. Hand-test
roundtrip vs mxdrw confirmed: 20 elements per spec, IDs stable across
re-exports.

Deploy to mDock blocked on MEXDRAW_TOKEN — picasso correctly refused
to fake the secret. m to drop value into /home/m/secrets/mcables/.env
on mdock, then redeploy.
2026-05-16 01:42:17 +02:00
mAi
8a6e8c8406 feat(ui): wire Export button — POST /sync/export + toast
Export button is no longer disabled. On click it POSTs to the export
endpoint and shows a toast next to the button:
  ✓ Exported · open in mxdrw   (with viewer URL)
  ✗ Export failed — <detail>
2026-05-16 01:35:50 +02:00
mAi
275cb5a55a 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.
2026-05-16 01:35:46 +02:00
mAi
a81dbe2f8c merge: fix apply-template UX hole
apply-template now auto-solves by default (?solve=0 opt-out for power
users) and returns combined {template_apply, solve} response.
Frontend reloads via activateProject() after Apply, so devices +
cables render immediately without manual Solve click.

Verified: TEST-AUTO project + Living Room template → 3 devices +
2 HDMI cables visible in one round-trip.
2026-05-16 01:24:52 +02:00
mAi
2cd981d3ae fix: apply-template auto-solves + frontend reloads via activateProject
Two changes to close the UX hole m hit on slice 6 — Apply Template
appeared to do nothing because (a) the canvas wasn't refreshed cleanly
and (b) the cables hadn't been computed yet.

Backend (internal/server/solver.go applyTemplate handler):
- After ApplyTemplate succeeds, run Solve(false) inside the same
  request. Combined response shape:
    { template_apply: <ApplyTemplateResult>, solve: <SolveResult> }
- Opt out with ?solve=0 for power-users who want to inspect the
  seeded devices/requirements before the solver runs. Response in that
  case is { template_apply: ... } only.
- If Solve fails after a successful apply, return
  { template_apply, solve_error: "..." } so the frontend can recover
  (devices are still there; m can hit Solve manually).

Frontend (web/static/main.js apply-template modal submit):
- Replaced the bare re-snapshot with a call to activateProject(pid).
  That's the canonical project-load path — it re-hydrates ALL
  collections (frames, devices, ports, io_markers, cables, bundles,
  requirements, cable_types, device_types), clears state.selection
  so a stale pre-apply selection can't linger, and routes through the
  same render() the URL-state hydration uses on initial page load.
- The slice-6 inlined re-snapshot missed the device_types refresh +
  selection reset, which I suspect was what made the canvas look
  stuck — render()ing with state.selection.kind="cable_type" or
  "requirement" pointing at a not-yet-loaded row.

Hand-test (local): Living Room + auto-solve produces 4 devices + 3
requirements + 3 cables; ?solve=0 leaves cables empty. Snapshot
includes the cables on auto-solve path.
2026-05-16 01:23:37 +02:00
mAi
0c7d165ed6 merge: slice 7 — manual ports + cable draw + promote button
picasso shipped (3 commits @ 9625d97):
- backend: ports CRUD endpoints, port-delete cascades cables fix
- frontend: +Port tool with edge-snap, click-port → click-port draws
  auto=0 cable, shift-click=device bind, click IO=terminator,
  clickable driving-requirement link, explicit Promote button
  (PATCH cables with {promote:true} required; label-only PATCH
  preserves auto)
2026-05-16 01:20:48 +02:00
mAi
9625d97efc feat(ui): +Port tool + manual cable draw + driving-req link
+Port (device inspector):
- New button on the device inspector arms a port-placement tool with
  the device + currently-active cable type pre-selected.
- Click anywhere on the canvas: snapToDeviceEdge() finds the closest
  edge of the selected device, clamps the perpendicular coord, POSTs a
  new port. The new port renders immediately (state.ports.push +
  render()).
- Per-port × delete button in the inspector ports grid.

Manual cable draw:
- Port circles are now clickable (slice 4 had pointer-events:none).
- Click a port → starts a cable draw with that port as the source
  (state.cableDrawFromPortID, port highlighted via .cable-from class).
- Click another port → POSTs a cable with from_port_id + to_port_id,
  type derived from source port, auto=false. If the target port's type
  differs, confirm-prompt warns m before committing.
- Shift+click target port → binds to the target's parent device
  (to_device_id) instead of the port.
- Click an IO marker mid-draw → terminates the cable with to_io_id.
- Esc cancels the draw + clears state.cableDrawFromPortID.
- "Draw cable" toolbar button is now enabled (data-tool=cable, keyboard
  is implicit via port-click). armTool() teardown clears the source-port
  state.

Cable inspector tweak (slice 6 callback):
- "driver" row now renders as a clickable button showing the
  requirement's "FromName ↔ ToName" instead of the raw id; click jumps
  the inspector to that requirement.

CSS:
- tool-port + tool-cable add the same crosshair cursor as the other
  tools (descendant-targeted with !important to beat svg-draggable's
  grab cursor — same fix-pattern as slice 3's cursor-cache pass).
- .port-circle.cable-from gives the source port a glow.
- .btn-link styles for inspector inline buttons.
2026-05-16 01:18:55 +02:00
mAi
f9c245fbcc fix(db): cascade-delete cables when a port is removed
The schema has ON DELETE SET NULL on cables.from_port_id /
cables.to_port_id, but the cables CHECK constraint requires exactly one
of (port/device/io) to be non-null per side. Setting both refs to NULL
on a port-delete violates the CHECK, blowing up the DELETE with a 500.

DeletePort now opens a tx, deletes any cable that referenced the port
on either side, then deletes the port. Same observable effect from m's
POV: cables that point at a deleted port are gone (he can re-draw with
the manual cable tool if he still wants them).
2026-05-16 01:18:55 +02:00
mAi
c61bff7cf2 feat(backend): ports CRUD endpoints for slice 7
New store methods on internal/db/ports.go:
- CreatePort / GetPort / UpdatePort / DeletePort (all project-scoped)
- ListPortsForDevice for the inspector's per-device list

New handlers (internal/server/ports.go):
- GET    /api/projects/:pid/devices/:id/ports
- POST   /api/projects/:pid/devices/:id/ports  ← {type_id, label?, x_offset, y_offset}
- PATCH  /api/projects/:pid/ports/:id           ← partial
- DELETE /api/projects/:pid/ports/:id          (cables ref → ON DELETE SET NULL)

Lets slice 7's +Port tool add/remove instance ports without going
through the type-seeded auto-creation path from slice 4.
2026-05-16 01:10:59 +02:00
mAi
1d226844d1 merge: slice 6 — solver MVP + Solve button + setup templates
picasso shipped (3 commits @ c681b01):
- migration 004: setup_templates + setup_template_devices +
  setup_template_requirements, seeded with 3 built-ins (Living Room,
  Home Office, Server Rack)
- store: solver (greedy port allocation, endpoint-pair bundling,
  auto=1 cables), apply-template (creates devices from types + seeds
  requirements), cables + bundles CRUD
- handlers: POST /api/projects/:pid/solve (+ ?preview=1), POST
  /api/projects/:pid/apply-template, combo add-port-and-resolve
  endpoint for the unmet quick-fix, full /cables and /bundles CRUD
- frontend: Solve button in header, preview-diff modal (added/removed
  cables + bundles + unsatisfied list with quick-fix actions), cable
  SVG rendering coloured by type, setup-templates picker on the New
  Project modal
2026-05-16 01:08:41 +02:00
44 changed files with 5293 additions and 270 deletions

View File

@@ -15,7 +15,7 @@ data
# Build artefacts # Build artefacts
bin bin
mcables /cablegui
# Editor cruft # Editor cruft
.vscode .vscode

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@ data/*.db-shm
# Build artefacts # Build artefacts
bin/ bin/
mcables /cablegui
# Editor # Editor
.vscode/ .vscode/

View File

@@ -1,11 +1,11 @@
# mCables — Project Instructions # CableGUI — Project Instructions
## Project Overview ## Project Overview
Cable-management **framework + solver** for m's setup. m declares his Cable-management **framework + solver** for m's setup. m declares his
**devices** and the **connection requirements** between them ("NAS must **devices** and the **connection requirements** between them ("NAS must
connect to Switch via RJ45"). mCables runs a solver that emits the cable connect to Switch via RJ45"). CableGUI runs a solver that emits the cable
plan + bundle recommendations. mCables is a **schematic**, not a plan + bundle recommendations. CableGUI is a **schematic**, not a
physical-routing tool — cables are straight lines between endpoints; the physical-routing tool — cables are straight lines between endpoints; the
"maximum bundling" objective is satisfied by the endpoint-pair rule "maximum bundling" objective is satisfied by the endpoint-pair rule
(when two or more cables share the same A↔B endpoint pair, group them (when two or more cables share the same A↔B endpoint pair, group them
@@ -13,16 +13,17 @@ into one bundle). The visual editor is still there for tweaking the
plan, but the solver is the headline. plan, but the solver is the headline.
Each cable-managed environment (LOFT, OFFICE, …) is a separate Each cable-managed environment (LOFT, OFFICE, …) is a separate
**mCables project**, and each project is backed by exactly one Excalidraw **CableGUI project**, and each project is backed by exactly one Excalidraw
drawing. The framework provides a visual web interface backed by a Go drawing. The framework provides a visual web interface backed by a Go
HTTP API and SQLite, plus an export pipeline that writes `.excalidraw` HTTP API and SQLite, plus an export pipeline that writes `.excalidraw`
files via mExDraw. files via mExDraw.
**Memory group_id:** `mcables` **Memory group_id:** `mcables` (kept historical — all prior memories live
under this id; renaming would orphan them)
**No CLI.** Frontend-first — every interaction is through the visual **No CLI.** Frontend-first — every interaction is through the visual
interface. The backend serves the UI and the API; there is no interface. The backend serves the UI and the API; there is no
`mcables` shell binary intended for humans. `cablegui` shell binary intended for humans.
## Goal ## Goal
@@ -53,7 +54,7 @@ interface. The backend serves the UI and the API; there is no
| Layer | Tech | Notes | | Layer | Tech | Notes |
|---|---|---| |---|---|---|
| DB | SQLite | `./data/mcables.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). | | DB | SQLite | `./data/cablegui.db` (project-local, gitignored). Driver: `modernc.org/sqlite` (cgo-free). |
| Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. | | Backend | Go | `net/http` HTTP API + static frontend via `embed.FS`. Standard library + minimal deps. Single binary. |
| Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. | | Frontend | Vanilla JS modules + SVG, no build step | TypeScript types via JSDoc, optional `tsc --noEmit` in CI. Preact-via-CDN-ESM is the documented fallback if vanilla state gets painful — no build step either way. |
| Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) | | Diagram I/O | mExDraw HTTP API | `PUT https://mxdrw.msbls.de/api/drawings/<name>.excalidraw` with `Authorization: Bearer $MEXDRAW_TOKEN`. (The `mcp__mexdraw__*` MCP tools are not currently configured for this project — workers use the raw HTTP API.) |
@@ -112,14 +113,14 @@ interface. The backend serves the UI and the API; there is no
## Deployment — mDock, raw docker (NOT Dokploy) ## Deployment — mDock, raw docker (NOT Dokploy)
mCables runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`) CableGUI runs on **mDock** (`192.168.178.131` on the LAN, Tailscale `mdock`)
as a **plain docker-compose service**. Dokploy is for public mlake/mRiver as a **plain docker-compose service**. Dokploy is for public mlake/mRiver
stuff; mDock uses raw `docker compose` per the conventions of the existing stuff; mDock uses raw `docker compose` per the conventions of the existing
mDock services (mgreen, mgeo, msports-garmin, paperless, …). mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- Repo layout on mDock: `/home/m/stacks/mcables/` with `docker-compose.yml`, - Repo layout on mDock: `/home/m/stacks/cablegui/` with `docker-compose.yml`,
`data/` bind-mount, secrets in `/home/m/secrets/mcables/.env`. `data/` bind-mount, secrets in `/home/m/secrets/cablegui/.env`.
- Image: `mgit.msbls.de/m/mcables:latest` (built and pushed by a Gitea - Image: `mgit.msbls.de/m/cablegui:latest` (built and pushed by a Gitea
Actions workflow on push to `main`, runs on the self-hosted runner on Actions workflow on push to `main`, runs on the self-hosted runner on
mDock with label `self-hosted:host`). mDock with label `self-hosted:host`).
- Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy. - Port mapping: `7777:7777`, exposed on the LAN — no reverse proxy.
@@ -127,12 +128,12 @@ mDock services (mgreen, mgeo, msports-garmin, paperless, …).
- LAN URL: `http://mdock:7777`. - LAN URL: `http://mdock:7777`.
- No auth — LAN-trusted. - No auth — LAN-trusted.
Local dev (no Docker): `go run ./cmd/mcables` against `./data/mcables.db`. Local dev (no Docker): `go run ./cmd/cablegui` against `./data/cablegui.db`.
## Seed drawing — visual grammar reference, **not** a runtime importer ## Seed drawing — visual grammar reference, **not** a runtime importer
`mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material `mxdrw.msbls.de/draw/Cable-Management.excalidraw` is **reference material
only**. mCables does **not** auto-ingest it. m will rebuild LOFT and OFFICE only**. CableGUI does **not** auto-ingest it. m will rebuild LOFT and OFFICE
from scratch inside the tool — the seed exists so the **exporter** mimics from scratch inside the tool — the seed exists so the **exporter** mimics
its visual grammar: its visual grammar:
@@ -163,13 +164,13 @@ Legend colours (global, seeded once by migration 001):
## Out of scope (v0) ## Out of scope (v0)
- Multi-user. mCables is m-only. - Multi-user. CableGUI is m-only.
- Auth / sharing — LAN-trusted on mDock. - Auth / sharing — LAN-trusted on mDock.
- Mobile / responsive — desktop browser only. - Mobile / responsive — desktop browser only.
- Cable inventory beyond visual structure (no length, no purchase history, - Cable inventory beyond visual structure (no length, no purchase history,
no SKU). Strictly visual structure for v0. no SKU). Strictly visual structure for v0.
- Import from `.excalidraw` at runtime. If a one-shot migration is ever - Import from `.excalidraw` at runtime. If a one-shot migration is ever
needed, a separate `mcables-migrate` CLI tool is the right shape, not a needed, a separate `cablegui-migrate` CLI tool is the right shape, not a
hot API endpoint. hot API endpoint.
## Worker Preferences ## Worker Preferences

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# #
# mCables — single-stage build → distroless runtime image. # CableGUI — single-stage build → distroless runtime image.
# go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0 # go.mod requires go 1.25; modernc.org/sqlite is pure Go so CGO_ENABLED=0
# and a distroless/static runtime is all we need. # and a distroless/static runtime is all we need.
@@ -17,20 +17,20 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \ RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \ -trimpath \
-ldflags="-s -w" \ -ldflags="-s -w" \
-o /out/mcables \ -o /out/cablegui \
./cmd/mcables ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/mcables /app/mcables COPY --from=build /out/cablegui /app/cablegui
ENV MCABLES_ADDR=0.0.0.0:7777 \ ENV CABLEGUI_ADDR=0.0.0.0:7777 \
MCABLES_DB=/app/data/mcables.db CABLEGUI_DB=/app/data/cablegui.db
EXPOSE 7777 EXPOSE 7777
# Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted # Run as UID:GID 1000:1000 to match m on mDock — the bind-mounted
# /home/m/stacks/mcables/data is owned by m:m, so the container can write # /home/m/stacks/cablegui/data is owned by m:m, so the container can write
# to it without chowning the host dir. distroless/static-debian12 accepts # to it without chowning the host dir. distroless/static-debian12 accepts
# arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry. # arbitrary numeric UIDs; the Go binary doesn't need a /etc/passwd entry.
USER 1000:1000 USER 1000:1000
ENTRYPOINT ["/app/mcables"] ENTRYPOINT ["/app/cablegui"]

View File

@@ -1,14 +1,14 @@
.PHONY: build run test typecheck fmt clean .PHONY: build run test typecheck fmt clean
BIN := bin/mcables BIN := bin/cablegui
PKG := ./... PKG := ./...
build: build:
@mkdir -p bin @mkdir -p bin
go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/mcables go build -trimpath -ldflags="-s -w" -o $(BIN) ./cmd/cablegui
run: run:
go run ./cmd/mcables go run ./cmd/cablegui
test: test:
go test -race $(PKG) go test -race $(PKG)

View File

@@ -1,9 +1,9 @@
# mCables # CableGUI
Cable-management **framework** for m's setup — visual web editor backed by Cable-management **framework** for m's setup — visual web editor backed by
a single Go binary + SQLite, generating Excalidraw drawings via mExDraw. a single Go binary + SQLite, generating Excalidraw drawings via mExDraw.
Each cable-managed environment (LOFT, OFFICE, …) is a separate mCables Each cable-managed environment (LOFT, OFFICE, …) is a separate CableGUI
*project*; each project is backed by exactly one `.excalidraw` drawing on *project*; each project is backed by exactly one `.excalidraw` drawing on
mxdrw.msbls.de. mxdrw.msbls.de.
@@ -23,7 +23,7 @@ end-to-end; the SVG canvas is intentionally empty until slice 2.
## Run it ## Run it
```sh ```sh
go run ./cmd/mcables go run ./cmd/cablegui
# open http://localhost:7777 # open http://localhost:7777
``` ```
@@ -31,20 +31,21 @@ Or built:
```sh ```sh
make build make build
./bin/mcables ./bin/cablegui
``` ```
The binary serves the frontend from an embedded `web/static/` and the The binary serves the frontend from an embedded `web/static/` and the
JSON API under `/api/`. SQLite lives at `./data/mcables.db` by default. JSON API under `/api/`. SQLite lives at `./data/cablegui.db` by default.
### Environment ### Environment
| Var | Default | Notes | | Var | Default | Notes |
|---|---|---| |---|---|---|
| `MCABLES_ADDR` | `0.0.0.0:7777` | Listen address. | | `CABLEGUI_ADDR` | `0.0.0.0:7777` | Listen address. |
| `MCABLES_DB` | `./data/mcables.db` | SQLite path. Parent dir is created on boot. | | `CABLEGUI_DB` | `./data/cablegui.db` | SQLite path. Parent dir is created on boot. |
| `MEXDRAW_BASE_URL` | (unset) | Used by slice 5 export — not consumed yet. | | `MEXDRAW_BASE_URL` | `https://mxdrw.msbls.de` | Base URL for mExDraw export. |
| `MEXDRAW_TOKEN` | (unset) | Bearer for the mExDraw export. Not consumed yet. | | `MEXDRAW_USER` | (unset) | Username for the mxdrw HTTP Basic Auth on export. Required. |
| `MEXDRAW_PASS` | (unset) | Password for the mxdrw HTTP Basic Auth on export. Required. |
### Tests ### Tests
@@ -77,37 +78,35 @@ DELETE /api/cable-types/:id ← 409 in_use if any cable references
## Deploy to mDock ## Deploy to mDock
mCables runs on **mDock** at `http://mdock:7777` as a docker-compose CableGUI runs on **mDock** at `http://mdock:7777` as a docker-compose
service under `/home/m/stacks/mcables/`. Pattern matches the other service under `/home/m/stacks/cablegui/`. Pattern matches the other
mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy, mDock services (mgreen-journal, mgeo, msports-garmin, …) — no Dokploy,
no reverse proxy, LAN-trusted. no reverse proxy, LAN-trusted.
### Manual deploy (first roll) ### Manual deploy
1. **Build + push the image** (from any host with docker; today the 1. **Build + push the image** (image now lives under `m/` in Gitea):
image lives in mAi's Gitea namespace because mAi doesn't have write
access to `m/`):
```sh ```sh
docker build -t mgit.msbls.de/mai/mcables:latest . docker build -t mgit.msbls.de/m/cablegui:latest .
awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc-mai \ awk '/machine mgit.msbls.de/{getline; getline; print $2}' ~/.netrc \
| docker login mgit.msbls.de -u mAi --password-stdin | docker login mgit.msbls.de -u m --password-stdin
docker push mgit.msbls.de/mai/mcables:latest docker push mgit.msbls.de/m/cablegui:latest
``` ```
2. **Prepare directories on mDock** (one-time): 2. **Prepare directories on mDock** (one-time):
```sh ```sh
ssh mdock 'mkdir -p /home/m/stacks/mcables/data /home/m/secrets/mcables \ ssh mdock 'mkdir -p /home/m/stacks/cablegui/data /home/m/secrets/cablegui \
&& touch /home/m/secrets/mcables/.env \ && touch /home/m/secrets/cablegui/.env \
&& chmod 0600 /home/m/secrets/mcables/.env' && chmod 0600 /home/m/secrets/cablegui/.env'
scp docker-compose.yml mdock:/home/m/stacks/mcables/docker-compose.yml scp docker-compose.yml mdock:/home/m/stacks/cablegui/docker-compose.yml
``` ```
3. **Pull + start**: 3. **Pull + start**:
```sh ```sh
ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d' ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'
``` ```
4. **Verify** from any LAN host: 4. **Verify** from any LAN host:
@@ -118,11 +117,11 @@ no reverse proxy, LAN-trusted.
``` ```
To **update** to a new build: rebuild + push the image, then To **update** to a new build: rebuild + push the image, then
`ssh mdock 'cd /home/m/stacks/mcables && docker compose pull && docker compose up -d'`. `ssh mdock 'cd /home/m/stacks/cablegui && docker compose pull && docker compose up -d'`.
### Persistence ### Persistence
SQLite lives at `/home/m/stacks/mcables/data/mcables.db` on the host SQLite lives at `/home/m/stacks/cablegui/data/cablegui.db` on the host
(bind-mounted into the container at `/app/data`). Container runs as (bind-mounted into the container at `/app/data`). Container runs as
UID 1000:1000 to align with `m:m` ownership on mDock — DB files end UID 1000:1000 to align with `m:m` ownership on mDock — DB files end
up owned by `m`, the host user. up owned by `m`, the host user.

64
cmd/cablegui/main.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"mgit.msbls.de/m/cablegui/internal/db"
"mgit.msbls.de/m/cablegui/internal/server"
"mgit.msbls.de/m/cablegui/web"
)
func main() {
addr := envOr("CABLEGUI_ADDR", "0.0.0.0:7777")
dbPath := envOr("CABLEGUI_DB", "./data/cablegui.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
log.Fatalf("mkdir data dir: %v", err)
}
store, err := db.Open(dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer store.Close()
if err := db.Migrate(store.DB()); err != nil {
log.Fatalf("migrate: %v", err)
}
srv := &http.Server{
Addr: addr,
Handler: server.New(store, web.Static()),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("cablegui listening on %s (db=%s)", addr, dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -1,20 +1,20 @@
# mCables — production compose for mDock. # CableGUI — production compose for mDock.
# Lives at /home/m/stacks/mcables/docker-compose.yml on mDock. # Lives at /home/m/stacks/cablegui/docker-compose.yml on mDock.
# Matches the existing mDock service patterns (mgreen, mgeo, …). # Matches the existing mDock service patterns (mgreen, mgeo, …).
services: services:
mcables: cablegui:
image: mgit.msbls.de/m/mcables:latest image: mgit.msbls.de/m/cablegui:latest
container_name: mcables container_name: cablegui
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7777:7777" - "7777:7777"
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777 - CABLEGUI_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db - CABLEGUI_DB=/app/data/cablegui.db
env_file: env_file:
# Empty for slice 1. MEXDRAW_TOKEN lands here when slice 5 ships. # MEXDRAW_USER + MEXDRAW_PASS for the mxdrw HTTP Basic Auth on export.
- /home/m/secrets/mcables/.env - /home/m/secrets/cablegui/.env
volumes: volumes:
- /home/m/stacks/mcables/data:/app/data - /home/m/stacks/cablegui/data:/app/data

View File

@@ -1,4 +1,4 @@
# mCables — Design v4.1 # CableGUI — Design v4.1
Cable-management **framework + solver** for m's setup. Inventor shift 1 Cable-management **framework + solver** for m's setup. Inventor shift 1
design, revised through v2 (rescope to multi-project framework), v3 design, revised through v2 (rescope to multi-project framework), v3
@@ -6,7 +6,7 @@ design, revised through v2 (rescope to multi-project framework), v3
**v4.1 — six locked answers from m's v4 review**. **v4.1 — six locked answers from m's v4 review**.
> **What changed in v4.1** (tight pass on v4) > **What changed in v4.1** (tight pass on v4)
> 1. **mCables is a schematic, not a physical-routing tool.** Cables are > 1. **CableGUI is a schematic, not a physical-routing tool.** Cables are
> straight lines between endpoints; the solver and the renderer do not > straight lines between endpoints; the solver and the renderer do not
> care about paths, trunks, frame edges, or cable-tray polylines. > care about paths, trunks, frame edges, or cable-tray polylines.
> "Maximum bundling" reduces to the v3 rule: **≥2 cables between the > "Maximum bundling" reduces to the v3 rule: **≥2 cables between the
@@ -33,13 +33,13 @@ design, revised through v2 (rescope to multi-project framework), v3
Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as Sources: the live `Cable-Management.excalidraw` on mxdrw.msbls.de (used as
the *visual-grammar reference*, not a bootstrap import target), the *visual-grammar reference*, not a bootstrap import target),
`mai-memory` (`mcables`, `m`), and the live mDock services for deploy `mai-memory` (`cablegui`, `m`), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification: conventions (§10). v4 driven by m's product-vision clarification:
> "we provide a cable manager — I say what devices we have, the app tells > "we provide a cable manager — I say what devices we have, the app tells
> me how to bundle cables and how the most efficient connection looks like" > me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a **solver** that CableGUI shifts from a manual draw-and-click editor to a **solver** that
takes a list of devices + the connections m needs and emits the cable takes a list of devices + the connections m needs and emits the cable
plan + bundle recommendations. The manual editor stays (it's the only way plan + bundle recommendations. The manual editor stays (it's the only way
to inspect + tweak the plan) but is no longer the primary surface. to inspect + tweak the plan) but is no longer the primary surface.
@@ -60,7 +60,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> without applying; default applies. > without applying; default applies.
> - **Solver objective: maximum bundling** (§5b.1). Schematic only: when > - **Solver objective: maximum bundling** (§5b.1). Schematic only: when
> two or more cables share the same endpoint pair, group them into one > two or more cables share the same endpoint pair, group them into one
> bundle. No path or trunk geometry — mCables is a wiring schematic, > bundle. No path or trunk geometry — CableGUI is a wiring schematic,
> not a routing tool. v4.1 strips all path/trunk language from the v4 > not a routing tool. v4.1 strips all path/trunk language from the v4
> draft. > draft.
> - **UI: device-type dropdown** on device-create, **Connection > - **UI: device-type dropdown** on device-create, **Connection
@@ -72,7 +72,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
> bundle-rendering polish. > bundle-rendering polish.
> >
> **What carried over from v3 (unchanged in v4)** > **What carried over from v3 (unchanged in v4)**
> - mCables is a framework: top-level `projects`, each backed by one > - CableGUI is a framework: top-level `projects`, each backed by one
> `.excalidraw` drawing. `UNIQUE(projects.name)`. > `.excalidraw` drawing. `UNIQUE(projects.name)`.
> - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45. > - `cable_types` is global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.
> - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef > - `devices` UNIQUE(project_id, name); `frame_id` nullable; FrameRef
@@ -81,8 +81,8 @@ to inspect + tweak the plan) but is no longer the primary surface.
> - `projects.drawing_name` auto-defaults to `<name>.excalidraw`. > - `projects.drawing_name` auto-defaults to `<name>.excalidraw`.
> - `DELETE /api/projects/:pid?confirm=<name>` guardrail. > - `DELETE /api/projects/:pid?confirm=<name>` guardrail.
> - No cable inventory metadata; visual + connectivity structure only. > - No cable inventory metadata; visual + connectivity structure only.
> - DB at `./data/mcables.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth. > - DB at `./data/cablegui.db` (gitignored). Bind `0.0.0.0:7777` LAN, no auth.
> - Deploy on mDock under `/home/m/stacks/mcables/`, raw docker-compose. > - Deploy on mDock under `/home/m/stacks/cablegui/`, raw docker-compose.
> >
> **What's superseded in v4** > **What's superseded in v4**
> - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a > - The "manual draw-a-cable port-to-port" flow from v3 §7 is *kept* as a
@@ -97,7 +97,7 @@ to inspect + tweak the plan) but is no longer the primary surface.
`Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at `Cable-Management.excalidraw` on mxdrw.msbls.de is **not** ingested at
runtime. It is the visual-grammar reference we lock the export onto so that runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported `.excalidraw` when m rebuilds LOFT and OFFICE inside CableGUI, the exported `.excalidraw`
looks like the seed. looks like the seed.
Concrete numbers from the live file (180 elements): Concrete numbers from the live file (180 elements):
@@ -128,7 +128,7 @@ Three observations about the seed's visual grammar — these constrain the
1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by 1. **Ports sit on a device edge as small ellipses (~12×9)**, coloured by
cable type. They are not children of the device in the Excalidraw sense cable type. They are not children of the device in the Excalidraw sense
(no `containerId`/`boundElements` link) — purely positional. When we (no `containerId`/`boundElements` link) — purely positional. When we
export from mCables we mimic that: port ellipse at `(device.x + export from CableGUI we mimic that: port ellipse at `(device.x +
port.x_offset, device.y + port.y_offset)`, stroke colour = type colour. port.x_offset, device.y + port.y_offset)`, stroke colour = type colour.
2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses 2. **Cable arrows bind to elements**. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to (ports), 12 to whole rectangles (device-level, no specific port), 3 to
@@ -161,7 +161,7 @@ painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
## 2. SQLite schema ## 2. SQLite schema
`./data/mcables.db` (project-local, gitignored). WAL mode, FKs on. `./data/cablegui.db` (project-local, gitignored). WAL mode, FKs on.
Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple Driver: **`modernc.org/sqlite`** (cgo-free — clean cross-compile, simple
Dockerfile). Dockerfile).
@@ -453,18 +453,26 @@ Office setup template:
| fritz | network | Power × 1; RJ45 × 4 | | fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 | | ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 | | SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) | | IOx-3 | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| IOx-6 | hub | Power × 1; USB × 6 | | IOx-6 | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| IOx-8 | hub | Power × 1; USB × 8 | | IOx-8 | hub | Power In × 1 (top/back); Power Out × 8 (bottom/front) |
| **Screen** | display | Power × 1; HDMI × 1 | | **Screen** | display | Power × 1; HDMI × 1 |
| **Keyboard** | accessory | USB × 1 | | **Keyboard** | accessory | USB × 1 |
| **Mouse** | accessory | USB × 1 | | **Mouse** | accessory | USB × 1 |
| **Multi-plug 3** | hub | Power In × 1 (top/back); Power Out × 3 (bottom/front) |
| **Multi-plug 4** | hub | Power In × 1 (top/back); Power Out × 4 (bottom/front) |
| **Multi-plug 5** | hub | Power In × 1 (top/back); Power Out × 5 (bottom/front) |
| **Multi-plug 6** | hub | Power In × 1 (top/back); Power Out × 6 (bottom/front) |
| **Wifi-plug** | accessory | Power In × 1 (top/back); Power Out × 1 (bottom/front) — pass-through outlet |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing v5 (migration 005) added the Multi-plug 36 strips and the Wifi-plug
shows them in red because most carry Power, but they also hub USB). v0 pass-through outlet. v6 (migration 006) re-shaped the IOx-* and
seeds them as USB hubs; m overrides per-instance. The catalog is editable Multi-plug-* profiles to the "1 in on top / N out on bottom" layout —
in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3 the IOx-* devices are physical power strips, not USB hubs (m's
profile once and not re-override every instance. hardware), and the Multi-plug-* outputs are now visually distinct from
the input. Convention: `top = back`, `bottom = front`. Existing device
instances keep their already-seeded ports per §2.3 — to pick up the
new layout, delete + re-create the instance.
m can also add **project-custom types** at any time (UI: "+ New device m can also add **project-custom types** at any time (UI: "+ New device
type" inside the device-create modal) with `project_id = current`. type" inside the device-create modal) with `project_id = current`.
@@ -601,8 +609,8 @@ cascade does **not** touch `cable_types` (no FK to projects).
## 3. Go HTTP API ## 3. Go HTTP API
Single binary `cmd/mcables`, `net/http`, no router framework. Listens on Single binary `cmd/cablegui`, `net/http`, no router framework. Listens on
`0.0.0.0:7777` by default (overridable via `MCABLES_ADDR`). Static frontend `0.0.0.0:7777` by default (overridable via `CABLEGUI_ADDR`). Static frontend
from `embed.FS` at `/`, JSON API under `/api/`. from `embed.FS` at `/`, JSON API under `/api/`.
``` ```
@@ -772,7 +780,7 @@ generated scene JSON.
## 4. Export — DB → Excalidraw (visual-grammar conformance) ## 4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a `.excalidraw` scene from a project's rows. The seed CableGUI generates a `.excalidraw` scene from a project's rows. The seed
drawing's grammar is the contract. drawing's grammar is the contract.
### 4.1 Element mapping ### 4.1 Element mapping
@@ -790,7 +798,7 @@ drawing's grammar is the contract.
### 4.2 Element IDs are stable across exports ### 4.2 Element IDs are stable across exports
Every mCables row carries `excalidraw_id` (TEXT, generated on first export Every CableGUI row carries `excalidraw_id` (TEXT, generated on first export
via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row via `crypto/rand` → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means: reuses the same ID. This means:
@@ -858,7 +866,7 @@ left strictly alone — the solver only adds and removes its own.
### 5b.1 Objective: maximum bundling — schematic only ### 5b.1 Objective: maximum bundling — schematic only
mCables is a **schematic**, not a physical-routing tool. Cables are CableGUI is a **schematic**, not a physical-routing tool. Cables are
straight lines between endpoints; the solver has no model of walls, straight lines between endpoints; the solver has no model of walls,
floors, cable trays, or path geometry. "Maximum bundling" therefore floors, cable trays, or path geometry. "Maximum bundling" therefore
reduces to a single rule on the schematic: reduces to a single rule on the schematic:
@@ -980,7 +988,7 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
``` ```
┌─────────────────────┐ ┌─────────────────────┐
mCables DB (truth) │ │ CableGUI DB (truth) │
└──────────┬──────────┘ └──────────┬──────────┘
export ▼ export ▼
@@ -990,11 +998,11 @@ triggers a debounced re-solve) is parked at slice 9+ as an opt-in.
└────────────────────────┘ └────────────────────────┘
``` ```
- mCables UI → DB: synchronous (every drag/add/remove persists immediately). - CableGUI UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: **manual** button "Export to Excalidraw" in the header, - DB → Excalidraw: **manual** button "Export to Excalidraw" in the header,
per project. Calls `POST /api/projects/:pid/sync/export`. per project. Calls `POST /api/projects/:pid/sync/export`.
- Excalidraw → DB: **not implemented** in v0. Anything m draws in - Excalidraw → DB: **not implemented** in v0. Anything m draws in
Excalidraw stays in Excalidraw until he redraws it in mCables. Excalidraw stays in Excalidraw until he redraws it in CableGUI.
This keeps the v0 scope tight: no conflict resolution, no element-diff This keeps the v0 scope tight: no conflict resolution, no element-diff
import, no auto-debounce. mExDraw keeps its own version history (git import, no auto-debounce. mExDraw keeps its own version history (git
@@ -1004,7 +1012,7 @@ When mxdrw is unreachable: the export button shows a tooltip and disables;
the editor keeps working against the local DB. the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate Post-MVP, import returns as a one-shot migration tool (separate
`mcables-migrate` CLI tool, not part of the running server) for seeding `cablegui-migrate` CLI tool, not part of the running server) for seeding
new projects from existing `.excalidraw` files. new projects from existing `.excalidraw` files.
--- ---
@@ -1015,7 +1023,7 @@ The editor lives at `/`. Layout:
``` ```
┌────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────┐
mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header │ CableGUI [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤ ├────────┬───────────────────────────────────────────────────────────┤
│ │ │ │ │ │
│ Legend │ │ │ Legend │ │
@@ -1246,7 +1254,7 @@ Slices 9+ (not promised for the first coder shift):
- Cable inventory metadata (length/SKU) if m later wants it. - Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode. - Dark mode.
Out of scope, period (would change mCables's mental model): path Out of scope, period (would change CableGUI's mental model): path
routing, cable-tray polylines, frame-edge corridors, wall-axis bundling, routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
3D, anything that treats a cable as more than a labelled endpoint pair. 3D, anything that treats a cable as more than a labelled endpoint pair.
@@ -1256,7 +1264,7 @@ routing, cable-tray polylines, frame-edge corridors, wall-axis bundling,
The six v4 questions are now answered. Locked answers: The six v4 questions are now answered. Locked answers:
1. **Where do paths come from?** → **Nowhere — mCables is a schematic.** 1. **Where do paths come from?** → **Nowhere — CableGUI is a schematic.**
Cables are straight lines between endpoints. The solver does not Cables are straight lines between endpoints. The solver does not
route, the renderer does not route, and "maximum bundling" reduces to route, the renderer does not route, and "maximum bundling" reduces to
the endpoint-pair rule (§5b.1). Anything resembling a path, trunk, the endpoint-pair rule (§5b.1). Anything resembling a path, trunk,
@@ -1299,25 +1307,25 @@ before writing this:
- Host port mappings: deliberately collision-free across the host. Existing - Host port mappings: deliberately collision-free across the host. Existing
high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878 high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878
(radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr). (radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr).
**Port 7777 is free** — taking it for mCables. **Port 7777 is free** — taking it for CableGUI.
- Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical - Bind-mount volumes: `/home/m/<project>-data:/app/data` is the canonical
pattern (mgreen). For project-local data we put `data/` *next to* the pattern (mgreen). For project-local data we put `data/` *next to* the
compose file so a `git pull && docker compose up -d` is the whole deploy: compose file so a `git pull && docker compose up -d` is the whole deploy:
`/home/m/stacks/mcables/data:/app/data`. `/home/m/stacks/cablegui/data:/app/data`.
- Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin - Secrets via `env_file: /home/m/secrets/<project>/.env` (msports-garmin
pattern). mCables only needs `MEXDRAW_TOKEN` for export. pattern). CableGUI only needs `MEXDRAW_TOKEN` for export.
- No reverse proxy on mDock. Services expose ports directly on the LAN - No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via (mDock = `192.168.178.131` / Tailscale `mdock`). Public exposure goes via
mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only). mlake/Dokploy + Caddy when needed — out of scope for CableGUI (LAN-only).
- Auto-deploy via the Gitea Actions self-hosted runner already installed - Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to on mDock (`/home/m/act-runner/`, label `self-hosted:host`). Push to
`main` → workflow on mDock → `docker compose up --build -d`. `main` → workflow on mDock → `docker compose up --build -d`.
### Repo layout for mCables ### Repo layout for CableGUI
``` ```
mCables/ CableGUI/
├── cmd/mcables/main.go # Go binary ├── cmd/cablegui/main.go # Go binary
├── internal/ ├── internal/
│ ├── db/ # migrations + store │ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP) │ ├── importer/ # post-MVP only (not in MVP)
@@ -1328,7 +1336,7 @@ mCables/
│ ├── main.js # ES module entry │ ├── main.js # ES module entry
│ ├── style.css │ ├── style.css
│ └── lib/... # SVG helpers, store, components │ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored) ├── data/ # CableGUI runtime DB lives here (gitignored)
│ └── .gitkeep │ └── .gitkeep
├── docs/design.md # this file ├── docs/design.md # this file
├── Dockerfile ├── Dockerfile
@@ -1353,37 +1361,37 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables -o /out/cablegui ./cmd/cablegui
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app WORKDIR /app
COPY --from=build /out/mcables /app/mcables COPY --from=build /out/cablegui /app/cablegui
ENV MCABLES_ADDR=0.0.0.0:7777 ENV CABLEGUI_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db ENV CABLEGUI_DB=/app/data/cablegui.db
USER nonroot:nonroot USER nonroot:nonroot
EXPOSE 7777 EXPOSE 7777
ENTRYPOINT ["/app/mcables"] ENTRYPOINT ["/app/cablegui"]
``` ```
### docker-compose.yml (on mDock at `/home/m/stacks/mcables/`) ### docker-compose.yml (on mDock at `/home/m/stacks/cablegui/`)
```yaml ```yaml
services: services:
mcables: cablegui:
image: mgit.msbls.de/m/mcables:latest image: mgit.msbls.de/m/cablegui:latest
container_name: mcables container_name: cablegui
restart: unless-stopped restart: unless-stopped
ports: ports:
- "7777:7777" - "7777:7777"
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777 - CABLEGUI_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db - CABLEGUI_DB=/app/data/cablegui.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de - MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file: env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN - /home/m/secrets/cablegui/.env # contains MEXDRAW_TOKEN
volumes: volumes:
- /home/m/stacks/mcables/data:/app/data - /home/m/stacks/cablegui/data:/app/data
``` ```
LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`). LAN URL: `http://mdock:7777` (or `http://192.168.178.131:7777`).
@@ -1404,15 +1412,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build image - name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest . run: docker build -t mgit.msbls.de/m/cablegui:latest .
- name: Push image - name: Push image
run: | run: |
echo "${{ secrets.GITEA_TOKEN }}" | \ echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest docker push mgit.msbls.de/m/cablegui:latest
- name: Up - name: Up
run: | run: |
cd /home/m/stacks/mcables cd /home/m/stacks/cablegui
docker compose pull docker compose pull
docker compose up -d docker compose up -d
``` ```
@@ -1420,7 +1428,7 @@ jobs:
### Local-development run (no Docker) ### Local-development run (no Docker)
``` ```
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db make run # go run ./cmd/cablegui → :7777 against ./data/cablegui.db
make typecheck # tsc --noEmit on web/ make typecheck # tsc --noEmit on web/
make test # go test ./... make test # go test ./...
``` ```
@@ -1430,4 +1438,228 @@ gitignored.
--- ---
DESIGN v4.1 READY FOR REVIEW ## 11. v5 — Cable routing via clamps
m's bundling primitive: a **clamp** is a physical anchor on the canvas
(think cable tie / clip). A cable routes from its `from` endpoint,
through zero or more clamps **in order**, to its `to` endpoint. Two
cables that share an ordered pair of consecutive clamps are visibly
bundled along that segment — no detection pass, no inference: the
overlap *is* the bundle.
This replaces the abandoned waypoints + segment-detection approach.
v0's straight-line schematic stays as the empty-clamps case
(`cable_clamps` is empty for a fresh solver-emitted cable).
### 11.1 Schema (migration 007)
```sql
CREATE TABLE clamps (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
x REAL NOT NULL,
y REAL NOT NULL,
label TEXT NOT NULL DEFAULT '',
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX clamps_project_idx ON clamps(project_id);
CREATE TABLE cable_clamps (
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
ord INTEGER NOT NULL, -- 1..N along from→to
PRIMARY KEY (cable_id, ord),
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
```
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
inside a frame and the frame-drag carries it.
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
during edits and the renderer is fine with that), but the UI keeps them
contiguous on every mutation for sanity.
### 11.2 Cable rendering model
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
where:
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
resolver (port / device / IO).
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
width/height to centre.
For N=0 clamps the result is the v0 straight line. For N≥1 we render
a `<polyline>` instead of a `<line>`.
The endpoint-replug handles from §10 (cable-replug) stay on the **first
and last** vertices. Mid-polyline vertices get their own clamp-handle —
small grab points only on the selected cable, which behave like
clamp-detach when dragged onto empty canvas (drop a clamp off the
cable's path).
### 11.3 Bundle visualisation — derived from shared segments
A **segment** is a directed pair `(A, B)` where A and B are consecutive
nodes of a cable's polyline. Two cables share a segment when their
polyline contains the same A→B (or B→A — segment matching is
undirected).
For each segment, compute `cables[]` — the cables that traverse it.
If `len(cables) ≥ 2`, render the segment as a single thick line on top
of the individual ones:
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
- **Colour**: a striped pattern, one stripe per distinct cable type in
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
hard stops produces the stripe band cheaply; render it on a sibling
`<polyline>` over the individual lines.
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
shows a small count badge (`×N`) when N > 1. At fan-out points
(endpoint with no clamp before it on the polyline) the individual
coloured lines re-emerge, so m sees which port each strand goes to.
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
cable) this is trivial. We rebuild the segment map on every renderCanvas
— no caching layer.
### 11.4 UI gestures
**+ Clamp tool (`C` shortcut, also a sidebar button):**
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
Standalone clamp — not on any cable yet.
- Click a cable line → insert this clamp into that cable. The new clamp
sits at the click position (snapped to the nearest point on the
cable's polyline) and its `ord` is computed so it falls between the
two existing vertices it lies between.
**Drag a cable's mid-segment:**
- Pointerdown on a cable line (not on an endpoint handle) and drag.
Live preview shows a bend at the cursor. Pointerup:
- If the cursor is within snap-radius (~16 px) of an existing clamp:
insert that clamp into the cable's polyline at the right `ord`.
- Otherwise: create a fresh clamp at the release point and insert it.
**Clamp inspector** (selecting a clamp on the canvas):
- Position (x, y editable + label)
- "Cables through this clamp": list with each cable's two endpoints,
click → select that cable
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
row; cable's polyline collapses around the gap.
- "Delete clamp" → cascade-removes from every cable_clamps row.
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
inline.
**Frame drag** carries clamps the same way it carries devices + IO
markers (clamp.frame_id mirrors the existing pattern, drag handler
already iterates frame-contained items).
### 11.5 Relationship to the existing `bundles` table
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
- Implicit/auto bundles → derived live from shared clamp segments. No
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
"you might want to route these through the same clamps" hint.
- Explicit named bundles → still in the `bundles` table. m names a
group ("desk → wall trunk"), the UI offers "route all members through
these clamps" as a one-click action. Useful for the case where m
wants a stable label on a logical bundle that isn't yet routed.
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
can drop them if m decides the explicit-named path isn't worth keeping.
### 11.6 Solver coupling
The v0 solver still emits **straight cables** — no clamp rows. m
hand-routes after Solve. The solver's preview-diff is unaffected
(solver compares endpoint pairs; clamp routing is independent of the
endpoint identity).
Future v5.1: solver-suggested clamps based on shared paths between
endpoint pairs. Out of scope here.
### 11.7 Export to mxdrw
Clamps map to small diamond elements (separate from IO markers — IO
diamonds are red wall-outlets; clamps are grey routing points).
`excalidraw_id` is stable across re-exports per the existing pattern.
Cable arrows become Excalidraw `arrow` elements with mid-points (the
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
via the `points` array. Each `startBinding` / `endBinding` resolves to
the from/to anchor's excalidraw_id; mid-vertices are unbound.
Bundle visualisation (thick striped lines on shared segments) is **not
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
and the mxdrw round-trip would lose them. We export each cable as its
own polyline; bundling is a viewer-only concept.
### 11.8 API additions
```
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
DELETE /api/projects/:pid/clamps/:id
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
# Convenience: re-order clamps on a cable in one call
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
```
Snapshot endpoint grows two arrays:
- `clamps: []Clamp`
- `cable_clamps: []{ cable_id, clamp_id, ord }`
### 11.9 Open questions for m
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
when zoomed out), small filled circle (overlaps with port circles),
or rounded square `` 10×10? Recommend rounded square — distinct from
everything else on the canvas today.
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
right at 1× zoom. Should it scale with zoom (visual constant) or stay
world-constant (gesture stays the same regardless of zoom)? Recommend
visual constant — divide by current zoom.
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
draft says cascade silently. Worth a confirmation?
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
order on a thick line affects readability. Order by stripe-count
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
but unrelated to importance)? Recommend by-count, ties broken by id.
5. **Solver respect for existing routing.** When m re-runs Solve after
hand-routing, should the solver preserve existing clamp routing on
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
their clamps disappear with them — that's expected. But manual cables
with clamps should clearly keep them. Confirm.
### 11.10 Slice plan (post-design)
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
AttachClampToCable, DetachClampFromCable, ReorderClamps).
2. HTTP endpoints + snapshot extension.
3. Frontend: clamp render + + Clamp tool + canvas placement (no
cable attach yet).
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
clamp inspector.
5. Shared-segment bundle visualisation (gradient stripe + count badge).
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
diamonds. Bundle viz stays viewer-only.
---
DESIGN v5 READY FOR REVIEW

2
go.mod
View File

@@ -1,4 +1,4 @@
module mgit.msbls.de/m/mcables module mgit.msbls.de/m/cablegui
go 1.25.5 go 1.25.5

351
internal/db/clamps.go Normal file
View File

@@ -0,0 +1,351 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// ClampCreate is the create-shape for a new clamp.
type ClampCreate struct {
X float64
Y float64
Label string
FrameID *int64
}
// ClampUpdate is the partial-update shape.
type ClampUpdate struct {
X *float64
Y *float64
Label *string
// FrameID tri-state: nil = leave alone; non-nil pointer to nil ptr
// would be ambiguous, so we use FrameRef like devices.
FrameID FrameRef
}
// CreateClamp inserts a new clamp inside a project.
func (s *Store) CreateClamp(projectID int64, c ClampCreate) (*Clamp, error) {
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if c.FrameID != nil {
if _, err := s.GetFrame(projectID, *c.FrameID); err != nil {
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
}
}
res, err := s.db.Exec(
`INSERT INTO clamps (project_id, x, y, label, frame_id)
VALUES (?, ?, ?, ?, ?)`,
projectID, c.X, c.Y, strings.TrimSpace(c.Label), nullableInt64(c.FrameID),
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetClamp(projectID, id)
}
// GetClamp returns a single clamp scoped to the project.
func (s *Store) GetClamp(projectID, id int64) (*Clamp, error) {
var c Clamp
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
FROM clamps WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
c.FrameID = &v
}
if ex.Valid {
c.ExcalidrawID = &ex.String
}
return &c, nil
}
// ListClamps returns every clamp in a project, ordered by id.
func (s *Store) ListClamps(projectID int64) ([]Clamp, error) {
rows, err := s.db.Query(
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
FROM clamps WHERE project_id = ? ORDER BY id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Clamp{}
for rows.Next() {
var c Clamp
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
c.FrameID = &v
}
if ex.Valid {
c.ExcalidrawID = &ex.String
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateClamp applies a partial update.
func (s *Store) UpdateClamp(projectID, id int64, u ClampUpdate) (*Clamp, error) {
cur, err := s.GetClamp(projectID, id)
if err != nil {
return nil, err
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.Label != nil {
cur.Label = strings.TrimSpace(*u.Label)
}
if u.FrameID.Set {
if u.FrameID.ID == nil {
cur.FrameID = nil
} else {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
}
id := *u.FrameID.ID
cur.FrameID = &id
}
}
if _, err := s.db.Exec(
`UPDATE clamps SET x = ?, y = ?, label = ?, frame_id = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.X, cur.Y, cur.Label, nullableInt64(cur.FrameID), id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetClamp(projectID, id)
}
// DeleteClamp removes a clamp. cable_clamps rows cascade.
func (s *Store) DeleteClamp(projectID, id int64) error {
res, err := s.db.Exec(`DELETE FROM clamps WHERE id = ? AND project_id = ?`, id, projectID)
if err != nil {
return mapWriteErr(err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ListCableClamps returns every (cable_id, clamp_id, ord) row in a
// project, joined through cables to scope by project_id.
func (s *Store) ListCableClamps(projectID int64) ([]CableClamp, error) {
rows, err := s.db.Query(
`SELECT cc.cable_id, cc.clamp_id, cc.ord
FROM cable_clamps cc
JOIN cables c ON c.id = cc.cable_id
WHERE c.project_id = ?
ORDER BY cc.cable_id, cc.ord`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableClamp{}
for rows.Next() {
var cc CableClamp
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
return nil, err
}
out = append(out, cc)
}
return out, rows.Err()
}
// ListClampsForCable returns the clamps on a cable in ord sequence.
func (s *Store) ListClampsForCable(projectID, cableID int64) ([]CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
rows, err := s.db.Query(
`SELECT cable_id, clamp_id, ord
FROM cable_clamps WHERE cable_id = ? ORDER BY ord`, cableID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CableClamp{}
for rows.Next() {
var cc CableClamp
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
return nil, err
}
out = append(out, cc)
}
return out, rows.Err()
}
// AttachClampToCable inserts a (cable, clamp) row. If `ord` is 0, the
// clamp is appended at the end. Otherwise existing rows at or after
// `ord` shift up by 1 to make room.
func (s *Store) AttachClampToCable(projectID, cableID, clampID int64, ord int) (*CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
if _, err := s.GetClamp(projectID, clampID); err != nil {
return nil, fmt.Errorf("%w: clamp not found", ErrInvalidInput)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Refuse loops — UNIQUE (cable_id, clamp_id) enforces this, but a
// pre-check gives a clearer error.
var exists int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
).Scan(&exists); err != nil {
return nil, err
}
if exists > 0 {
return nil, fmt.Errorf("%w: clamp %d already on cable %d", ErrConflict, clampID, cableID)
}
var maxOrd sql.NullInt64
if err := tx.QueryRow(
`SELECT MAX(ord) FROM cable_clamps WHERE cable_id = ?`, cableID,
).Scan(&maxOrd); err != nil {
return nil, err
}
current := 0
if maxOrd.Valid {
current = int(maxOrd.Int64)
}
if ord <= 0 || ord > current+1 {
ord = current + 1
} else if ord <= current {
// Shift existing rows at ord..current up by 1 to free the slot.
// SQLite UPDATE doesn't support ORDER BY (no UPDATE-with-temp
// trick available), so a single `ord = ord + 1` would collide
// with the UNIQUE (cable_id, ord) constraint during the bulk
// update. Two-pass avoids the conflict: bump to a high offset
// first, then settle back to ord+1.
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord + 10000
WHERE cable_id = ? AND ord >= ?`, cableID, ord,
); err != nil {
return nil, mapWriteErr(err)
}
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord - 10000 + 1
WHERE cable_id = ? AND ord >= ?`, cableID, 10000+ord,
); err != nil {
return nil, mapWriteErr(err)
}
}
if _, err := tx.Exec(
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
cableID, clampID, ord,
); err != nil {
return nil, mapWriteErr(err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &CableClamp{CableID: cableID, ClampID: clampID, Ord: ord}, nil
}
// DetachClampFromCable removes a clamp from a cable's polyline. The
// trailing rows close up to keep `ord` contiguous.
func (s *Store) DetachClampFromCable(projectID, cableID, clampID int64) error {
if _, err := s.GetCable(projectID, cableID); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var removed sql.NullInt64
if err := tx.QueryRow(
`SELECT ord FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
).Scan(&removed); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(
`DELETE FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
cableID, clampID,
); err != nil {
return mapWriteErr(err)
}
// Close the gap: anyone with ord > removed slides down by 1.
if _, err := tx.Exec(
`UPDATE cable_clamps SET ord = ord - 1
WHERE cable_id = ? AND ord > ?`, cableID, removed.Int64,
); err != nil {
return mapWriteErr(err)
}
return tx.Commit()
}
// ReorderCableClamps replaces the whole clamp sequence on a cable with
// the given clamp IDs, in order. Every member of clampIDs must already
// be a valid clamp in the same project; duplicates → ErrConflict.
func (s *Store) ReorderCableClamps(projectID, cableID int64, clampIDs []int64) ([]CableClamp, error) {
if _, err := s.GetCable(projectID, cableID); err != nil {
return nil, err
}
seen := map[int64]bool{}
for _, cid := range clampIDs {
if seen[cid] {
return nil, fmt.Errorf("%w: duplicate clamp %d", ErrConflict, cid)
}
seen[cid] = true
if _, err := s.GetClamp(projectID, cid); err != nil {
return nil, fmt.Errorf("%w: clamp %d not in project", ErrInvalidInput, cid)
}
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM cable_clamps WHERE cable_id = ?`, cableID); err != nil {
return nil, mapWriteErr(err)
}
out := make([]CableClamp, 0, len(clampIDs))
for i, cid := range clampIDs {
ord := i + 1
if _, err := tx.Exec(
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
cableID, cid, ord,
); err != nil {
return nil, mapWriteErr(err)
}
out = append(out, CableClamp{CableID: cableID, ClampID: cid, Ord: ord})
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}

188
internal/db/clamps_test.go Normal file
View File

@@ -0,0 +1,188 @@
package db
import (
"errors"
"testing"
)
func TestCreateClamp_Basic(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
c, err := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 200, Label: "trunk-1"})
if err != nil {
t.Fatalf("create: %v", err)
}
if c.X != 100 || c.Y != 200 || c.Label != "trunk-1" {
t.Errorf("bad shape: %+v", c)
}
if c.ProjectID != p.ID {
t.Errorf("project_id mismatch: got %d, want %d", c.ProjectID, p.ID)
}
}
func TestUpdateClamp_PositionAndLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
c, _ := s.CreateClamp(p.ID, ClampCreate{X: 0, Y: 0})
nx, ny := 50.0, 75.0
lbl := "renamed"
upd, err := s.UpdateClamp(p.ID, c.ID, ClampUpdate{X: &nx, Y: &ny, Label: &lbl})
if err != nil {
t.Fatalf("update: %v", err)
}
if upd.X != 50 || upd.Y != 75 || upd.Label != "renamed" {
t.Errorf("update didn't take: %+v", upd)
}
}
func TestDeleteClamp_CascadesToCableClamps(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
cl, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 50})
if _, err := s.AttachClampToCable(p.ID, cab.ID, cl.ID, 0); err != nil {
t.Fatalf("attach: %v", err)
}
if err := s.DeleteClamp(p.ID, cl.ID); err != nil {
t.Fatalf("delete: %v", err)
}
rows, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(rows) != 0 {
t.Errorf("cable_clamps not cleared: %+v", rows)
}
}
func TestAttachClampToCable_AppendsAndOrders(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
cc1, _ := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
cc2, _ := s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
cc3, _ := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
if cc1.Ord != 1 || cc2.Ord != 2 || cc3.Ord != 3 {
t.Errorf("ord sequence wrong: %d, %d, %d", cc1.Ord, cc2.Ord, cc3.Ord)
}
}
func TestAttachClampToCable_InsertShiftsExisting(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0) // ord=1
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0) // ord=2
// Insert c3 between c1 and c2 → c3 gets ord=2, old c2 bumps to 3.
if _, err := s.AttachClampToCable(p.ID, cab.ID, c3.ID, 2); err != nil {
t.Fatalf("attach mid: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 3 {
t.Fatalf("len = %d, want 3: %+v", len(got), got)
}
want := []struct{ id int64; ord int }{
{c1.ID, 1}, {c3.ID, 2}, {c2.ID, 3},
}
for i, w := range want {
if got[i].ClampID != w.id || got[i].Ord != w.ord {
t.Errorf("[%d] got %+v, want clamp=%d ord=%d", i, got[i], w.id, w.ord)
}
}
}
func TestAttachClampToCable_DuplicateRejected(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
if _, err := s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate err = %v, want ErrConflict", err)
}
}
func TestDetachClampFromCable_ClosesGap(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
_, _ = s.AttachClampToCable(p.ID, cab.ID, c1.ID, 0)
_, _ = s.AttachClampToCable(p.ID, cab.ID, c2.ID, 0)
_, _ = s.AttachClampToCable(p.ID, cab.ID, c3.ID, 0)
if err := s.DetachClampFromCable(p.ID, cab.ID, c2.ID); err != nil {
t.Fatalf("detach: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ClampID != c1.ID || got[0].Ord != 1 {
t.Errorf("[0] = %+v", got[0])
}
if got[1].ClampID != c3.ID || got[1].Ord != 2 {
t.Errorf("[1] = %+v", got[1])
}
}
func TestReorderCableClamps_FullReplace(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
pcT := builtInTypeID(t, s, "PC")
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
cab, _ := s.CreateCable(p.ID, CableCreate{TypeID: 1,
From: CableEndpoint{DeviceID: &a.ID}, To: CableEndpoint{DeviceID: &b.ID}})
c1, _ := s.CreateClamp(p.ID, ClampCreate{X: 50, Y: 0})
c2, _ := s.CreateClamp(p.ID, ClampCreate{X: 100, Y: 0})
c3, _ := s.CreateClamp(p.ID, ClampCreate{X: 150, Y: 0})
if _, err := s.ReorderCableClamps(p.ID, cab.ID, []int64{c3.ID, c1.ID, c2.ID}); err != nil {
t.Fatalf("reorder: %v", err)
}
got, _ := s.ListClampsForCable(p.ID, cab.ID)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0].ClampID != c3.ID || got[1].ClampID != c1.ID || got[2].ClampID != c2.ID {
t.Errorf("order wrong: %+v", got)
}
}
func TestSnapshot_IncludesClamps(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 10, Y: 20})
_, _ = s.CreateClamp(p.ID, ClampCreate{X: 30, Y: 40})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.Clamps) != 2 {
t.Errorf("clamps in snapshot = %d, want 2", len(snap.Clamps))
}
}

View File

@@ -1,4 +1,4 @@
// Package db owns SQLite access for mCables: migrations runner + the // Package db owns SQLite access for CableGUI: migrations runner + the
// query layer (store.go). The Store wraps a *sql.DB with helpers; tests // query layer (store.go). The Store wraps a *sql.DB with helpers; tests
// and the HTTP layer take a *Store, never a raw *sql.DB. // and the HTTP layer take a *Store, never a raw *sql.DB.
package db package db

View File

@@ -17,6 +17,7 @@ func TestSeed_BuiltInDeviceTypes(t *testing.T) {
"NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz", "NAS", "PC", "Mac", "Notebook", "TV", "Soundbar", "Switch", "fritz",
"ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8", "ChromeCast", "SteamLink", "IOx-3", "IOx-6", "IOx-8",
"Screen", "Keyboard", "Mouse", "Screen", "Keyboard", "Mouse",
"Multi-plug 3", "Multi-plug 4", "Multi-plug 5", "Multi-plug 6", "Wifi-plug",
} }
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("built-in count = %d, want %d", len(got), len(want)) t.Fatalf("built-in count = %d, want %d", len(got), len(want))
@@ -54,12 +55,17 @@ func TestSeed_PortProfiles(t *testing.T) {
"fritz": {5}, // Power 1 + RJ45 4 "fritz": {5}, // Power 1 + RJ45 4
"ChromeCast": {2}, // Power 1 + HDMI 1 "ChromeCast": {2}, // Power 1 + HDMI 1
"SteamLink": {4}, // Power 1 + HDMI 1 + USB 2 "SteamLink": {4}, // Power 1 + HDMI 1 + USB 2
"IOx-3": {4}, // Power 1 + USB 3 "IOx-3": {4}, // Power In 1 + Power Out 3 (after v6)
"IOx-6": {7}, // Power 1 + USB 6 "IOx-6": {7}, // Power In 1 + Power Out 6 (after v6)
"IOx-8": {9}, // Power 1 + USB 8 "IOx-8": {9}, // Power In 1 + Power Out 8 (after v6)
"Screen": {2}, // Power 1 + HDMI 1 "Screen": {2}, // Power 1 + HDMI 1
"Keyboard": {1}, // USB 1 "Keyboard": {1}, // USB 1
"Mouse": {1}, // USB 1 "Mouse": {1}, // USB 1
"Multi-plug 3": {4}, // Power In 1 + Power Out 3 (after v6)
"Multi-plug 4": {5}, // Power In 1 + Power Out 4 (after v6)
"Multi-plug 5": {6}, // Power In 1 + Power Out 5 (after v6)
"Multi-plug 6": {7}, // Power In 1 + Power Out 6 (after v6)
"Wifi-plug": {2}, // Power In 1 + Power Out 1 (after v6)
} }
for name, want := range cases { for name, want := range cases {
dt, ok := byName[name] dt, ok := byName[name]
@@ -77,6 +83,80 @@ func TestSeed_PortProfiles(t *testing.T) {
} }
} }
// TestSeed_PowerHubs locks down the post-migration-006 port profile for
// every power-distribution device type: IOx-3/6/8, Multi-plug 3/4/5/6,
// and Wifi-plug. Each carries exactly two profile rows — a single
// "Power In" port on the top (back) edge and N "Power Out" ports on the
// bottom (front) edge, where N is the device-specific output count.
//
// This test covers the v5 catalog identity (kind, icon, built-in) for
// the 5 power-distribution types and the v6 port-profile fix for all
// 8 hubs in one table.
func TestSeed_PowerHubs(t *testing.T) {
s := newTestStore(t)
all, err := s.ListBuiltInDeviceTypes()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(all) != 21 {
t.Errorf("built-in count = %d, want 21 (16 from v4 + 5 from v5)", len(all))
}
byName := map[string]DeviceType{}
for _, d := range all {
byName[d.Name] = d
}
cases := []struct {
name string
// kind/icon are only set for the 5 v5-power types; empty means
// "don't check" (the IOx-* keep their v4-seeded kind=hub icon=nil).
kind string
icon string
outCount int // N — number of "Power Out" outlets on the bottom edge
}{
// v5 catalog (kind+icon checked)
{name: "Multi-plug 3", kind: "hub", icon: "🔌", outCount: 3},
{name: "Multi-plug 4", kind: "hub", icon: "🔌", outCount: 4},
{name: "Multi-plug 5", kind: "hub", icon: "🔌", outCount: 5},
{name: "Multi-plug 6", kind: "hub", icon: "🔌", outCount: 6},
{name: "Wifi-plug", kind: "accessory", icon: "📶", outCount: 1},
// v4 hubs re-shaped by v6 (kind/icon left blank → not checked)
{name: "IOx-3", outCount: 3},
{name: "IOx-6", outCount: 6},
{name: "IOx-8", outCount: 8},
}
for _, c := range cases {
dt, ok := byName[c.name]
if !ok {
t.Errorf("missing %q", c.name)
continue
}
if !dt.BuiltIn {
t.Errorf("%s: built_in should be true", c.name)
}
if dt.ProjectID != nil {
t.Errorf("%s: project_id should be nil", c.name)
}
if c.kind != "" && dt.Kind != c.kind {
t.Errorf("%s: kind = %q, want %q", c.name, dt.Kind, c.kind)
}
if c.icon != "" && (dt.Icon == nil || *dt.Icon != c.icon) {
t.Errorf("%s: icon = %v, want %q", c.name, dt.Icon, c.icon)
}
if len(dt.Ports) != 2 {
t.Errorf("%s: expected 2 port-profile rows, got %d", c.name, len(dt.Ports))
continue
}
in := dt.Ports[0]
out := dt.Ports[1]
if in.CableTypeID != 1 || in.Count != 1 || in.Edge != "top" || in.LabelPrefix != "Power In" {
t.Errorf("%s: Power In row mismatch: %+v", c.name, in)
}
if out.CableTypeID != 1 || out.Count != c.outCount || out.Edge != "bottom" || out.LabelPrefix != "Power Out" {
t.Errorf("%s: Power Out row mismatch: %+v (want count=%d)", c.name, out, c.outCount)
}
}
}
// -------------------------------------------------------- CRUD (custom rows) // -------------------------------------------------------- CRUD (custom rows)
func TestCreateDeviceType_CustomBasic(t *testing.T) { func TestCreateDeviceType_CustomBasic(t *testing.T) {

View File

@@ -0,0 +1,63 @@
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, clamps 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
}
if err := updateExIDs(tx, "clamps", projectID, clamps); 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
}

View File

@@ -1,4 +1,4 @@
-- mCables v3 initial schema. See docs/design.md §2. -- CableGUI v3 initial schema. See docs/design.md §2.
-- A project IS a drawing. LOFT and OFFICE are separate projects. -- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw. -- One project ↔ one .excalidraw file in mExDraw.

View File

@@ -1,4 +1,4 @@
-- mCables v4 device-type catalog. See docs/design.md §2.1 + §2.2. -- CableGUI v4 device-type catalog. See docs/design.md §2.1 + §2.2.
-- v4 — device-type catalog. Built-in types live globally (project_id NULL). -- v4 — device-type catalog. Built-in types live globally (project_id NULL).
-- Per-project custom types use project_id = X. -- Per-project custom types use project_id = X.

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 connection requirements + solver-owned cable flag. -- CableGUI v4.1 connection requirements + solver-owned cable flag.
-- See docs/design.md §2.1 + §2 connection_requirements + §5b.3. -- See docs/design.md §2.1 + §2 connection_requirements + §5b.3.
-- The solver's input: "device A must connect to device B via cable type T". -- The solver's input: "device A must connect to device B via cable type T".

View File

@@ -1,4 +1,4 @@
-- mCables v4.1 setup templates. See docs/design.md §2.4. -- CableGUI v4.1 setup templates. See docs/design.md §2.4.
-- --
-- A template is a named recipe of (device_types + requirements) that -- A template is a named recipe of (device_types + requirements) that
-- bootstraps a project from blank to solver-ready in one apply call. -- bootstraps a project from blank to solver-ready in one apply call.

View File

@@ -0,0 +1,32 @@
-- CableGUI v5 — catalog: power-distribution devices.
-- Adds 5 built-in device_types (project_id NULL, built_in=1).
--
-- Multi-plug N exposes Power × (N+1) ports — one input + N outputs. The
-- solver treats every Power port identically regardless of in/out
-- direction; m knows which end is which from the physical setup.
--
-- Wifi-plug is a pass-through outlet (Power × 2: one in, one out).
INSERT INTO device_types (name, kind, icon, built_in, description) VALUES
('Multi-plug 3', 'hub', '🔌', 1, '3-way power strip (1 in + 3 out)'),
('Multi-plug 4', 'hub', '🔌', 1, '4-way power strip (1 in + 4 out)'),
('Multi-plug 5', 'hub', '🔌', 1, '5-way power strip (1 in + 5 out)'),
('Multi-plug 6', 'hub', '🔌', 1, '6-way power strip (1 in + 6 out)'),
('Wifi-plug', 'accessory', '📶', 1, 'WiFi-controllable pass-through outlet');
-- Port profiles. cable_types id 1 = Power (seeded in 001).
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 4, 'bottom', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 5, 'bottom', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 6, 'bottom', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 7, 'bottom', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power', 2, 'bottom', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -0,0 +1,87 @@
-- CableGUI v6 — fix IOx-* and Multi-plug-* + Wifi-plug port profiles.
--
-- v4 seeded the IOx-3 / IOx-6 / IOx-8 as USB hubs (Power × 1 + USB × N),
-- but m's physical IOx-* devices are power strips (1 power input on
-- the back, N power outputs on the front). v5's Multi-plug 3/4/5/6
-- profiles also lumped every Power port on the bottom edge without
-- distinguishing the input from the outputs.
--
-- This migration replaces the port profile for the 8 power-distribution
-- types with the canonical "1 in (top/back) + N out (bottom/front)"
-- layout. Convention: top=back, bottom=front.
--
-- N for each type:
-- IOx-3 / Multi-plug 3 → 3 outputs
-- IOx-6 → 6 outputs
-- IOx-8 → 8 outputs
-- Multi-plug 4 → 4 outputs
-- Multi-plug 5 → 5 outputs
-- Multi-plug 6 → 6 outputs
-- Wifi-plug → 1 output (it's a pass-through outlet)
--
-- Existing devices m may have created with the old profile keep their
-- already-seeded ports — per design §2.3, ports are instance-owned. To
-- get the new layout on an existing instance, delete it and re-create.
--
-- cable_types id 1 = Power (seeded in 001).
-- 1) Drop the existing port-profile rows for each affected type.
DELETE FROM device_type_ports
WHERE device_type_id IN (
SELECT id FROM device_types
WHERE project_id IS NULL
AND name IN (
'IOx-3', 'IOx-6', 'IOx-8',
'Multi-plug 3', 'Multi-plug 4', 'Multi-plug 5', 'Multi-plug 6',
'Wifi-plug'
)
);
-- 2) Insert the canonical (1 in on top, N out on bottom) profile.
-- IOx-3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='IOx-3' AND project_id IS NULL;
-- IOx-6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='IOx-6' AND project_id IS NULL;
-- IOx-8 — 1 in + 8 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 8, 'bottom', 1 FROM device_types WHERE name='IOx-8' AND project_id IS NULL;
-- Multi-plug 3 — 1 in + 3 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 3, 'bottom', 1 FROM device_types WHERE name='Multi-plug 3' AND project_id IS NULL;
-- Multi-plug 4 — 1 in + 4 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 4, 'bottom', 1 FROM device_types WHERE name='Multi-plug 4' AND project_id IS NULL;
-- Multi-plug 5 — 1 in + 5 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 5, 'bottom', 1 FROM device_types WHERE name='Multi-plug 5' AND project_id IS NULL;
-- Multi-plug 6 — 1 in + 6 out
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 6, 'bottom', 1 FROM device_types WHERE name='Multi-plug 6' AND project_id IS NULL;
-- Wifi-plug — 1 in + 1 out (pass-through outlet)
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power In', 1, 'top', 0 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;
INSERT INTO device_type_ports (device_type_id, cable_type_id, label_prefix, count, edge, sort_order)
SELECT id, 1, 'Power Out', 1, 'bottom', 1 FROM device_types WHERE name='Wifi-plug' AND project_id IS NULL;

View File

@@ -0,0 +1,31 @@
-- CableGUI v5 — cable routing via clamps. See docs/design.md §11.
--
-- A clamp is a physical anchor placed on the canvas. A cable's polyline
-- runs from its `from` endpoint → its clamps in `ord` sequence → its
-- `to` endpoint. Cables that share an ordered pair of consecutive
-- clamps are visibly bundled along that segment (computed live by the
-- frontend; no detection pass).
CREATE TABLE clamps (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
x REAL NOT NULL,
y REAL NOT NULL,
label TEXT NOT NULL DEFAULT '',
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX clamps_project_idx ON clamps(project_id);
CREATE INDEX clamps_frame_idx ON clamps(frame_id);
CREATE TABLE cable_clamps (
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
ord INTEGER NOT NULL, -- 1-based along from→to
PRIMARY KEY (cable_id, ord),
UNIQUE (cable_id, clamp_id)
);
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);

View File

@@ -191,10 +191,11 @@ type UnsatisfiedReq struct {
// ApplyTemplateResult is the response from POST /apply-template. // ApplyTemplateResult is the response from POST /apply-template.
type ApplyTemplateResult struct { type ApplyTemplateResult struct {
DevicesAdded []Device `json:"devices_added"` FramesAdded []Frame `json:"frames_added"`
RequirementsAdded []ConnectionRequirement `json:"requirements_added"` DevicesAdded []Device `json:"devices_added"`
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"` RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"` SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
} }
type SkippedTemplateDevice struct { type SkippedTemplateDevice struct {
@@ -219,4 +220,29 @@ type Snapshot struct {
Bundles []Bundle `json:"bundles"` Bundles []Bundle `json:"bundles"`
CableTypes []CableType `json:"cable_types"` CableTypes []CableType `json:"cable_types"`
ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"` ConnectionRequirements []ConnectionRequirement `json:"connection_requirements"`
Clamps []Clamp `json:"clamps"`
CableClamps []CableClamp `json:"cable_clamps"`
}
// Clamp is a routing anchor on the canvas. Cables route through clamps
// in `ord` sequence (see cable_clamps), giving m a physical handle on
// where bundles converge.
type Clamp struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Label string `json:"label"`
FrameID *int64 `json:"frame_id"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CableClamp is one (cable, clamp, ord) row. Ord is 1-based along the
// cable's from→to direction.
type CableClamp struct {
CableID int64 `json:"cable_id"`
ClampID int64 `json:"clamp_id"`
Ord int `json:"ord"`
} }

View File

@@ -2,8 +2,187 @@ package db
import ( import (
"database/sql" "database/sql"
"errors"
"fmt"
"strings"
) )
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
type PortCreate struct {
TypeID int64
Label string
XOffset float64
YOffset float64
}
// PortUpdate is the partial-update shape.
type PortUpdate struct {
TypeID *int64
Label *string
XOffset *float64
YOffset *float64
}
// CreatePort inserts a port on a device. The device must exist in the
// project; the cable type must exist globally.
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
if _, err := s.GetDevice(projectID, deviceID); err != nil {
return nil, err
}
if _, err := s.GetCableType(p.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
}
return nil, err
}
label := strings.TrimSpace(p.Label)
var labelArg any
if label != "" {
labelArg = label
}
res, err := s.db.Exec(
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetPort(projectID, id)
}
// GetPort loads a port by id, project-scoped.
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
var p Port
var label, ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
return &p, nil
}
// UpdatePort applies a partial update.
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
cur, err := s.GetPort(projectID, id)
if err != nil {
return nil, err
}
if u.TypeID != nil {
if _, err := s.GetCableType(*u.TypeID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
}
return nil, err
}
cur.TypeID = *u.TypeID
}
if u.Label != nil {
v := strings.TrimSpace(*u.Label)
if v == "" {
cur.Label = nil
} else {
cur.Label = &v
}
}
if u.XOffset != nil {
cur.XOffset = *u.XOffset
}
if u.YOffset != nil {
cur.YOffset = *u.YOffset
}
var labelArg any
if cur.Label != nil {
labelArg = *cur.Label
}
if _, err := s.db.Exec(
`UPDATE ports
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetPort(projectID, id)
}
// DeletePort removes a port from a device. The schema's
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
// we instead cascade-delete any cables that referenced the port on
// either side — same effect from m's POV: the cable is gone, m can
// re-draw if he still wants it.
func (s *Store) DeletePort(projectID, id int64) error {
if _, err := s.GetPort(projectID, id); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
projectID, id, id,
); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return tx.Commit()
}
// ListPortsForDevice returns every port on one device, project-scoped.
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
rows, err := s.db.Query(
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
excalidraw_id, created_at, updated_at
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
projectID, deviceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Port{}
for rows.Next() {
var p Port
var label, ex sql.NullString
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
if label.Valid {
v := label.String
p.Label = &v
}
if ex.Valid {
p.ExcalidrawID = &ex.String
}
out = append(out, p)
}
return out, rows.Err()
}
// ListPortsForProject returns every port in a project, ordered by // ListPortsForProject returns every port in a project, ordered by
// device_id + id so callers can group cheaply. // device_id + id so callers can group cheaply.
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) { func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
) )
@@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
} }
out := &ApplyTemplateResult{ out := &ApplyTemplateResult{
FramesAdded: []Frame{},
DevicesAdded: []Device{}, DevicesAdded: []Device{},
RequirementsAdded: []ConnectionRequirement{}, RequirementsAdded: []ConnectionRequirement{},
SkippedDevices: []SkippedTemplateDevice{}, SkippedDevices: []SkippedTemplateDevice{},
@@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
opts.OriginX, opts.OriginY = 200, 200 opts.OriginX, opts.OriginY = 200, 200
} }
// Pull existing device names in the project so we can pre-check // Pull existing device + frame names in the project so we can
// collisions without aborting the whole transaction. // pre-check collisions without aborting the whole transaction.
existing, err := s.ListDevices(projectID, nil) existing, err := s.ListDevices(projectID, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
for _, d := range existing { for _, d := range existing {
nameTaken[d.Name] = true nameTaken[d.Name] = true
} }
existingFrames, err := s.ListFrames(projectID)
if err != nil {
return nil, err
}
frameNameTaken := map[string]bool{}
for _, f := range existingFrames {
frameNameTaken[f.Name] = true
}
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
} }
defer tx.Rollback() defer tx.Rollback()
// Plan a uniform grid for the template's devices inside a new frame
// named after the template. The grid drives both frame size and
// per-device (x, y). Devices that get skipped (name collision /
// SkipDevices) leave their grid cell empty.
const (
devW, devH = 100.0, 35.0
gapX, gapY = 30.0, 50.0
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
)
n := len(tmpl.Devices)
cols := 1
if n > 0 {
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
}
rows := 1
if n > 0 {
rows = (n + cols - 1) / cols
}
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
frameName := pickFrameName(tmpl.Name, frameNameTaken)
frame, err := createFrameTx(tx, projectID, FrameCreate{
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
Width: frameW, Height: frameH,
})
if err != nil {
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
}
out.FramesAdded = append(out.FramesAdded, *frame)
// Map: template_device_id → newly-created device_id (or 0 if skipped). // Map: template_device_id → newly-created device_id (or 0 if skipped).
tmplToDevice := map[int64]int64{} tmplToDevice := map[int64]int64{}
@@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
tmplToDevice[td.ID] = 0 tmplToDevice[td.ID] = 0
continue continue
} }
// Lay out devices in a horizontal row near the origin, 150 px apart. // Grid cell (col, row) within the frame. Cell anchor is the
x := opts.OriginX + float64(i)*150 // top-left of the device rect; offsets are added to the frame's
y := opts.OriginY // own (x, y) so the device sits inside the frame.
// Use createDeviceTx so the port-seeding share the same transaction. col := i % cols
row := i / cols
x := frame.X + padX + float64(col)*(devW+gapX)
y := frame.Y + padY + float64(row)*(devH+gapY)
// Use createDeviceTx so port-seeding shares the same transaction.
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{ d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
Name: name, Name: name,
TypeID: &td.DeviceTypeID, TypeID: &td.DeviceTypeID,
FrameID: &frame.ID,
X: x, X: x,
Y: y, Y: y,
Width: 100, Width: devW,
Height: 35, Height: devH,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("seed %s: %w", name, err) return nil, fmt.Errorf("seed %s: %w", name, err)
@@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
return out, nil return out, nil
} }
// pickFrameName returns a frame name that doesn't collide with anything
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
// and so on.
func pickFrameName(base string, taken map[string]bool) string {
if !taken[base] {
return base
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s %d", base, i)
if !taken[candidate] {
return candidate
}
}
}
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
// the validation in CreateFrame (name + positive size) but avoids the
// s.db.Exec call so ApplyTemplate can keep everything on the same
// connection under MaxOpenConns(1).
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
name := strings.TrimSpace(f.Name)
if name == "" {
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if f.Width <= 0 || f.Height <= 0 {
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
}
res, err := tx.Exec(
`INSERT INTO frames (project_id, name, x, y, width, height)
VALUES (?, ?, ?, ?, ?, ?)`,
projectID, name, f.X, f.Y, f.Width, f.Height,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
var out Frame
var ex sql.NullString
err = tx.QueryRow(
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
&ex, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
if ex.Valid {
out.ExcalidrawID = &ex.String
}
return &out, nil
}
// createDeviceTx is a tx-aware variant of CreateDevice used by // createDeviceTx is a tx-aware variant of CreateDevice used by
// ApplyTemplate so seeding the template's devices + their ports stays // ApplyTemplate so seeding the template's devices + their ports stays
// inside one atomic apply. // inside one atomic apply.

View File

@@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
} }
} }
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
frame := res.FramesAdded[0]
if frame.Name != "Living Room" {
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
}
for _, d := range res.DevicesAdded {
if d.FrameID == nil || *d.FrameID != frame.ID {
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
}
// Device top-left should be inside the frame rect.
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
}
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
}
}
// No two devices share the same (X, Y) — the grid layout spreads them out.
seen := map[[2]float64]string{}
for _, d := range res.DevicesAdded {
key := [2]float64{d.X, d.Y}
if prev, ok := seen[key]; ok {
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
}
seen[key] = d.Name
}
}
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
// Pre-create a frame called "Living Room" so the template's frame name collides.
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
tmpls, _ := s.ListSetupTemplates()
var lr SetupTemplate
for _, tm := range tmpls {
if tm.Name == "Living Room" {
lr = tm
break
}
}
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.FramesAdded) != 1 {
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
}
if res.FramesAdded[0].Name != "Living Room 2" {
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
}
}
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) { func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "") p, _ := s.CreateProject("LOFT", "", "")

View File

@@ -187,6 +187,14 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
clamps, err := s.ListClamps(id)
if err != nil {
return nil, err
}
cableClamps, err := s.ListCableClamps(id)
if err != nil {
return nil, err
}
return &Snapshot{ return &Snapshot{
Project: *p, Project: *p,
Frames: frames, Frames: frames,
@@ -197,6 +205,8 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
Bundles: bundles, Bundles: bundles,
CableTypes: types, CableTypes: types,
ConnectionRequirements: reqs, ConnectionRequirements: reqs,
Clamps: clamps,
CableClamps: cableClamps,
}, nil }, nil
} }

View File

@@ -0,0 +1,630 @@
// 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"
"sort"
"mgit.msbls.de/m/cablegui/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"`
Clamps map[int64]string `json:"clamps"`
}
// 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{},
Clamps: 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,
})
}
// Clamps — small grey rounded squares (v5 §11.7). Distinct from the
// red IO marker diamonds so m can tell routing anchors from wall
// outlets at a glance.
const clampSize = 12.0
for _, cl := range snap.Clamps {
elID := idFor(cl.ExcalidrawID)
a.Clamps[cl.ID] = elID
var frameRef *string
if cl.FrameID != nil {
if v, ok := frameElID[*cl.FrameID]; ok {
frameRef = &v
}
}
els = append(els, Element{
ID: elID,
Type: "rectangle",
X: cl.X - clampSize/2,
Y: cl.Y - clampSize/2,
Width: clampSize,
Height: clampSize,
StrokeColor: "#555555",
BackgroundColor: "#888888",
FillStyle: "solid",
StrokeWidth: 1,
StrokeStyle: "solid",
Roughness: 0,
Opacity: 100,
GroupIDs: []string{},
FrameID: frameRef,
Roundness: &Roundness{Type: 3},
Seed: randInt(),
Version: 1,
VersionNonce: randInt(),
Updated: nowMilli,
})
}
// Pre-group cable_clamps by cable for the arrow mid-points pass.
clampsByCable := map[int64][]db.CableClamp{}
for _, cc := range snap.CableClamps {
clampsByCable[cc.CableID] = append(clampsByCable[cc.CableID], cc)
}
for _, arr := range clampsByCable {
// Already sorted by ListCableClamps (ORDER BY cable_id, ord),
// but defend against unsorted inputs.
sort.Slice(arr, func(i, j int) bool { return arr[i].Ord < arr[j].Ord })
}
clampPos := map[int64][2]float64{}
for _, cl := range snap.Clamps {
clampPos[cl.ID] = [2]float64{cl.X, cl.Y}
}
// 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"
// Excalidraw arrow `points` is relative to (X, Y). We anchor at
// the from-point, so vertex 0 is always (0, 0). Mid-vertices
// (clamps) and the final to-vertex are offsets from there.
pts := [][2]float64{{0, 0}}
for _, cc := range clampsByCable[c.ID] {
pos, ok := clampPos[cc.ClampID]
if !ok {
continue
}
pts = append(pts, [2]float64{pos[0] - fromAnchor[0], pos[1] - fromAnchor[1]})
}
pts = append(pts, [2]float64{toAnchor[0] - fromAnchor[0], toAnchor[1] - fromAnchor[1]})
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: pts,
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: "cablegui",
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)
}

View File

@@ -0,0 +1,225 @@
package exporter
import (
"encoding/json"
"strings"
"testing"
"mgit.msbls.de/m/cablegui/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 TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
snap := sampleSnapshot()
snap.Clamps = []db.Clamp{
{ID: 1, ProjectID: 1, X: 500, Y: 300},
{ID: 2, ProjectID: 1, X: 550, Y: 320},
}
scene, ids := BuildScene(snap, 1700000000000, newSeq())
if len(ids.Clamps) != 2 {
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
}
clampElIDs := map[string]bool{}
for _, id := range ids.Clamps {
clampElIDs[id] = true
}
got := 0
for _, e := range scene.Elements {
if clampElIDs[e.ID] && e.Type == "rectangle" {
got++
}
}
if got != 2 {
t.Errorf("clamp rectangle elements = %d, want 2", got)
}
}
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
snap := sampleSnapshot()
snap.Clamps = []db.Clamp{
{ID: 10, ProjectID: 1, X: 350, Y: 250},
}
snap.CableClamps = []db.CableClamp{
{CableID: 1000, ClampID: 10, Ord: 1},
}
scene, _ := BuildScene(snap, 1700000000000, newSeq())
var arrow *Element
for i := range scene.Elements {
if scene.Elements[i].Type == "arrow" {
arrow = &scene.Elements[i]
break
}
}
if arrow == nil {
t.Fatal("no arrow in scene")
}
if len(arrow.Points) != 3 {
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
}
// First point is always (0, 0) by convention; middle point should
// equal the clamp's position relative to the arrow's anchor.
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
}
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
wantX, wantY := 350.0-250.0, 250.0-235.0
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
}
}
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)
}
}

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type cableEndpointBody struct { type cableEndpointBody struct {

195
internal/server/clamps.go Normal file
View File

@@ -0,0 +1,195 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type clampCreate struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Label string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
}
type clampPatch struct {
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Label *string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
}
type cableClampAttach struct {
ClampID int64 `json:"clamp_id"`
Ord int `json:"ord,omitempty"`
}
type cableClampReorder struct {
ClampIDs []int64 `json:"clamp_ids"`
}
func (h *handlers) listClamps(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cs, err := h.store.ListClamps(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, cs)
}
func (h *handlers) createClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body clampCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
c, err := h.store.CreateClamp(pid, db.ClampCreate{
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref.ID,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *handlers) patchClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body clampPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
c, err := h.store.UpdateClamp(pid, id, db.ClampUpdate{
X: body.X, Y: body.Y, Label: body.Label, FrameID: ref,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, c)
}
func (h *handlers) deleteClamp(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteClamp(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/projects/:pid/cables/:cid/clamps — attach a clamp to a cable.
func (h *handlers) attachClampToCable(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
var body cableClampAttach
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
cc, err := h.store.AttachClampToCable(pid, cid, body.ClampID, body.Ord)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, cc)
}
// DELETE /api/projects/:pid/cables/:cid/clamps/:cmid — detach a clamp.
func (h *handlers) detachClampFromCable(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
cmid, ok := parseInt64Path(r, "cmid")
if !ok {
writeError(w, db.ErrInvalidInput, "cmid must be a positive integer")
return
}
if err := h.store.DetachClampFromCable(pid, cid, cmid); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PUT /api/projects/:pid/cables/:cid/clamps — replace clamp sequence.
func (h *handlers) reorderCableClamps(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
cid, ok := parseInt64Path(r, "cid")
if !ok {
writeError(w, db.ErrInvalidInput, "cid must be a positive integer")
return
}
var body cableClampReorder
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
out, err := h.store.ReorderCableClamps(pid, cid, body.ClampIDs)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type connReqCreate struct { type connReqCreate struct {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type deviceTypePortBody struct { type deviceTypePortBody struct {

122
internal/server/export.go Normal file
View File

@@ -0,0 +1,122 @@
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

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// ---------------------------------------------------------------- frames // ---------------------------------------------------------------- frames

View File

@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type handlers struct { type handlers struct {

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
type ioMarkerCreate struct { type ioMarkerCreate struct {

114
internal/server/ports.go Normal file
View File

@@ -0,0 +1,114 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/cablegui/internal/db"
)
type portCreate struct {
TypeID int64 `json:"type_id"`
Label string `json:"label,omitempty"`
XOffset float64 `json:"x_offset"`
YOffset float64 `json:"y_offset"`
}
type portPatch struct {
TypeID *int64 `json:"type_id,omitempty"`
Label *string `json:"label,omitempty"`
XOffset *float64 `json:"x_offset,omitempty"`
YOffset *float64 `json:"y_offset,omitempty"`
}
func (h *handlers) listPortsForDevice(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
ps, err := h.store.ListPortsForDevice(pid, id)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ps)
}
func (h *handlers) createPort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body portCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.CreatePort(pid, id, db.PortCreate{
TypeID: body.TypeID, Label: body.Label,
XOffset: body.XOffset, YOffset: body.YOffset,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *handlers) patchPort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body portPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
p, err := h.store.UpdatePort(pid, id, db.PortUpdate{
TypeID: body.TypeID, Label: body.Label,
XOffset: body.XOffset, YOffset: body.YOffset,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *handlers) deletePort(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeletePort(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -7,10 +7,10 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
// New returns an http.Handler serving the mCables API at /api/ and the // New returns an http.Handler serving the CableGUI API at /api/ and the
// embedded frontend at /. The frontend FS should be rooted such that // embedded frontend at /. The frontend FS should be rooted such that
// "index.html" is at its root. // "index.html" is at its root.
func New(store *db.Store, frontend fs.FS) http.Handler { func New(store *db.Store, frontend fs.FS) http.Handler {
@@ -51,6 +51,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker) mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker) mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
// Ports — slice 7 lets m add/edit/remove instance ports on a device.
mux.HandleFunc("GET /api/projects/{pid}/devices/{id}/ports", h.listPortsForDevice)
mux.HandleFunc("POST /api/projects/{pid}/devices/{id}/ports", h.createPort)
mux.HandleFunc("PATCH /api/projects/{pid}/ports/{id}", h.patchPort)
mux.HandleFunc("DELETE /api/projects/{pid}/ports/{id}", h.deletePort)
// Device-type catalog. Built-ins are read-only; project-custom rows // Device-type catalog. Built-ins are read-only; project-custom rows
// support full CRUD scoped to the project. // support full CRUD scoped to the project.
mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes) mux.HandleFunc("GET /api/device-types", h.listBuiltInDeviceTypes)
@@ -84,6 +90,18 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates) mux.HandleFunc("GET /api/setup-templates", h.listSetupTemplates)
mux.HandleFunc("POST /api/projects/{pid}/apply-template", h.applyTemplate) 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)
// v5 — clamps + cable routing.
mux.HandleFunc("GET /api/projects/{pid}/clamps", h.listClamps)
mux.HandleFunc("POST /api/projects/{pid}/clamps", h.createClamp)
mux.HandleFunc("PATCH /api/projects/{pid}/clamps/{id}", h.patchClamp)
mux.HandleFunc("DELETE /api/projects/{pid}/clamps/{id}", h.deleteClamp)
mux.HandleFunc("POST /api/projects/{pid}/cables/{cid}/clamps", h.attachClampToCable)
mux.HandleFunc("PUT /api/projects/{pid}/cables/{cid}/clamps", h.reorderCableClamps)
mux.HandleFunc("DELETE /api/projects/{pid}/cables/{cid}/clamps/{cmid}", h.detachClampFromCable)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS. // Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified // Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively // the file server already emits — without this, browsers cache aggressively

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"mgit.msbls.de/m/mcables/internal/db" "mgit.msbls.de/m/cablegui/internal/db"
) )
func (h *handlers) solve(w http.ResponseWriter, r *http.Request) { func (h *handlers) solve(w http.ResponseWriter, r *http.Request) {
@@ -108,7 +108,27 @@ func (h *handlers) applyTemplate(w http.ResponseWriter, r *http.Request) {
writeError(w, err, nil) writeError(w, err, nil)
return return
} }
writeJSON(w, http.StatusOK, res)
// Auto-solve by default. ?solve=0 opts out for power users who want
// to inspect the seeded devices/requirements before the solver runs.
// This is THE fix for the v6 UX hole: m hit Apply, saw an empty
// canvas because nothing reloaded *and* nothing solved. With the
// frontend re-snapshotting after the POST returns and the response
// already carrying solver output, m sees the wired diagram in one click.
skipSolve := r.URL.Query().Get("solve") == "0"
combined := map[string]any{"template_apply": res}
if !skipSolve {
solveRes, err := h.store.Solve(pid, false)
if err != nil {
// Apply succeeded but Solve failed — don't 500 the whole
// call. Return template_apply with the solve error inline so
// the UI can recover (devices are there; m can re-solve).
combined["solve_error"] = err.Error()
} else {
combined["solve"] = solveRes
}
}
writeJSON(w, http.StatusOK, combined)
} }
// fmtSscan parses a base-10 int from a string, returning (n, nil) on success. // fmtSscan parses a base-10 int from a string, returning (n, nil) on success.

View File

@@ -3,12 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mCables</title> <title>CableGUI</title>
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<span class="brand">mCables</span> <span class="brand">CableGUI</span>
<div class="project-picker"> <div class="project-picker">
<label for="project-select" class="sr-only">Project</label> <label for="project-select" class="sr-only">Project</label>
<select id="project-select" aria-label="Active project"> <select id="project-select" aria-label="Active project">
@@ -22,9 +22,13 @@
<div class="topbar-spacer"></div> <div class="topbar-spacer"></div>
<button type="button" id="btn-apply-template" class="btn">Apply template…</button> <button type="button" id="btn-apply-template" class="btn">Apply template…</button>
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button> <button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
<button type="button" id="btn-export" class="btn" disabled title="Slice 8"> <button type="button" id="btn-export" class="btn">Export</button>
Export <button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
</button> <span class="zoom-cluster">
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
</span>
<span id="toast" class="toast" hidden></span>
</header> </header>
<main class="layout"> <main class="layout">
@@ -32,12 +36,6 @@
<section class="legend"> <section class="legend">
<h2 class="sidebar-heading">Cable types</h2> <h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul> <ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="requirements">
<h2 class="sidebar-heading">Requirements</h2>
<ul id="requirement-list" class="requirement-list"></ul>
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
</section> </section>
<section class="tools"> <section class="tools">
<h2 class="sidebar-heading">Tools</h2> <h2 class="sidebar-heading">Tools</h2>
@@ -45,18 +43,22 @@
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li> <li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li> <li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li> <li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li> <li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li> <li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
</ul> </ul>
</section> </section>
</aside> </aside>
<section class="canvas-wrap" aria-label="Diagram"> <section class="canvas-wrap" aria-label="Diagram">
<svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet"> <svg id="canvas" viewBox="0 0 2000 1500" preserveAspectRatio="xMidYMid meet">
<defs id="canvas-defs"></defs>
<g id="canvas-frames"></g> <g id="canvas-frames"></g>
<g id="canvas-devices"></g> <g id="canvas-devices"></g>
<g id="canvas-ports"></g> <g id="canvas-ports"></g>
<g id="canvas-cables"></g> <g id="canvas-cables"></g>
<g id="canvas-bundles"></g>
<g id="canvas-clamps"></g>
<g id="canvas-io"></g> <g id="canvas-io"></g>
</svg> </svg>
<p id="empty-hint" class="empty-hint"> <p id="empty-hint" class="empty-hint">
@@ -225,6 +227,24 @@
</form> </form>
</dialog> </dialog>
<!-- Admin: projects + cable types + device types + setup templates -->
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
<div class="admin-shell">
<header class="admin-header">
<h2 id="adm-title">Admin</h2>
<button type="button" class="btn btn-link admin-close" data-close></button>
</header>
<nav class="admin-tabs" role="tablist">
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
</nav>
<section class="admin-body" id="admin-body" role="tabpanel"></section>
</div>
</dialog>
<script type="module" src="/main.js"></script> <script type="module" src="/main.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -180,17 +180,43 @@ body {
fill: var(--accent); fill: var(--accent);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
pointer-events: none; cursor: grab;
} }
/* Frame bottom-right resize affordance. Mirrors .device-resize-handle
but uses the accent-on-frame palette so it reads as part of the frame
chrome rather than the device. */
.frame-resize-handle {
fill: rgba(0, 0, 0, 0.15);
stroke: rgba(0, 0, 0, 0.25);
stroke-width: 1;
cursor: nwse-resize;
}
.frame-resize-handle:hover {
fill: rgba(0, 0, 0, 0.3);
}
/* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */
.device-rect { .device-rect {
fill: #fff;
stroke: var(--text);
stroke-width: 1.5; stroke-width: 1.5;
} }
.device-rect.selected { stroke-width: 3; } .device-rect.selected { stroke-width: 3; }
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); } .device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
/* Bottom-right resize affordance per device. Subtle grey by default,
stronger on hover so m can find it without it dominating the rect. */
.device-resize-handle {
fill: rgba(120, 120, 120, 0.35);
stroke: rgba(60, 60, 60, 0.45);
stroke-width: 1;
cursor: nwse-resize;
}
.device-resize-handle:hover {
fill: rgba(60, 60, 60, 0.65);
}
.device-label { .device-label {
fill: var(--text); fill: var(--text);
font-size: 12px; font-size: 12px;
@@ -213,7 +239,100 @@ body {
.canvas-wrap.tool-device #canvas, .canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas *, .canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas, .canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; } .canvas-wrap.tool-io #canvas *,
.canvas-wrap.tool-clamp #canvas,
.canvas-wrap.tool-clamp #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
/* Clamps — small grey rounded squares (v5 §11). Cables route through
them in `ord` sequence. */
.clamp {
fill: rgba(120, 120, 120, 0.85);
stroke: rgba(40, 40, 40, 0.85);
stroke-width: 1.5;
cursor: grab;
}
.clamp.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.clamp-label {
fill: var(--text-muted);
font-size: 10px;
pointer-events: none;
}
/* Shared-segment count badge — m sees ×N next to clamps that route
≥ 2 cables. */
.clamp-badge {
fill: var(--text);
font-size: 10px;
font-weight: 700;
pointer-events: none;
}
/* Bundle overlay — thick striped polyline drawn on top of individual
cables along shared segments. v5 §11.3. */
.bundle-line {
fill: none;
pointer-events: none;
opacity: 0.85;
}
.btn-link {
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
font: inherit;
padding: 0 4px;
line-height: 1;
}
.btn-link:hover { color: var(--danger); }
/* Highlight a port that's been picked as the cable-draw source. */
.port-circle.cable-from {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
/* Zoom cluster — % + Fit button next to Admin. */
.zoom-cluster {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
#zoom-pct {
font-size: 12px;
color: var(--text-muted);
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.canvas-wrap.panning #canvas,
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
.canvas-wrap.space-pan-ready #canvas,
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
/* 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 /* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the the Power cable_type colour (#e03131). Rotated 45° rect is the
@@ -238,15 +357,16 @@ body {
user-select: none; user-select: none;
} }
/* Ports — small circles laid out along the device edge. The fill is /* Ports — small circles laid out along the device edge. Both fill and
white so the port is visible regardless of the underlying device's stroke come from the cable_type the port carries (set inline in JS)
stroke; the stroke colour comes from the cable_type the port carries so the port reads clearly as a coloured anchor on the device. */
(set inline in JS). */
.port-circle { .port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2; stroke-width: 2;
pointer-events: none; /* slice 4 — selection happens at device-level */ cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
} }
.port-row { .port-row {
@@ -255,13 +375,20 @@ body {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
padding: 2px 0; padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
} }
.port-row .swatch { .port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15); border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
} }
.port-row .label { color: var(--text); } .port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; } .port-row .conn { color: var(--text-muted); font-size: 11px; }
@@ -328,8 +455,121 @@ body {
.cable-line:hover { stroke-width: 4; } .cable-line:hover { stroke-width: 4; }
.cable-line.selected { stroke-width: 4; } .cable-line.selected { stroke-width: 4; }
/* Endpoint handles — only rendered for the currently-selected cable.
Grab cursor on idle, grabbing while dragging (.replugging on root). */
.cable-handle {
cursor: grab;
stroke-width: 2;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
}
.cable-handle:hover { stroke-width: 3; }
.canvas-wrap.replugging .cable-handle,
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
/* Solve preview-diff modal */ /* Solve preview-diff modal */
.modal-wide { width: 560px; } .modal-wide { width: 560px; }
/* Admin modal — wider, tabbed */
.modal-wide.admin-shell-host { width: 760px; }
#modal-admin { width: 760px; max-width: 90vw; }
.admin-shell { padding: 16px; min-height: 460px; }
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.admin-header h2 { margin: 0; }
.admin-close { font-size: 16px; padding: 4px 8px; }
.admin-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.admin-tab {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 8px 12px;
font: inherit;
color: var(--text-muted);
cursor: pointer;
}
.admin-tab:hover { color: var(--text); }
.admin-tab[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
.admin-body {
font-size: 13px;
max-height: 60vh;
overflow-y: auto;
}
.admin-row {
display: grid;
gap: 6px 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.admin-row:last-child { border-bottom: 0; }
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
.admin-row .field input,
.admin-row .field textarea,
.admin-row .field select {
width: 100%;
font: inherit;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
.admin-row.locked { opacity: 0.85; }
.admin-row .locked-badge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--surface-2);
color: var(--text-muted);
}
.admin-row-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.admin-row-title .swatch { display: inline-block; }
.admin-empty { color: var(--text-muted); padding: 16px 0; }
.admin-add-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.port-profile-list {
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--text-muted);
}
.port-profile-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.tmpl-detail {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
.sv-body { font-size: 13px; } .sv-body { font-size: 13px; }
.sv-body h3 { .sv-body h3 {
font-size: 11px; font-size: 11px;

View File

@@ -1,5 +1,5 @@
// Package web bundles the frontend (HTML/JS/CSS) into the Go binary // Package web bundles the frontend (HTML/JS/CSS) into the Go binary
// via embed.FS so deploying mCables means shipping one file. // via embed.FS so deploying CableGUI means shipping one file.
package web package web
import ( import (